协程切换到底做了啥

注:该文章基于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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
package main

import "sync"

func main() {
var wg sync.WaitGroup

c := make(chan int, 10)

wg.Add(1)
go func(){
for i:=0;i<100;i++{
c <- i
}
close(c)

wg.Done()
}()

for i:=0;i<3;i++ {
wg.Add(1)

go func(){
for v:=range c {
if v%2 == 0 {
println(v)
}
}
wg.Done()
}()
}

wg.Wait()
}

程序将打印出1~99的偶数。先看第一个协程-生产者-将数字添加到管道(带缓冲的管道)。当管道装满时,数据放送方将被阻塞。同事,Go将切换到go并调度另一个协程来工作。

根据上文的知识,此时,Go需要保存当前操作以便恢复时能继续v执行。程序计数器(PC)被保存为协程的一个内部结构体。此处为图示:

可以使用go tool objdump命令查看指令及其地址信息。此处以生产者为例

1
2
go tool compile -N -l 1.go
go tool objdump 1.o

程序逐个指令执行,直到被函数runtime.channelsend1阻塞。Go将当前程序计数器保存到协程的内部属性中。本例中,程序计数器被保存到地址0x4268d0

然后,g0协程唤醒休眠的协程,执行同样的质量,循环数字并推入管道。接下来,我们看看协程切换时的站空间管理。

栈(Stack)

在被阻塞前,running协程有它自己的栈空间。其中包含临时内存,如变量i

然后,当管道阻塞时,协程及其栈被切换到g0(g0有一个更大的栈空间):

协程切换前,栈空间会被保存以便恢复协程时可以继续执行:

现在,我们对协程切换有了一个完整的认识。我们来看看它是如何影响性能的。

操作(Operations)

我们继续使用上面的代码测量协程切换消耗的时间。因查找下一个待调度协程需要时间,因此这个例子中的测量结果不是太精准。从函数的prolog切换比从阻塞的管道切换协程,要花费更多的操作。

总结一下待测量的操作:

  • 当前被管道阻塞的协程g,切换到g0协程:
    • PC和栈指针一起被保存到内部结构体
    • g0被设置为running协程
    • g0的栈空间替换当前栈空间
  • g0查找下一个待调度协程。
  • g0与待调度协程切换:
    • PC和栈指针从内部结构体中恢复。
    • 程序跳至PC指针位置执行

协程切换,从gg0或从g0g是最快的阶段。仅包含有限的几个指令,而调度器查找下一个协程需要很多操作。查找待调度协程甚至需要更多时间,这取决于运行的程序。

图示的测试结果,给出了性能的数量级示意。通常也没有标准的工具来做这么测量。当然测量结果也与架构相关(作者使用的是 Mac 2.9GHz 双核i5)。