在 Go 语言中可以很方便的用协程来实现并发,但是协程是如何高效运行的呢?本文从设计的角度出发一探 Go Runtime 调度器的实现方式。所有了不起的工程都是源自需求驱动的,所以为了理解 为什么需要协程 和 它是如何工作的 ,让我们回顾操作系统的历史以便我们找到问题,如果不能理解问题的根源那么可能无法很好的解决它。
操作系统历史
协程
协程是由 Go runtime 托管的轻量级的线程,开启一个新的协程只需要在函数调用的地方增加 go 关键字即可。下面看一个协程的例子:
for i := 0; i < 10; i++ { gofunc(i int) { defer wg.Done() fmt.Printf("loop i is - %d\n", i) }(i) }
wg.Wait() fmt.Println("Hello, Welcome to Go") }
该例子可能的输出结果如下:
1 2 3 4 5 6 7 8 9 10 11
loop i is - 9 loop i is - 4 loop i is - 0 loop i is - 1 loop i is - 2 loop i is - 3 loop i is - 7 loop i is - 8 loop i is - 6 loop i is - 5 Hello, Welcome to Go
那么此时我们脑海便有了两个疑问:
这 10 个协程是如何并行运行?
这 10 个协程以什么顺序运行?
随之而来我们解决的两个问题就是:
如何在运行于多核 CPU 的多个内核系统线程上分发多个协程?(即:多协程 -> 多内核线程 -> 多CPU核 如何调度?)
多个协程以什么顺序运行以保证公平?
剩下的讨论将主要围绕从设计的角度解决 Go runtime 调度器特有的这些问题。和其他所有问题一样,我们需要定义好边界,否则问题陈述太含糊就无法得出结论。调度可能针对多个目标中的一个或多个问题,对于我们而言,我们将自己限定在以下要求之内:
TEXT ·Syscall(SB),NOSPLIT,$0-56 CALL runtime·entersyscall(SB) MOVQ a1+8(FP), DI MOVQ a2+16(FP), SI MOVQ a3+24(FP), DX MOVQ trap+0(FP), AX // syscall entry SYSCALL CMPQ AX, $0xfffffffffffff001 JLS ok MOVQ $-1, r1+32(FP) MOVQ$0, r2+40(FP) NEGQ AX MOVQAX, err+48(FP) CALL runtime·exitsyscall(SB) RET ok: MOVQAX, r1+32(FP) MOVQDX, r2+40(FP) MOVQ$0, err+48(FP) CALL runtime·exitsyscall(SB) RET
阻塞调用 SYSCALL 被封装在 runtime.entersyscall(SB) 和 runtime.exitsyscall(SB) 之间,从调用名字上看,在正式进入和离开系统调用之前都执行一些逻辑。当发出一个阻塞的系统调用时,封装的系统调用会自动把处理器 P 和内核线程 M 分开以便另一个内核线程来运行它。这样就让 Go runtime 在不增加运行的队列的前提下高效处理阻塞系统调用。
例如,在有一个内核线程 M 和一个逻辑处理器 P 的 Go 程序中,如果内核线程 M 因执行系统调用被阻塞,则需要与逻辑处理器 P 数量相同的自旋线程,以允许等待可运行的协程继续执行。因此,在此期间,内核线程 M 的数量大于 P (一个 自旋线程 + 一个阻塞线程),所以即使 runtime.GOMAXPROCS 的值设为 1,程序也是处于多线程状态。
functions create the stack frame during the beginning of the function (called the function prologue) and tear it down at the end of a function (called the function epilogue).
/* we should never set just Z_TYPE, we should set Z_TYPE_INFO */ #define Z_TYPE(zval) zval_get_type(&(zval)) #define Z_TYPE_P(zval_p) Z_TYPE(*(zval_p))