BBYR Achieve
返回信息流
这是一条镜像帖。来源:北邮人论坛 / soft-design / #45147同步于 2014/9/11
该镜像源已超过 30 天没有更新,可能在源站已被删除。
SoftDesign机器人发帖

goroutine sched 菜鸟漫谈

zxsword
2014/9/11镜像同步8 回复
本文的golang源码版本为go1.3。 开头首先对goroutine做一个简单的介绍。 goroutine是golang的协程,以下代码是一个简单的示例: package main import "fmt" import "runtime" runtime.GOMAXPROCS(4) func f(msg int){ fmt.Println(msg) } func main() { go f(1) go f(2) go f(3) go f(4) go func(msg int){ fmt.Println(msg) }(5) } 如上例所示,以关键字go开始的代码就是一个goroutine。 这些goroutine都是并发执行的。所以12345到底那个数字会最先打印出来呢?另外这5个协程是“真的”同时在执行吗? 第一个问题,12345理论上可能以任意的顺序打印出来,这些goroutine都是并行的在向标准输出打印。 至于第二个问题,这5个协程并不是同时在执行,因为在代码中设置了GOMAXPROCS(4),所以虽然有5个goroutine,但最多只会有4个处理器在执行我们的代码。 goroutine是golang最大的优点,如代码所示,需要异步执行的函数只需要以go关键字去调用就可以了,简单好写。当需要异步执行一段代码时,我们就可以开一个goroutine。我们完全可以开上万个goroutine,开上万个goroutine并不代表着系统中会多了上万个线程。goroutine是协程,并不是线程。 golang的协程和线程之间的关系是M:N的关系。有几种协程模型: 第一种是1:1的模型,一个协程对应于一个线程,缺点就是协程显得过于重了,协程的上下文切换开销也比较大。 第二种是M:1的模型,所有的协程放在一个OS线程中,缺点是无法利用更多的CPU。 第三种就是golang所采用的模式,M:N的模型,缺点是实现起来比较复杂。 上文所示代码中的5个goroutine都是简单的goroutine,每个goroutine都进行打印操作(有系统调用write),个人感觉以系统调用切入到goroutine的调度是一个比较好的切入点,下文将会从这个角度来阅读goroutine的源代码。
订阅后,新回复会通过你的通知中心匿名送达。
8 条回复
zxsword机器人#1 · 2014/9/11
本文所关注的就是goroutine的一些内部细节,所以接下来就深入go/src/pkg/runtime/proc.c来看一看golang的协程及其调度的实现。 首先需要说明的是最重要的3个struct:G,M,P // Goroutine scheduler // The scheduler's job is to distribute ready-to-run goroutines over worker threads. // // The main concepts are: // G - goroutine. // M - worker thread, or machine. // P - processor, a resource that is required to execute Go code. // M must have an associated P to execute Go code, however it can be // blocked or in a syscall w/o an associated P. // // Design doc at http://golang.org/s/go11sched. 如注释所示,最重要的3个数据结构依次为G-goroutine,M-OSthread,P-processor or context。 文章开头代码中的GOMAXPROCS(4)就设定了P的数目为4。 当一个goroutine在执行时,必备的3个要素就是GMP。 |-----| | M0 | |-----| | | |-----| |------| | P0 |------| G1 | |-----| |------| | | |------| | G2 | |------| GMP的关系如图所示。线程M0占有了处理器P0,P0有可执行的goroutine G1和G2,此时正在执行G1。(为什么不是G0呢,下文会讲到)所以go代码执行的三要素就是GMP,当这三者都有的情况下,go代码就能run了。 假如此时goroutine G1调用了一个系统调用时,例如seek系统调用,seek系统调用一般来说会很快完成,所以上图不变,goroutine依旧在run。 但如果G1执行了一个阻塞的系统调用例如read,此时珍贵的CPU资源当然不能浪费,此时P0会被别的M拿走去执行G。上图就变成了: |-----| | M0 | |-----| | | |-----| | G1 | |-----| 接下来就深入代码,跟随着一次系统调用来观察golang调度的实现和原理。 开始阅读代码之前,有两个关键的寄存器变量需要简单的说一下: g --> cur g m --> cur m 读代码时会在任意时候看到g和m,g时一个cpu寄存器,m也是一个cpu寄存器,g指向了当前正在执行的G,m指向了当前正在执行的M。 另外还需要注意的是有两个runq来存放runnable的goroutine,一个是全局的runq,另外一个是每个P私有的runq。
zxsword机器人#2 · 2014/9/11
以上文中的两张图切入到goroutine的内部代码中,首先需要看一下goroutine的系统调用代码。 golang对系统调用做了封装,如下代码所示: //syscall/asm_linux_386.s TEXT ·Syscall(SB),NOSPLIT,$0-28 CALL runtime·entersyscall(SB) ... CALL *runtime·_vdso(SB) ... CALL runtime·exitsyscall(SB) RET 对系统调用进行封装是有必要的,否则无法完成上文中两图之间的转换。vdso是linux对系统调用的封装,也就是系统调用代码,中间的汇编代码省略。 可以从代码中看到,在进行系统调用之前和之后有runtime·entersyscall和runtime·exitsyscall的系统调用。 proc.c void ·entersyscall(int32 dummy) { save(runtime·getcallerpc(&dummy), runtime·getcallersp(&dummy)); g->status = Gsyscall; m->mcache = nil; m->p->m = nil; runtime·atomicstore(&m->p->status, Psyscall); //省略部分代码 } 如上代码所示,G和P的status都为syscall。 在golang中,有一个系统monitor线程,以最大10ms的间隔扫描监控系统(开始以20us的频次扫描,最大会增大到10ms,若有一些触发事件发生,则重置睡眠时间为20us)。参看proc.c: static void sysmon(void) { for(;;){ if(idle == 0) // start with 20us sleep... delay = 20; else if(idle > 50) // start doubling the sleep after 1ms... delay *= 2; if(delay > 10*1000) // up to 10ms delay = 10*1000; runtime·usleep(delay); // retake P's blocked in syscalls // and preempt long running G's if(retake(now)) idle = 0; else idle++; } } 接下来简单看一下retake函数: // retake仅被sysmon调用。扫描所有的P,若Psyscall,则在一定条件下调用startm(通过handoffp),并重置Psyscall状态为Pidle。若Prunning,则抢占运行超过10ms的g。 static uint32 retake(int64 now) { n = 0; for(i = 0; i < runtime·gomaxprocs; i++) { p = runtime·allp[i]; s = p->status; if(s == Psyscall) { if(check p->syscalltick) continue if(runtime·cas(&p->status, s, Pidle)) { n++; handoffp(p); } } else if(s == Prunning) { // Preempt G if it's running for more than 10ms. if(pd->schedwhen + 10*1000*1000 > now) continue; preemptone(p); } } return n; } 接下来简单看一下handoffp(p)函数: // Hands off P from syscall or locked M. static void handoffp(P *p) { if(...) { startm(p, ture); return; } if(...) { startm(p, false); return; } ... pidleput(p); } 如代码所示,要么在P上startm,要么将p放入一个idle P的list中。 // Schedules some M to run the p (creates an M if necessary). // If p==nil, tries to get an idle P, if no idle P's does nothing. static void startm(P *p, bool spinning) { M *mp; void (*fn)(void); mp = mget(); if(mp == nil) { fn = nil; if(spinning) fn = mspinning; newm(fn, p); return; } mp->spinning = spinning; mp->nextp = p; runtime·notewakeup(&mp->park); } 代码读到这里。P这个资源已经被一个新的M所抢走了,不是么?此时某一个os thread,也就是M占有着宝贵的处理器P,执行着goroutine。而原来的M0和G1则阻塞在系统调用。
zxsword机器人#3 · 2014/9/11
代码读到这里。P这个资源已经被一个新的M所抢走了,不是么?此时某一个os thread,也就是M占有着宝贵的处理器P,执行着goroutine。而原来的M0和G1则阻塞在系统调用。 那假如是不阻塞的系统调用怎么办啊?也会将宝贵的处理器P让给别的os thread M去执行么?在上文中省略了大量的代码,其实retake函数在handoffp时是有条件的,会对当前P的syscalltick进行一些简单的判断。大致是这么判断的,如果是第一次看到某个P在Psyscall状态,并不会抢走P,但是若睡一会醒来之后第二次看见这个P在Psyscall状态,那就没商量了,必须要把宝贵的处理器资源从M抢走给别的M去执行,毕竟还有很多goroutine等着执行需要处理器P呢。 现在呢,上文图中的M和G都阻塞在系统调用中,而另外一些M和G占有着P在快乐的running着。但是就像灰太郎喊的口号一样(或是别的反派喊的?),M和G总会从阻塞的系统调用中回来滴!接下来 //syscall/asm_linux_386.s TEXT ·Syscall(SB),NOSPLIT,$0-28 CALL runtime·entersyscall(SB) ... CALL *runtime·_vdso(SB) ... CALL runtime·exitsyscall(SB) RET 需要读的函数是proc.c: runtime·exitsyscall(SB) void runtime·exitsyscall(void) { if(exitsyscallfast()) { // There's a cpu for us, so we can run. m->p->syscalltick++; g->status = Grunning; return; } runtime·mcall(exitsyscall0); m->p->syscalltick++; } 注意代码中有两部分内容,一部分是根据注释所述M是有P的,所以 g->status = Grunning; 之后就可以继续的执行下去了。 而另外一部分则会在当前m的g0栈上执行exitsyscall0函数。关于这个g0,我是这么理解的,是线程自己所有的一个idle goroutine,也是线程自己所有的调度器执行所在的goroutine,类似于linux os中的idle线程。 接下来看看exitsyscallfast和exitsyscall0: static bool exitsyscallfast(void) { P *p; // Try to re-acquire the last P. cas操作是原子操作,check and set。 if(m->p && m->p->status == Psyscall && runtime·cas(&m->p->status, Psyscall, Prunning)) { // There's a cpu for us, so we can run. m->mcache = m->p->mcache; m->p->m = m; return true; } // Try to get any other idle P. m->p = nil; if(runtime·sched.pidle) { p = pidleget(); if(p) { // acquirep是将参数p和m联系起来的函数。 acquirep(p); return true; } } return false; } // runtime·exitsyscall slow path on g0. // Failed to acquire P, enqueue gp as runnable. static void exitsyscall0(G *gp) { P *p; gp->status = Grunnable; gp->m = nil; m->curg = nil; p = pidleget(); if(p == nil) globrunqput(gp); if(p) { acquirep(p); execute(gp); // Never returns. } stopm(); schedule(); // Never returns. } exitsyscall0是当前os线程m在m的g0上执行的函数。如果能够获得一个空闲的P,那么将P和M联系起来之后就可以继续执行goroutine了。如果没有空闲的处理器P资源的话,那么当前os线程stop,然后开始调用schedule()函数。 到这里,GMP在系统调用前后的关系就已经讲完了。如果一个发起系统调用的os线程M没有处理器P可以执行时,会将goroutine放入全局队列中,然后stop,再然后调用schedule()。 注意,stopm函数首先将m放入idleM list中,当stopm函数返回时,这个M已经又拿到了宝贵的资源P。拿到资源P之后总得有活可干吧,于是调用schedule来获取goroutine好开始干活。
zxsword机器人#4 · 2014/9/11
开始读schedule之前,首先梳理一下当前的GMP。M和P都是有的,G是M的g0。接下来进入到schedule函数中。 // One round of scheduler: find a runnable goroutine and execute it. // Never returns. static void schedule(void) { G *gp; uint32 tick; top: if(runtime·sched.gcwaiting) { // Stops the current m for stoptheworld. // Returns when the world is restarted. gcstopm(); goto top; // 这段代码没有略去,来展示一下goroutine致命的缺点-GC。GC总是要付出代价的,有的语言是将GC的代价平摊到每个对象上,而golang的做法是定期扫描,所以虽然平时仿佛没有GC一样快,但是呢偶尔stoptheworld去GC,也就实在没办法啦。当然这个也是逐渐在进步呢。曾经在某篇博客中看到百万对象的gc停顿随着golang版本的提升,从10ms下降到2ms了。 } gp = nil; tick = m->p->schedtick; if(tick - (((uint64)tick*0x4325c53fu)>>36)*61 == 0 && runtime·sched.runqsize > 0) { gp = globrunqget(m->p, 1); } if(gp == nil) { // 从本地获得可执行的g。 gp = runqget(m->p); } if(gp == nil) { gp = findrunnable(); // blocks until work is available } if(gp->lockedm) { // Hands off own p to the locked m, // then blocks waiting for a new p. startlockedm(gp); goto top; } execute(gp); } 在进入schedule时,有M有P没有G,自然要获取一个runnable goroutine。先尝试从全局的runnable goroutine queue中拿goroutine, 然后再尝试从本地P的runq中去拿,如果还是没有的话,就调用findrunnable尝试去从别的P的runq中偷一些goroutine出来。如果很不幸拿到的goroutine比较特殊的话,那么goto top重新再试图拿一个goroutine了。 最终MP有了G,可以欢快的开始执行了。 // Schedules gp to run on the current M. // Never returns. static void execute(G *gp) { gp->status = Grunning; m->p->schedtick++; m->curg = gp; gp->m = m; // void gogo(Gobuf*) // restore state from Gobuf; longjmp runtime·gogo(&gp->sched); } 从runtime.gogo这个汇编函数中也可以看到goroutine的上下文切换确实比os thread要轻量级很多,上下文切换就一个Gobuf就足够了。附上结构体定义: struct Gobuf { // The offsets of sp, pc, and g are known to (hard-coded in) libmach. uintptr sp; uintptr pc; G* g; void* ctxt; uintreg ret; uintptr lr; }; schedule的工作是调度goroutine,只在表层读一下schedule函数总觉的有些不够,深入读一把 gp = globrunqget(m->p, 1); gp = runqget(m->p); gp = findrunnable(); 三个函数。先易后难,首先是:gp = runqget(m->p); // Get g from local runnable queue. // Executed only by the owner P. static G* runqget(P *p) { G *gp; uint32 t, h; for(;;) { h = runtime·atomicload(&p->runqhead); // load-acquire, synchronize with other consumers t = p->runqtail; if(t == h) return nil; gp = p->runq[h%nelem(p->runq)]; if(runtime·cas(&p->runqhead, h, h+1)) // cas-release, commits consume return gp; } } 从P自己的runq拿出来一个goroutine比较简单,不再赘述。 接下来是:gp = globrunqget(m->p, 1); // Try get a batch of G's from the global runnable queue. static G* globrunqget(P *p, int32 max) { G *gp, *gp1; int32 n; if(runtime·sched.runqsize == 0) return nil; if(max > 0 && n > max) n = max; // 省略check n的代码。最多只拿走runqsize的一半, 即128。 runtime·sched.runqsize -= n; // 以下代码完成了从runq中拿出G的工作,第一个直接返回,其余的存放到p的runq中。代码省略。 return gp; } 对于schedule函数而言,传入的max为1, 所以只从全局runq中拿一个goroutine。 最后一个函数是:gp = findrunnable(); 当本地没有goroutine,全局的runq中也有没有goroutine,那只能去外面找找看有没有可以干的活了,一直到找到工作为止。 // Finds a runnable goroutine to execute. // Tries to steal from other P's, get g from global queue, poll network. static G* findrunnable(void) { G *gp; P *p; int32 i; top: if(runtime·sched.gcwaiting) { gcstopm(); goto top; // 又看到gcstopm了哦 } // local runq gp = runqget(m->p); if(gp) return gp; // global runq if(runtime·sched.runqsize) { gp = globrunqget(m->p, 0); //上文中读过这个函数,当传入参数0时,拿到本地runq的goroutine就多了。 if(gp) return gp; } // poll network gp = runtime·netpoll(false); // non-blocking // golang对网络io进行了优化。 if(gp) { injectglist(gp->schedlink); gp->status = Grunnable; return gp; } if(...) goto stop; // random steal from other P's for(i = 0; i < 2*runtime·gomaxprocs; i++) { p = runtime·allp[runtime·fastrand1()%runtime·gomaxprocs]; if(p == m->p) gp = runqget(p); else // Steal half of elements from local runnable queue of p2 // and put onto local runnable queue of p. // Returns one of the stolen elements (or nil if failed). gp = runqsteal(m->p, p); if(gp) return gp; } stopm(); goto top; } 注意,代码中有大量细节省略。 至此,通过读代码,goroutine的schedule策略大致可以总结为: 从全局runq,本地runq,或者出去偷一点goroutine,然后开始执行。
zxsword机器人#5 · 2014/9/11
直接emacs写的纯文本,分段发到论坛上吧。
zxsword机器人#6 · 2015/4/28
golang已经1.4了呀。不知道sched这块变动多大,貌似gc改动很多,stoptheworld这个函数还在不在了。 吾生也有涯,唉。
iFadeToBlack机器人#7 · 2015/5/7
能只用code标签标示代码吗?没换行看着好难受
zxsword机器人#8 · 2015/5/8
当初是写在公司内部的wiki上的,粘过来太懒了。 如今又辞职闲适了,回头重写一份go1.4的=。= 【 在 iFadeToBlack 的大作中提到: 】 : 能只用code标签标示代码吗?没换行看着好难受 来自「北邮人论坛手机版」