首页 > 技术文章 > goroutine切换背后那些事儿

double12gzh 2020-09-21 22:04 原文

本文基于于GoLang 1.13。

1. 写在前面

微信公众号:[double12gzh]

个人主页: https://gzh.readthedocs.io

关注容器技术、关注Kubernetes。问题或建议,请公众号留言。

Goroutine很轻量,从资源消耗方面来看,它只需要一个2Kb的内存栈就可以运行;从运行时来看,它的运行成本也很低,将一个goroutine切换到另一个goroutine并不需要很多操作。

在进行讲解golang的切换之前,我们先High Level的看一下goroutine切换的相关内容。

2. 案例

golang会根据两种断点将goroutine调度到线程上:

  • 当一个goroutine阻塞了。如:系统调用,mutex,或者通道。被阻塞的goroutine会进入睡眠模式/队列,让Go调度并运行一个等待的goroutine。被阻塞的goroutine进入睡眠模式/队列,允许Go调度和运行一个等待的goroutine。

  • 在函数调用过程中,假如goroutine必须增长它的栈。这个断点允许Go调度另一个goroutine,避免正在运行的那个goroutine占用CPU。

在这两种情况下,运行调度器的g0会用另一个准备运行的goroutine替换当前的goroutine。然后,被选中的goroutine取代g0,从而在线程上运行。

如果您想了解更多关于g0的内容,请参考g0

将一个运行中的goroutine切换到另一个运行中的goroutine涉及到两个切换。

  • g->g0

  • g0-> 另一个g

在GoLang中,groutine真的非常轻量。为了保存,它只需要两个东西:

  • goroutine是在哪一行停止的。即:在被调度前,goroutine是在哪一行停止的,当前要运行的指令被记录在程序计数器(PC)中。goroutine稍后将在同一点恢复。

  • 存放goroutine的堆栈。这个堆栈的目的是为了方便再次运行时恢复其局部变量。

下面我们深入看一下。

3. PC(程序记数器)

为了便于举例,我将使用一个通过channel进行通信的goroutine来说明,这两个goroutine中,一个可以产生数据的,其它的用于消费数据。代码如下:

package main

import (
	"fmt"
	"sync"
)

const COUNT = 100

func main() {
	var wg sync.WaitGroup

	c := make(chan int, 10)

	wg.Add(1)

	// 生产数据
	go func() {
		for i := 0; i < COUNT; 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 {
					fmt.Println(v)
				}
			}
		}()
	}

	wg.Wait()
}

消费者基本上会打印0到99的偶数,我们将重点关注第一个goroutine--生产者--向缓冲区添加数字。当缓冲区满了,它将在发送消息时阻塞。此时,Go要切换到g0,调度另一个goroutine。

如前所述,Go首先需要保存当前指令,以便在同一指令处恢复goroutine。程序计数器(PC)保存在goroutine的内部结构中。

上面的代码可以使用以下图来简单说明:

指令和它们的地址可以通过命令获取:

➜  hello go tool compile -N -l main.go
➜  hello ls | grep main.o
main.o

下面是生产者的一个示例:

➜  hello go tool objdump main.o

在函数runtime.chansend1上阻塞通道前,程序逐条指令执行。Go将当前的程序计数器保存到当前goroutine的内部属性中。在我们的例子中,Go保存程序计数器的地址是0x4268d0,这个地址是在runtime和方法runtime.chansend1内部的。

然后,当g0唤醒goroutine时,它将在同一指令处恢复,对数值进行循环并推入通道。

下面我们来谈谈goroutine切换过程中的栈管理。

4. 栈(stack)

在被阻塞之前,正在运行的goroutine有它的原始栈。这个堆栈包含临时内存,比如变量i:

然后,当它在通道上阻塞时,goroutine将和它的堆栈一起切换到g0,这个goroutine将会有一个更大的栈。

在切换之前,堆栈将被保存,以便在goroutine再次运行时恢复。

我们现在已经完整地了解了goroutine切换中涉及的不同操作。现在让我们看看它是如何影响性能的。

我们应该注意到,一些架构(比如arm)需要多保存一个寄存器LR(链接寄存器)。

5. 操作

为了测量goroutine切换可能需要的时间,我们将使用前面写的程序。然而,它并不能给出一个完美的性能视图,因为它可能取决于找到下一个要调度的goroutine所需的时间。这样goroutine的切换也会影响性能,从函数prolog的切换比从通道上阻塞的goroutine切换要做的操作更多。

我们来总结一下我们要测量的操作:

  • 当前的g在通道上阻塞并切换到g0:
    • PC和堆栈指针一起被保存在一个内部结构中
    • g0被设置为正在运行的goroutine。
    • g0的堆栈取代了当前的堆栈。
  • g0正在寻找一个新的goroutine来运行。
  • g0必须与所选的goroutine进行切换。
    • PC和堆栈指针被从内部结构中提取出来。
    • 程序跳转到获取的PC地址。

如下图:

gg0g0到g的切换是最快的阶段。它们包含少量固定的指令,这一点与调度器检查许多源以寻找下一个要运行的goroutine的情况相反。根据运行程序的情况,这个阶段甚至可能需要更多的时间。

需要说明的一点是,对于以上测试的结果会因机器架构的不同而不同


欢迎关注我的微信公众号:

推荐阅读