预备知识
2021.07

9.1 预备知识

cgo内部实现相关的知识是比较偏底层的,同时与Go系统调用约定以及的goroutine的调度都有一定的关联,因此这里先写一些预备知识。

本节的内容可能需要前面第三章和第五章的一些基础,同时也作为前面没有提到的一些细节的继续补充。

m的g0栈

Go的运行时库中使用了几个重要的结构体,其中M是机器的抽象。每个M可以运行各个goroutine,在结构体M的定义中有一个相对特殊的goroutine叫g0(还有另一个比较特殊的gsignal,与本节内容无关暂且不讲)。那么这个g0特殊在什么地方呢?

g0的特殊之处在于它是带有调度栈的goroutine,下文就将其称为“m的g0栈“。Go在执行调度相关代码时,都是使用的m的g0栈。当一个g执行的是调度相关的代码时,它并不是直接在自己的栈中执行,而是先切换到m的g0栈然后再执行代码。

m的g0栈是一个特殊的栈,g0的分配和普通goroutine的分配过程不同,g0是在m建立时就生成的,并且给它分配的栈空间比较大,可以假定它的大小是足够大而不必使用分段栈。而普通的goroutine是在runtime.newproc时建立,并且初始栈空间分配得很小(4K),会在需要时增长。不仅如此,m的g0栈同时也是这个m对应的物理线程的栈。

这样就相当于拥有了一个“无穷”大小的非分段栈,于是回答了前面提的那个问题:Go使用的是分段栈,初始栈大小很小,当发现栈不够时会动态增长。动态增长是通过进入函数时插入检测指令实现的。然而C函数不使用分段栈技术,并且假设栈是足够大的。调用cgo代码时,使用的是m的g0栈,这是一个足够大的不会发生分段的栈。

函数newm是新那一个结构体M,其中调用runtime.allocm分配M的空间。它的g0域是这个分配的:

mp->g0 = runtime·malg(8192);

等等!好像有哪里不对?这个栈并不是真正的“无穷”大的,它只有8K并且不会增长?那么如果调用的C函数使用超过8K的栈大小会发生什么事情呢?让我们先试一下,我们建立一个文件test.go,内容如下:

package main

/*
#include "stdio.h"

void test(int n) {
  char dummy[1024];

  printf("in c test func iterator %d\n", n);
  if(n <= 0) {
    return;
  }
  dummy[n] = '\a';
  test(n-1);
}
#cgo CFLAGS: -g
*/
import "C"

func main() {
    C.test(C.int(20))
}

函数test被递归调用多次之后,使用的栈空间是超过8K的。然后?程序运行正常,什么也没发生。为什么呢?先卖个关子,到后面再解释原因。

进入系统调用

Go的运行时库对系统调用作了特殊处理,所有涉及到调用系统调用之前,都会先调用runtime.entersyscall,而在出系统调用函数之后,会调用runtime.exitsyscall。这样做原因跟调度器相关,目的是始终维持GOMAXPROCS的数量,当进入到系统调用时,runtime.entersyscall会将P的M剥离并将它设置为PSyscall状态,告知系统此时其它的P有机会运行,以保证始终是GOMAXPROCS个P在运行。

runtime.entersyscall函数会立刻返回,它仅仅是起到一个通知的作用。那么这跟cgo又有什么关系呢?这个关系可大着呢!在执行cgo函数调用之前,其实系统会先调用runtime.entersyscall。这是一个很关键的处理,Go把cgo的C函数调用像系统调用一样独立出去了,不让它影响运行时库。这就回答了前面提出的第二个问题:Go中的goroutine都是协作式的,运行到调用runtime库时就有机会进行调度。然而C函数是不会与Go的runtime做这种交互的,所以cgo的函数不是一个协作式的,那么如何避免进入C函数的这个goroutine“失控”?答案就在这里。将C函数像处理系统调用一样隔离开来,这个goroutine也就不必参与调度了。而其它部分的goroutine正常的运行不受影响。

退出系统调用

退出系统调用跟进入系统调用是一个相反的过程,runtime.exitsyscall函数会查看当前仍然有可用的P,则让它继续运行,否则这个goroutine就要被挂起了。

对于cgo的代码也是同样的作用,出了cgo的C函数调用之后会调用runtime.exitsyscall。