注:该文章基于Go 1.13
Go协程很小,启动仅需2Kb栈空间。协程也很轻,从一个协程切换到另一个协程不需要很多操作。在深入协程切换前,我们先从高层复习下切换是如何工作的。
建议结合Go: Goroutine, OS Thread and CPU Management一起阅读本文章。
案例
Go调度器将协程分配到线程上执行,依据以下两类:
- 当协程阻塞时:系统调用,mutex锁,或者协程。被阻塞的协程进入休眠状态/放入一个队列中,并允许Go调度并运行其他等待中的协程。
- 在函数调用时,若在初始阶段,协程需要扩展其栈空间。该中断允许调度器运行其他协程,避免当前写成独占CPU。
以上场景中,调度器g0
负责将当前协程替换为其他协程并准备运行之。然后,被替换的协程取代g0
在线程上运行。
将running
协程替换为其他协程,涉及两个切换:
running
协程切换为g0
:g0
切换为下一个协程g
:
在Go语言中,切换协程是轻量的。保存协程状态需要两样东西:
- 在被取消调度前,协程停止的行号。当前执行指令被记录在程序计数器中(PC)。协程稍后会在相同的位置恢复。
- 协程栈空间,用以再次执行时恢复变量。
来看个例子
程序计数器(Program counter)
例子中,一个写成生产数据,另一个消费数据:
1 |
|
程序将打印出1~99的偶数。先看第一个协程-生产者-将数字添加到管道(带缓冲的管道)。当管道装满时,数据放送方将被阻塞。同事,Go将切换到go
并调度另一个协程来工作。
根据上文的知识,此时,Go需要保存当前操作以便恢复时能继续v执行。程序计数器(PC)被保存为协程的一个内部结构体。此处为图示:
可以使用go tool objdump
命令查看指令及其地址信息。此处以生产者为例
1 |
|
程序逐个指令执行,直到被函数runtime.channelsend1
阻塞。Go将当前程序计数器保存到协程的内部属性中。本例中,程序计数器被保存到地址0x4268d0
:
然后,g0
协程唤醒休眠的协程,执行同样的质量,循环数字并推入管道。接下来,我们看看协程切换时的站空间管理。
栈(Stack)
在被阻塞前,running
协程有它自己的栈空间。其中包含临时内存,如变量i
:
然后,当管道阻塞时,协程及其栈被切换到g0
(g0
有一个更大的栈空间):
协程切换前,栈空间会被保存以便恢复协程时可以继续执行:
现在,我们对协程切换有了一个完整的认识。我们来看看它是如何影响性能的。
操作(Operations)
我们继续使用上面的代码测量协程切换消耗的时间。因查找下一个待调度协程需要时间,因此这个例子中的测量结果不是太精准。从函数的prolog切换比从阻塞的管道切换协程,要花费更多的操作。
总结一下待测量的操作:
- 当前被管道阻塞的协程
g
,切换到g0
协程:PC
和栈指针一起被保存到内部结构体g0
被设置为running
协程g0
的栈空间替换当前栈空间
g0
查找下一个待调度协程。g0
与待调度协程切换:PC
和栈指针从内部结构体中恢复。- 程序跳至
PC
指针位置执行
协程切换,从g
到g0
或从g0
到g
是最快的阶段。仅包含有限的几个指令,而调度器查找下一个协程需要很多操作。查找待调度协程甚至需要更多时间,这取决于运行的程序。
图示的测试结果,给出了性能的数量级示意。通常也没有标准的工具来做这么测量。当然测量结果也与架构相关(作者使用的是 Mac 2.9GHz 双核i5)。