goroutine 相关知识7

  • go channel 与 java 等价操作

  • Go中如何实现协程

  • GMP 模型

  • Go 调度器模型与演化过程

// 声明channel变量
var syncChan = make(chan int)  		// 无缓冲channel,主要用于两个Goroutine之间建立同步点
var cacheChan = make(chan int, 10)  // 缓冲channel

// 向channel中写入数据
syncChan <- 1
cacheChan <- 1

// 从channel读取数据
var i = <-syncChan
var j = <-cacheChan

几乎等价于的 Java 中的操作:

TransferQueue<Integer> syncQueue = new LinkedTransferQueue<Integer>();
BlockingQueue<Integer> cacheQueue = new ArrayBlockingQueue<Integer>(10); 

syncQueue.transfer(1);
cacheQueue.put(1);

int i = syncQueue.take();
int j = cacheQueu.take();

golang语言实现协程

只需要通过go关键字,执行某个函数就可以实现协程

go function(x,y,z)

golang调度器创建一个协程

具体

  1. 调用runtime.newproc将
  • 参数大小
  • 新的goroutine所运行的函数
  • 函数的n个参数传入
  1. 创建一个栈空间 保存在结构体G
  • 栈基址
  • 栈可用空间下界等信息
  1. 结构体G再关联到一个队列中

  2. 每个方法运行方式以栈帧的方式进行调用

jvm,某个线程中运行函数也是有这样的操作,创建一个新的栈空间

GMP 模型

G

struct G {
	uintptr stackguard; // 分段栈可用空间下界
	uintptr stackbase; // 分段栈基址(这两个参数是实现分段栈的关键)
	Gobuf sched; // 进程切换时,利用sched域保存上下文
	FunVal* fnstart; // goroutine运行的函数
	void* param; // 函数中使用的参数
	int16 status; // goroutine状态(Gidle,Grunning,Gwaiting等)
	int64 goid; // goroutine的id号
	M* lockedm; // G被锁定在这个M上运行
	......
};

作用

  • 类似与进程控制块,负责控制和管理goroutine的生老病死

M

strcut M {
	G* g0; // 带有调度栈的goroutine
	G* gsignal; // signal-handling G 处理信号的goroutine
	void (*mstartfn)(void);
	G* curg; // M中当前运行的goroutine
	P* p; // 关联P以执行Go代码 (如果没有执行Go代码则P为nil)
	P* nextp;
	int32 id;
	int32 mallocing; //状态
	int32 helpgc; //不为0表示此m在做帮忙gc。helpgc等于n只是一个编号
	M* alllink; // 这个域用于链接allm
	M* schedlink;
	MCache *mcache;
	G* lockedg; // 某些情况下,G锁定在这个M中运行而不会切换到其它M中去
	M* nextwaitm; // next M waiting for lock
	GCStats gcstats;
	......
};

M中包含了当前运行的G以及关联的P,还有自己的内存缓存MCache

作用

对机器的抽象,代表真正的OS线程,每个M都是对应到一条操作系统的物理线程

P

代表CPU处理器,保存调度上下文,实现work-stealing算法的关键,通俗的说就是提高Go语言的并发度(通常P=GOMAXPROCS=CPU核数)

struct P{
	Lock;
	uint32 status; // P的状态
	M* m; // 链接到它关联的M
	G *gfree; // freelist, P当前运行的runqueue
	G *ghead; // runnable, moved from sched
	G *gtail;
	MCache *mcache; // moved from M
	FixAlloc *stackalloc; // moved from M
	uint64 ncgocall;
	GCStats gcstats;
	......
};

早期 GM 模型 创建/终止/需要调度 Goroutine时,需要全局锁保护调度相关对象(sched),全局锁严重影响Goroutine的并发性能

加入 P 后

  • G需要绑定M才能运行
  • M需要绑定P才能运行
  • 多个M并不会同时都处于执行状态,最多GOMAXPROCS个M在执行

简单总结

通过go关键字-->调用runtime.newproc将信息传入并创建新的栈空间-->包装为结构体G-->放入P的runqueue等待执行

高并发其实就是goroutine在等待执行过程中当前队列若发生阻塞,可以随时交由其他线程处理

Go 调度器模型与演化过程

G-M 模型

go 1.0

M:1 的 线程池+任务队列形式

  • 每个内核级线程运行一个 M
  • 每个M不停地从任务队列shed中取出任务G并执行

Scalable Go Scheduler Design 指出这样的调度器设计上的一些问题

  • 所有G操作都由单个全局锁sched.Lock保护,14%的时间浪费在了runtime.futex()

比如:创建、重新调度等都要上锁

  • 当M阻塞时,G需要传递给别的M执行,这导致延迟和额外的负担

M用到的mCache是属于内核线程的,当M阻塞后相应的内存资源仍被占用着

  • 由于syscall造成的过多M(worker thread)阻塞和恢复,导致额外的性能损耗

G-P-M 模型

通过work stealing算法实现了M:N的G-P-M模型

通过引入逻辑Processer P来解决G-M模型的几大问题

  • P优先从本地G队列中获取任务执行,这个操作线程安全不用Lock
  • G存在于P队列中。当某个M陷入系统调用时,P将与M解绑定,并找另外的M(或创建新M)来执行G的代码,避免G在M之间传递
  • P给M运行提供了运行所需的mCache内存,当PM解绑时将收回内存

计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决

向 G-M 模型中增加了一个 P,实现了 Go scheduler 的 scalable

P(逻辑 Proccessor),G 想真正运行起来,需要被分配一个 P(进入到 P 的 local runq 中,暂忽略 global runq 环节)

对于 G 来说,P 就是运行它的 “CPU”

但从 Go scheduler 视角来看,真正的 “CPU” 是 M,只有将 P 和 M 绑定才能让 P 的 runq 中 G 得以真实运行起来

P 与 M 的关系,好比 os 调度层面用户线程 (user thread) 与核心线程 (kernel thread) 的对应关系那样(N x M)

抢占式调度

Scheduler 遗留一个头疼问题 : 不支持抢占式调度

某个 G 死循环或永久循环,G 永久占用分配给它的 P 和 M

同一个 P 中的其他 G 得不到调度, “饿死”

更严重的是,只有一个 P  (GOMAXPROCS=1) 时,整个程序中的其他 G 都将“饿死”

Go 1.2 中实现了 “抢占式” 调度

原理

每个函数或方法的入口,加上一段额外的代码,让 runtime 有机会检查是否需要执行抢占调度

局部解决了 “饿死” 问题,对于没有函数调用,纯算法循环计算的 G,scheduler 依然无法抢占

网络与文件调度优化

Go runtime 实现了 netpoller,使 G 发起网络 I/O 操作不会导致 M 被阻塞(仅阻塞 G), 不会大量创建 M

但 regular file 的 I/O 操作一旦阻塞,M sleep,等待 I/O 返回后被唤醒

P 与 sleep 的 M 分离,再选择一个 idle 的 M

如果没有 idle 的 M,新建一个 M,这就是为何大量 I/O 操作导致大量 Thread 被创建的原因

Data Structure

type g struct {
    stack       stack   // offset known to runtime/cgo
    stackguard0 uintptr // offset known to liblink
    stackguard1 uintptr // offset known to liblink
    m              *m      // current m
    stackAlloc     uintptr // [stack.lo,stack.lo+stackAlloc)
    sched          gobuf
    
    goid           int64
    waitsince      int64  // approx time when g blocked
    waitreason     string // if status==Gwaiting
    schedlink      guintptr
    preempt        bool   // preemption signal
    lockedm        *m
    gopc           uintptr // pc of go statement
    startpc        uintptr // pc of goroutine function
    readyg         *g     // scratch for readyExecute
  ...
}
  • goroutine通过go关键字调用runtime.newProc创建
  • 创建后即被放在P本地队列或全局队列中,等待被执行
  • 存储了goroutine执行的栈信息,状态,任务函数
  • 执行的func入口存在startPC域

由于它可以被任何P获取并执行

  • G在stack字段保存了goroutine栈的可用空间
  • 在sched域保存了goroutine切换所需的全部上下文
type p struct {
    lock mutex
    id          int32
    status      uint32 // one of pidle/prunning/...
    link        puintptr
    schedtick   uint32   // incremented on every scheduler call
    syscalltick uint32   // incremented on every system call
    m           muintptr // back-link to associated m
    mcache      *mcache
    // Queue of runnable goroutines. Accessed without lock.
    runqhead uint32
    runqtail uint32
    runq     [256]*g
    runnext guintptr
    // Available G's (status == Gdead)
    gfree    *g
    palloc persistentAlloc // per-P to avoid mutex
    // Per-P GC state
  ...
}

P 通常设置为CPU核数(GOMAXPROCS)

  • 维护本地的G队列
  • 为M运行提供内存资源mCache
  • 当M陷入阻塞的系统调用导致P和M分离时,P可以回收内存资源

P在本地runq中取任务G执行时不用lock,但是从别的P中stealG或从全局队列中获取G时需要lock

当有新的G加入到队列时,会试图唤醒空闲的P。如果有空闲的P,会试图找空闲的M或创建新的M来执行,也就是说M是按需创建的

type m struct {
    g0      *g     // goroutine with scheduling stack
    morebuf gobuf  // gobuf arg to morestack
    curg          *g       // current running goroutine
    p             puintptr // attached p for executing go code 
    nextp         puintptr
    id            int32
    alllink       *m // on allm
    mcache        *mcache
  
    mallocing     int32
    throwing      int32
    preemptoff    string // if != "", keep curg running on this m
    locks         int32
    dying         int32
    helpgc        int32
    spinning      bool // m is actively looking for work
    blocked       bool // m is blocked on a note
    inwb          bool // m is executing a write barrier
  
      mstartfn      func()
  ...
}

mstartfn是M线程创建时的入口函数地址

M与某个P绑定后,m.mcache指向P.mCache,同时获取了P和当前的G 然后M进入schedule循环

shedule的机制大致是:

  • 从当前P的队列、全局队列或者别的P队列中 findrunnableG
  • 栈指针从调度器栈m->g0切换到G自带的栈内存,并在此空间分配栈帧,执行G函数
  • 执行完后调用goexit做清理工作并回到M,如此反复
  • 如果遇到G切换,要将上下文都保存在G当中,使得G可以被任何P绑定的M执行。M只是根据G提供的状态和信息做相同的事情
type schedt struct {
    lock mutex
    goidgen uint64
    midle        muintptr // idle m's waiting for work
    nmidle       int32    // number of idle m's waiting for work
    nmidlelocked int32    // number of locked m's waiting for work
    mcount       int32    // number of m's that have been created
    maxmcount    int32    // maximum number of m's allowed (or die)
    pidle      puintptr // idle p's
    npidle     uint32
    nmspinning uint32
    // Global runnable queue.
    runqhead guintptr
    runqtail guintptr
    runqsize int32
    // Global cache of dead G's.
    gflock mutex
    gfree  *g
    ngfree int32
    gcwaiting  uint32 // gc is waiting to run
    stopwait   int32
    stopnote   note
    sysmonwait uint32
    sysmonnote note
    lastpoll   uint64
  ...
}

全局的任务队列保存了空闲m和空闲p的链表,以及全局G队列链表


Go调度器解决的问题

阻塞问题

G 陷入到阻塞的系统调用,内核线程 M 将一起阻塞,实际运行线程少了一个

如果所有M都阻塞了,那些本可以运行的任务G将没有系统资源运行

  • 封装系统调用entersyscallblock,使进入阻塞的系统调用前执行releaseP和handOffP,剥夺M拥有的P的mCache
  • 如果P本地队列还有G,P将去找别的M或创建新的M来执行,若没有则直接放进pidle全局链表
  • 当有新的G加入时可以通过wakeP获取这个空闲的P

进入非阻塞的系统调用entersyscall时会将P设置为Psyscall。监控线程sysmon在遍历P,发现Psyscall时将执行handOffP

抢占调度

go没有实现像内核一样的时间分片,设置优先级等抢占调度,只引入了初级的抢占

监控线程sysmon循环retake,发现阻塞于系统调用或运行了较长时间(10ms),发起抢占preemption(P)

抢占只是把G的stack设置为stackPreempt,这样下一次函数调用时检查栈空间会失败,触发morestack

morestack中发现是stackPreempt会触发goschedImpl,通过dropg将M和G分离,然后从全局获取runq重新进入schedule循环

栈分配

太大会造成浪费,太小又会溢出,需要栈可以自动增长

G 初始栈仅2KB

每次函数调用时调度器检测栈大小,若不够则触发中断(比较栈指针寄存器SP和g->stackguard,SP更大则用runtime.morestack扩展栈空间)

morestack保存了当前现场到m的morebuf域,并调用runtime.newstack

newstack需要切换到调度器栈m->g0去调用。它分配一个大的新空间,将旧栈中的数据复制过去。这个过程中要保证原来指向旧栈空间的指针不会失效,要对这些指针进行调整

G初始栈仅有2KB。在每次执行函数调用时调度器都会检测栈的大小,若发现不够用则触发中断。检查方法是比较栈指针寄存器SP和g->stackguard,如果SP更大则需要调用runtime.morestack扩展栈空间。其中morestack保存了当前现场到m的morebuf域,并调用runtime.newstack。

newstack需要切换到调度器栈m->g0去调用。它分配一个大的新空间,将旧栈中的数据复制过去。这个过程中要保证原来指向旧栈空间的指针不会失效,要对这些指针进行调整。

func newstack() {
    thisg := getg()
...
    casgstatus(gp, _Grunning, _Gwaiting)
    gp.waitreason = "stack growth"
  
    // Allocate a bigger segment and move the stack.
    oldsize := int(gp.stackAlloc)
    newsize := oldsize * 2
    if uintptr(newsize) > maxstacksize {
        throw("stack overflow")
    }
    casgstatus(gp, _Gwaiting, _Gcopystack)
    copystack(gp, uintptr(newsize))
    casgstatus(gp, _Gcopystack, _Grunning)
    gogo(&gp.sched)
}

新栈复制好以后仍然是调用gogo切换栈执行,这跟schedul中gogo到G函数一样

栈切换完成以后通过JMP返回到被中断的函数

直到遇到return才会返回到runtime.lessstack进行栈收缩

G的任务return时将返回到goexit清理现场

转载于:https://my.oschina.net/zhangthe9/blog/3021488

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值