goroutine的生老病死
2021.07

5.2 goroutine的生老病死

本小节将通过goroutine的创建,消亡,阻塞和恢复等过程,来观察Go语言的调度策略,这里就称之为生老病死吧。整个Go语言的调度系统是比较复杂的,为了避免结构体M和结构体P引入的其它干扰,这里主要将注意力集中到结构体G中,以goroutine为主线。

goroutine的创建

前面讲函数调用协议时说过go关键字最终被弄成了runtime.newproc。这就是一个goroutine的出生,所有新的goroutine都是通过这个函数创建的。

runtime.newproc(size, f, args)功能就是创建一个新的g,这个函数不能用分段栈,因为它假设参数的放置顺序是紧接着函数f的(见前面函数调用协议一章,有关go关键字调用时的内存布局)。分段栈会破坏这个布局,所以在代码中加入了标记#pragma textflag 7表示不使用分段栈。它会调用函数newproc1,在newproc1中可以使用分段栈。真正的工作是调用newproc1完成的。newproc1进行下面这些动作。

首先,它会检查当前结构体M中的P中,是否有可用的结构体G。如果有,则直接从中取一个,否则,需要分配一个新的结构体G。如果分配了新的G,需要将它挂到runtime的相关队列中。

获取了结构体G之后,将调用参数保存到g的栈,将sp,pc等上下文环境保存在g的sched域,这样整个goroutine就准备好了,整个状态和一个运行中的goroutine被中断时一样,只要等分配到CPU,它就可以继续运行。

newg->sched.sp = (uintptr)sp;
newg->sched.pc = (byte*)runtime·goexit;
newg->sched.g = newg;
runtime·gostartcallfn(&newg->sched, fn);
newg->gopc = (uintptr)callerpc;
newg->status = Grunnable;
newg->goid = runtime·xadd64(&runtime·sched.goidgen, 1);

然后将这个“准备好”的结构体G挂到当前M的P的队列中。这里会给予新的goroutine一次运行的机会,即:如果当前的P的数目没有到上限,也没有正在自旋抢CPU的M,则调用wakep将P立即投入运行。

wakep函数唤醒P时,调度器会试着寻找一个可用的M来绑定P,必要的时候会新建M。让我们看看新建M的函数newm:

// 新建一个m,它将以调用fn开始,或者是从调度器开始
static void
newm(void(*fn)(void), P *p)
{
    M *mp;
    mp = runtime·allocm(p);
    mp->nextp = p;
    mp->mstartfn = fn;
    runtime·newosproc(mp, (byte*)mp->g0->stackbase);
}

runtime.newm功能跟newproc相似,前者分配一个goroutine,而后者分配一个M。其实一个M就是一个操作系统线程的抽象,可以看到它会调用runtime.newosproc。

总算看到了从Go的运行时库到操作系统的接口,runtime.newosproc(平台相关的)会调用系统的runtime.clone(平台相关的)来新建一个线程,新的线程将以runtime.mstart为入口函数。runtime.newosproc是个很有意思的函数,还有一些信号处理方面的细节,但是对鉴于我们是专注于调度方面,就不对它进行更细致的分析了,感兴趣的读者可以自行去runtime/os_linux.c看看源代码。runtime.clone是用汇编实现的,代码在sys_linux_amd64.s。

既然线程是以runtime.mstart为入口的,那么接下来看mstart函数。

mstart是runtime.newosproc新建的系统线程的入口地址,新线程执行时会从这里开始运行。新线程的执行和goroutine的执行是两个概念,由于有m这一层对机器的抽象,是m在执行g而不是线程在执行g。所以线程的入口是mstart,g的执行要到schedule才算入口。函数mstart最后调用了schedule。

终于到了schedule了!

如果是从mstart进入到schedule的,那么schedule中逻辑非常简单,大概就这几步:

找到一个等待运行的g
如果g是锁定到某个M的,则让那个M运行
否则,调用execute函数让g在当前的M中运行

execute会恢复newproc1中设置的上下文,这样就跳转到新的goroutine去执行了。从newproc出生一直到运行的过程分析,到此结束!

虽然按这样a调用b,b调用c,c调用d,d调用e的方式去分析源代码谁看都会晕掉,但还是要重复一遍这里的读代码过程,希望感兴趣的读者可以拿着注释过的源码按顺序走一遍:

newproc -> newproc1 -> (如果P数目没到上限)wakep -> startm -> (可能引发)newm -> newosproc -> (线程入口)mstart -> schedule -> execute -> goroutine运行

进出系统调用

假设goroutine”生病”了,它要进入系统调用了,暂时无法继续执行。进入系统调用时,如果系统调用是阻塞的,goroutine会被剥夺CPU,将状态设置成Gsyscall后放到就绪队列。Go的syscall库中提供了对系统调用的封装,它会在真正执行系统调用之前先调用函数.entersyscall,并在系统调用函数返回后调用.exitsyscall函数。这两个函数就是通知Go的运行时库这个goroutine进入了系统调用或者完成了系统调用,调度器会做相应的调度。

比如syscall包中的Open函数,它会调用Syscall(SYS_OPEN, uintptr(unsafe.Pointer(_p0)), uintptr(mode), uintptr(perm))实现。这个函数是用汇编写的,在syscall/asm_linux_amd64.s中可以看到它的定义:

TEXT    ·Syscall(SB),7,$0
    CALL    runtime·entersyscall(SB)
    MOVQ    16(SP), DI
    MOVQ    24(SP), SI
    MOVQ    32(SP), DX
    MOVQ    $0, R10
    MOVQ    $0, R8
    MOVQ    $0, R9
    MOVQ    8(SP), AX    // syscall entry
    SYSCALL
    CMPQ    AX, $0xfffffffffffff001
    JLS    ok
    MOVQ    $-1, 40(SP)    // r1
    MOVQ    $0, 48(SP)    // r2
    NEGQ    AX
    MOVQ    AX, 56(SP)  // errno
    CALL    runtime·exitsyscall(SB)
    RET
ok:
    MOVQ    AX, 40(SP)    // r1
    MOVQ    DX, 48(SP)    // r2
    MOVQ    $0, 56(SP)    // errno
    CALL    runtime·exitsyscall(SB)
    RET

可以看到它进系统调用和出系统调用时分别调用了runtime.entersyscall和runtime.exitsyscall函数。那么,这两个函数做什么特殊的处理呢?

首先,将函数的调用者的SP,PC等保存到结构体G的sched域中。同时,也保存到g->gcsp和g->gcpc等,这个是跟垃圾回收相关的。

然后检查结构体Sched中的sysmonwait域,如果不为0,则将它置为0,并调用runtime·notewakeup(&runtime·sched.sysmonnote)。做这这一步的原因是,目前这个goroutine要进入Gsyscall状态了,它将要让出CPU。如果有人在等待CPU的话,会通知并唤醒等待者,马上就有CPU可用了。

接下来,将m的MCache置为空,并将m->p->m置为空,表示进入系统调用后结构体M是不需要MCache的,并且P也被剥离了,将P的状态设置为PSyscall。

有一个与entersyscall函数稍微不同的函数叫entersyscallblock,它会告诉提示这个系统调用是会阻塞的,因此会有一点点区别。它调用的releasep和handoffp。

releasep将P和M完全分离,使p->m为空,m->p也为空,剥离m->mcache,并将P的状态设置为Pidle。注意这里的区别,在非阻塞的系统调用entersyscall中只是设置成Psyscall,并且也没有将m->p置为空。

handoffp切换P。将P从处于syscall或者locked的M中,切换出来交给其它M。每个P中是挂了一个可执行的G的队列的,如果这个队列不为空,即如果P中还有G需要执行,则调用startm让P与某个M绑定后立刻去执行,否则将P挂到idlep队列中。

出系统调用时会调用到runtime·exitsyscall,这个函数跟进系统调用做相反的操作。它会先检查当前m的P和它状态,如果P不空且状态为Psyscall,则说明是从一个非阻塞的系统调用中返回的,这时是仍然有CPU可用的。因此将p->m设置为当前m,将p的mcache放回到m,恢复g的状态为Grunning。否则,它是从一个阻塞的系统调用中返回的,因此之前m的P已经完全被剥离了。这时会查看调用中是否还有idle的P,如果有,则将它与当前的M绑定。

如果从一个阻塞的系统调用中出来,并且出来的这一时刻又没有idle的P了,要怎么办呢?这种情况代码当前的goroutine无法继续运行了,调度器会将它的状态设置为Grunnable,将它挂到全局的就绪G队列中,然后停止当前m并调用schedule函数。

goroutine的消亡以及状态变化

goroutine的消亡比较简单,注意在函数newproc1,设置了fnstart为goroutine执行的函数,而将新建的goroutine的sched域的pc设置为了函数runtime.exit。当fnstart函数执行完返回时,它会返回到runtime.exit中。这时Go就知道这个goroutine要结束了,runtime.exit中会做一些回收工作,会将g的状态设置为Gdead等,并将g挂到P的free队列中。

从以上的分析中,其实已经基本上经历了goroutine的各种状态变化。在newproc1中新建的goroutine被设置为Grunnable状态,投入运行时设置成Grunning。在entersyscall的时候goroutine的状态被设置为Gsyscall,到出系统调用时根据它是从阻塞系统调用中出来还是非阻塞系统调用中出来,又会被设置成Grunning或者Grunnable的状态。在goroutine最终退出的runtime.exit函数中,goroutine被设置为Gdead状态。

等等,好像缺了什么?是的,Gidle始终没有出现过。这个状态好像实际上没有被用到。只有一个runtime.park函数会使goroutine进入到Gwaiting状态,但是park这个有什么作用我暂时还没看懂…

goroutine的状态变迁图:

goroutine的生老病死