1.2 协程切换
概述
协程是Golang中的轻量级线程,麻雀虽小五脏俱全,Golang管理协程时也必然会涉及到协程之间的切换:阻塞的协程被切换出去,可运行的协程被切换进来。我们在本章节就来仔细分析下协程如何切换。
TLS
thread local storage:
getg()
goget()用来获取当前线程正在执行的协程g。该协程g被存储在TLS中。
mcall()
mcall在golang需要进行协程切换时被调用,用来保存被切换出去协程的信息,并在当前线程的g0协程堆栈上执行新的函数。一般情况下,会在新函数中执行一次schedule()来挑选新的协程来运行。接下来我们就看看mcall的实现。
调用时机
系统调用返回
当执行系统调用的线程从系统调用中返回后,有可能需要执行一次新的schedule,此时可能会调用mcall来完成该工作,如下:
func exitsyscall(dummy int32) {
......
// Call the scheduler.
mcall(exitsyscall0)
......
}
在exitsyscall0中如果可能会放弃当前协程并执行一次schedule,挑选新的协程来占有m。
由于阻塞放弃执行
由于某些原因,当前执行的协程可能会被阻塞,如管道读写时条件无法满足,则当前协程会被阻塞直到条件满足。
在gopark()函数中,便会调用该mcall放弃当前协程并执行一次协程调度。
func gopark(unlockf func(*g, unsafe.Pointer) bool, lock unsafe.Pointer, reason string, traceEv byte, traceskip int) {
mp := acquirem()
gp := mp.curg
status := readgstatus(gp)
if status != _Grunning && status != _Gscanrunning {
throw("gopark: bad g status")
}
mp.waitlock = lock
mp.waitunlockf = *(*unsafe.Pointer)(unsafe.Pointer(&unlockf))
gp.waitreason = reason
mp.waittraceev = traceEv
mp.waittraceskip = traceskip
releasem(mp)
// can't do anything that might move the G between Ms here.
mcall(park_m)
}
而park_m函数我们在后面会分析,它放弃之前执行的协程并调用一次schedule()挑选新的协程来执行。
执行原理
前面我们主要描述了mcall被调用的时机,现在我们要来看看mcall的实现原理。
mcall的函数原型是:
func mcall(fn func(*g))
这里fn的参数指的是在调用mcall之前正在运行的协程。
我们前面说到,mcall的主要作用是协程切换,它将当前正在执行的协程状态保存起来,然后在m->g0的堆栈上调用新的函数。 在新的函数内会将之前运行的协程放弃,然后调用一次schedule()来挑选新的协程运行。
// func mcall(fn func(*g))
// Switch to m->g0's stack, call fn(g).
// Fn must never return. It should gogo(&g->sched)
// to keep running g.
TEXT runtime·mcall(SB), NOSPLIT, $0-8
// DI中存储参数fn
MOVQ fn+0(FP), DI
get_tls(CX)
// 获取当前正在运行的协程g信息
// 将其状态保存在g.sched变量
MOVQ g(CX), AX // save state in g->sched
MOVQ 0(SP), BX // caller's PC
MOVQ BX, (g_sched+gobuf_pc)(AX)
LEAQ fn+0(FP), BX // caller's SP
MOVQ BX, (g_sched+gobuf_sp)(AX)
MOVQ AX, (g_sched+gobuf_g)(AX)
MOVQ BP, (g_sched+gobuf_bp)(AX)
// switch to m->g0 & its stack, call fn
MOVQ g(CX), BX
MOVQ g_m(BX), BX
MOVQ m_g0(BX), SI
CMPQ SI, AX // if g == m->g0 call badmcall
JNE 3(PC)
MOVQ $runtime·badmcall(SB), AX
JMP AX
MOVQ SI, g(CX) // g = m->g0
// 切换到m->g0堆栈
MOVQ (g_sched+gobuf_sp)(SI), SP // sp = m->g0->sched.sp
// 参数AX为之前运行的协程g
PUSHQ AX
MOVQ DI, DX
MOVQ 0(DI), DI
// 在m->g0堆栈上执行函数fn
CALL DI
POPQ AX
MOVQ $runtime·badmcall2(SB), AX
JMP AX
RET
如何获取当前协程执行信息
前两句理解起来可能比较晦涩:
buf+0(FP) 其实就是获取gosave的第一个参数(gobuf地址),参考 A Quick Guide to Go's Assembler
The FP pseudo-register is a virtual frame pointer used to refer to function arguments. The compilers maintain a virtual frame pointer and refer to the arguments on the stack as offsets from that pseudo-register. Thus 0(FP) is the first argument to the function, 8(FP) is the second (on a 64-bit machine), and so on. However, when referring to a function argument this way, it is necessary to place a name at the beginning, as in first_arg+0(FP) and second_arg+8(FP).
LEAQ buf+0(FP), BX则是获取到第一个参数的存储地址,而根据golang的堆栈布局,这个地址其实是调用者的sp,如下:
接下来的几句比较容易理解,在第一句获取了gobuf的地址后,接下来将一些相关成员设置成合适的value。 最关键的是以下几句
get_tls(CX)
MOVQ g(CX), BX
MOVQ BX, gobuf_g(AX)
这几句的作用是从TLS中获取当前线程运行的g,然后将其存储在gobuf的成员g。
gosave()
gosave在golang协程切换时被调用,用来保存被切换出去协程的信息,以便在下次该协程被重新调度执行时可以快速恢复出协程的执行上下文。
与协程调度相关的数据结构如下:
type g struct {
stack stack
stackguard0 uintptr
stackguard1 uintptr
......
sched gobuf
......
}
// gobuf记录与协程切换相关信息
type gobuf struct {
sp uintptr
pc uintptr
g guintptr
ctxt unsafe.Pointer
ret uintreg
lr uintptr
bp uintptr
}
gosave是用汇编语言写的,性能比较高,但理解起来就没那么容易。
TODO: gosave()的调用路径是什么呢?
// void gosave(Gobuf*)
// save state in Gobuf; setjmp
TEXT runtime·gosave(SB), NOSPLIT, $0-8
MOVQ buf+0(FP), AX // gobuf
LEAQ buf+0(FP), BX // caller's SP
MOVQ BX, gobuf_sp(AX)
MOVQ 0(SP), BX // caller's PC
MOVQ BX, gobuf_pc(AX)
MOVQ $0, gobuf_ret(AX)
MOVQ $0, gobuf_ctxt(AX)
MOVQ BP, gobuf_bp(AX)
get_tls(CX)
MOVQ g(CX), BX
MOVQ BX, gobuf_g(AX)
RET
前两句理解起来可能比较晦涩:
buf+0(FP) 其实就是获取gosave的第一个参数(gobuf地址),参考 A Quick Guide to Go's Assembler
The FP pseudo-register is a virtual frame pointer used to refer to function arguments. The compilers maintain a virtual frame pointer and refer to the arguments on the stack as offsets from that pseudo-register. Thus 0(FP) is the first argument to the function, 8(FP) is the second (on a 64-bit machine), and so on. However, when referring to a function argument this way, it is necessary to place a name at the beginning, as in first_arg+0(FP) and second_arg+8(FP).
LEAQ buf+0(FP), BX则是获取到第一个参数的存储地址,而根据golang的堆栈布局,这个地址其实是调用者的sp,如下:
接下来的几句比较容易理解,在第一句获取了gobuf的地址后,接下来将一些相关成员设置成合适的value。 最关键的是以下几句
get_tls(CX)
MOVQ g(CX), BX
MOVQ BX, gobuf_g(AX)
这几句的作用是从TLS中获取当前线程运行的g,然后将其存储在gobuf的成员g。
gogo()
gogo的作用正好相反,用来从gobuf中恢复出协程执行状态并跳转到上一次指令处继续执行。因此,其代码也相对比较容易理解,我们就不过多赘述,如下:
gogo()主要的调用路径:schedule()-->execute()-->googo()
// void gogo(Gobuf*)
// restore state from Gobuf; longjmp
TEXT runtime·gogo(SB), NOSPLIT, $0-8
MOVQ buf+0(FP), BX // gobuf
MOVQ gobuf_g(BX), DX
MOVQ 0(DX), CX
get_tls(CX)
MOVQ DX, g(CX)
MOVQ gobuf_sp(BX), SP // restore SP
MOVQ gobuf_ret(BX), AX
MOVQ gobuf_ctxt(BX), DX
MOVQ gobuf_bp(BX), BP
MOVQ $0, gobuf_sp(BX)
MOVQ $0, gobuf_ret(BX)
MOVQ $0, gobuf_ctxt(BX)
MOVQ $0, gobuf_bp(BX)
// 恢复出上一次执行指令,并跳转至该指令处
MOVQ gobuf_pc(BX), BX
JMP BX
这里最后一句跳转至该协程被调度出的那条语句继续执行,需要注意的是该函数不再返回调用者。