0%

cgo 实现机制

上一篇文章 Goroutine 调度 - 网络调用 介绍了网络调用过程中,Goroutine 的切换过程。
对于网络操作而言,Linux 操作系统本身就提供了 epoll 机制,所以对于应用层比较友好。

那么对于其他系统调用,比如普通的文件读写,那是如何做到非阻塞的呢?
本文在介绍 cgo 的实现机制的时候,也会介绍到这里的调度机制。
因为对于 go 而言,C 函数默认是阻塞不安全的,被很保守的对待处理了,毕竟 go 并不是设计为嵌入式语言,go 自己才应该是主角。

跨语言函数调用

首先,对于 CPU 而言,函数调用也就是个 call 指令,对应的是将当前 PC 值压栈,同时 JMP 到新函数的指令位置。

那么 go 和 c 的跨语言函数调用,存在哪些难点呢?

  1. 数据类型不一样,go 和 c 都有自己的一套类型系统,参数/返回值可能需要做类型转换
  2. 函数调用的 ABI 不一样
    go 1.7 之后的函数调用 ABI 中,所有寄存器都是 caller saved。
    C 语言的 ABI 中 caller-saved 和 callee saved 基本一半一半
  3. Goroutine 的栈空间可能不够用
    Goroutine 的栈空间初始值只有 2 kB,是在执行 go 函数的时候,按需增长的。
    但是执行到 c 函数之后,c 函数里是不会检查栈空间是否够用的了。
  4. C 调用 go 的时候,如何绑定 M 和 P

go 调用 c

cgo 有两种用法:

  1. go 调用 c
  2. c 调用 go

我们先看 go 调用 c 这种简单的情况,如下面的例子。
为了方便,我们使用的内联 C 代码的方式,直接在 go 源码里定义了一个 c 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*
long add(long a, long b) {
return a + b;
}
*/
import "C"
import "fmt"

func main() {
var a C.long = 2
var b C.long = 3

r1 := C.add(a, b)

fmt.Printf("%d + %d = %d\n", a, b, r1)
}

执行过程

这个示例的最终执行路径是这样子的:

1
2
3
4
5
1. main.main
2. main._Cfunc_add (把函数参数写入到内存,一个 struct 中)
3. runtime.cgocall (entersyscall + 切换到 g0 系统栈)
4. _cgo_05b35a9f2a70_Cfunc_add (从内存读取函数参数)
5. add (c 生成的函数)

调用开销

我们可以到其中额外的开销:

  1. 24 主要是对接 go 和 C 函数调用 ABI,将参数通过内存中转了一下
  2. 因为 C 函数中可能会调用一些阻塞操作,所以需要 entersyscall 做好调度准备
  3. 同时因为 C 函数需要的栈空间未知,切到 g0 栈是更安全的做法

所以,go 调用 c 很频繁的话,这部分开销还是值得关注的。

如下是 add 的 go 函数实现,我对比了一下,go->go 调用是纳秒级别的开销,go->C 则是接近 100 纳秒级别的开销了。
当然,这个 add 函数只需要非常简单的一条 add 指令,真实情况下肯定不会这么简单,所以真实情况下的差距则肯定不会这么大了。

1
2
3
4
//go:noinline
func add(a int64, b int64) int64 {
return a + b
}

实现细节

使用 go tool cgo test.go 可以看到 cgo 生成源文件,在 _obj 目录下。

  1. _Cfunc_add 是 cgo 自动生成的 go 函数。

这里有一个细节,看起来只有 p0 的地址当做参数传递给了 _cgo_runtime_cgocall 也就是 runtime.cgop1 并没有被用到。
实际上,p0p1 并不是通过寄存器传参的,而是通过栈内存传递的,p1 总是跟随在 p0 后面。

1
2
3
4
5
6
7
8
9
//go:cgo_unsafe_args
func _Cfunc_add(p0 _Ctype_long, p1 _Ctype_long) (r1 _Ctype_long) {
_cgo_runtime_cgocall(_cgo_9c59eeeff222_Cfunc_add, uintptr(unsafe.Pointer(&p0)))
if _Cgo_always_false {
_Cgo_use(p0)
_Cgo_use(p1)
}
return
}
  1. _cgo_05b35a9f2a70_Cfunc_add 是 cgo 自动生成的 C 函数

这里的参数 v 这是上面的 p0 地址。这里定义了一个 struct 来描述 p0p1 的内存布局。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void
_cgo_9c59eeeff222_Cfunc_add(void *v)
{
struct {
long int p0;
long int p1;
long int r;
} __attribute__((__packed__, __gcc_struct__)) *_cgo_a = v;
char *_cgo_stktop = _cgo_topofstack();
__typeof__(_cgo_a->r) _cgo_r;
_cgo_tsan_acquire();
_cgo_r = add(_cgo_a->p0, _cgo_a->p1);
_cgo_tsan_release();
_cgo_a = (void*)((char*)_cgo_a + (_cgo_topofstack() - _cgo_stktop));
_cgo_a->r = _cgo_r;
_cgo_msan_write(&_cgo_a->r, sizeof(_cgo_a->r));
}
  1. p0p2 具体是由 _Cfunc_add 的调用方来写入

也就是 main.main 这个入口函数了,通过 go tool objdump -s main.main test 我们可以看到如下的汇编,两个参数确实是被放入了栈内存中。

1
2
3
MOVQ $0x2, 0(SP)
MOVQ $0x3, 0x8(SP)
CALL main._Cfunc_add.abi0(SB)
1
PS:示例中的 c 函数参数没有用 int 类型,因为用 int 的话,会有碰到一个优化,两个 int 合并到一个 long 了,汇编指令看起来就没那么直观了

Goroutine 调度

其中调度相关的逻辑主要在 runtime.cgocall 中的 entersyscall

  1. 将当前 PM 解除绑定,将 P 记录到 M.oldp
  2. 将当前 P 的状态改为 _Psyscall

需要注意的是:

  1. 此时并没有将 P 释放给其他 M 使用
    而是在另外的 sysmon 线程中不定期检查所有 P 的状态,具体逻辑在 retake 函数中:
    简单的点说,如果 P 处于 _Psyscall 超过一个 sysmon 的轮询周期(至少 20us)则会将 P 切换到 _Pidle 状态,供其他 M 使用。

  2. M 线程还是由操作系统调度运行的
    即使在 P 被抢走的情况下,M 也还是继续运行的,毕竟操作系统只认识 M
    当 M 中的任务(syscall or C function call)完成后继续运行的,会执行到 exitsyscall
    此时会按照这个顺序去执行:绑定 oldp 恢复执行,绑定其他空闲的 P 恢复执行,放回到运行队列等待调度。

总结一下,简单来说:

  1. entersyscall 标记进入可抢占状态
  2. sysmon 轮询检查,将长期运行的 P 释放
  3. exitsyscall 恢复 G 的运行
1
PS:runtime.cgocall 中另外一个关键逻辑是:asmcgocall(fn, arg),这个是切换到 g0 栈执行 fn 这个 C 函数

C 调用 go

C 调用 go 又分为两种情况:

  1. 原生 C 调用 go
  2. go -> C -> go

这里我们看下第一种情况:

go 生成 so

第一步,我们需要从 go 代码生成 so,并且生成 .h 的头文件,供 C 代码使用。

如下的 go 源码:

1
2
3
4
5
6
7
8
import "C"

//export AddFromGo
func AddFromGo(a int64, b int64) int64 {
return a + b
}

func main() {}

通过如下命令,会生成 libgo-hello.solibgo-hello.h 头文件:

1
go build -o libgo-hello.so -buildmode=c-shared hello.go

使用 go 生成的 so

有了头文件和 so,我们就可以当做普通库来使用了,比如下面的例子:

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
#include "libgo-hello.h"

int main() {
long a = 2;
long b = 3;

long r1 = AddFromGo(a, b);

printf("%ld + %ld = %ld\n", a, b, r1);
}

如下方式编译为可执行文件:

1
gcc -g -o hello hello.c -l go-hello -L. -Wl,-rpath,.

执行流程

这个示例的最终执行路径是这样子的:

1
2
3
4
5
6
1. main
2. AddFromGo (libgo-hello.so 导出的函数,将函数参数写入到内存,一个 struct 中)
3. crosscall2 (准备进入 cgocallback 这个 go 函数,对接两边的 call ABI)
4. runtime.cgocallback (获取 M 和 P 等等,逻辑比较多)
5. _cgoexp_51fb23d6311d_AddFromGo (从内存读取参数)
6. main.AddFromGo

函数参数这部分,和 go->C 没什么区别,都是将参数放入到一个 struct,然后固定传这个 struct 的地址。

如何获取 M 和 P

  1. libgo-hello.so 加载的时候,会触发 go runtime 的初始化,创建 M 和 P;是的,除了 c 主程序的线程,还会另外创建一些 go 的 runtime 线程。
  2. AddFromGo 函数中会检查 go runtime 是否已经初始化好了
  3. 执行 main.AddFromGo 的时候,并没有真的切换到新的线程。而是当前线程获取一个伪装的 M,extra M,具体过程这块还没细看。

总结

相对而言,目前的实现里,go 和 C 之间的调用开销也还是比较高的。应该比普通的 Lua C API 调用还是高,虽然传参都会经过内存,但是 go 还多了协程调度的逻辑。

优化的空间应该还是有的,对于某些 go 和 C 交互比较频繁,性能要求比较高的场景,应该还是可以搞一搞:

  1. 引入 unsafe C function call
    这种 unsafe 的情况下,不再调用 entersyscall 和 exitsyscall,C function 阻塞的风险就甩给程序员了
    不过还是需要注意下,Gorountine 的抢占调度在这个情况是否会踩坑
  2. 通过寄存器来传递参数,减少内存读写
    这个应该也可以搞,如果 1 实现了的话,应该相对容易一些,如果 1 没有的话,可能还比较难,或者效果也不明显了,毕竟中间又隔了好多的函数调用了。
  3. 避免切到 g0 栈运行
    这个看起来不太好搞。
    如果在 unsafe 模式下,让程序员来指定 C 需要的 stack size 倒是可以搞;不过不可靠,很容易就指定错了,可能会出现诡异的问题,不可取。