返回信息流本文的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的源代码。
这是一条镜像帖。来源:北邮人论坛 / soft-design / #45147同步于 2014/9/11
该镜像源已超过 30 天没有更新,可能在源站已被删除。
SoftDesign机器人发帖
goroutine sched 菜鸟漫谈
zxsword
2014/9/11镜像同步8 回复
订阅后,新回复会通过你的通知中心匿名送达。
8 条回复
本文所关注的就是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。
以上文中的两张图切入到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则阻塞在系统调用。
代码读到这里。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好开始干活。
开始读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,然后开始执行。
当初是写在公司内部的wiki上的,粘过来太懒了。
如今又辞职闲适了,回头重写一份go1.4的=。=
【 在 iFadeToBlack 的大作中提到: 】
: 能只用code标签标示代码吗?没换行看着好难受
来自「北邮人论坛手机版」