上一篇文章 Goroutine 调度 - 网络调用 介绍了网络调用过程中,Goroutine 的切换过程。
对于网络操作而言,Linux 操作系统本身就提供了 epoll 机制,所以对于应用层比较友好。
那么对于其他系统调用,比如普通的文件读写,那是如何做到非阻塞的呢?
本文在介绍 cgo 的实现机制的时候,也会介绍到这里的调度机制。
因为对于 go 而言,C 函数默认是阻塞不安全的,被很保守的对待处理了,毕竟 go 并不是设计为嵌入式语言,go 自己才应该是主角。
跨语言函数调用
首先,对于 CPU 而言,函数调用也就是个 call
指令,对应的是将当前 PC 值压栈,同时 JMP 到新函数的指令位置。
那么 go 和 c 的跨语言函数调用,存在哪些难点呢?
- 数据类型不一样,go 和 c 都有自己的一套类型系统,参数/返回值可能需要做类型转换
- 函数调用的 ABI 不一样
go 1.7 之后的函数调用 ABI 中,所有寄存器都是 caller saved。
C 语言的 ABI 中 caller-saved 和 callee saved 基本一半一半 - Goroutine 的栈空间可能不够用
Goroutine 的栈空间初始值只有 2 kB,是在执行 go 函数的时候,按需增长的。
但是执行到 c 函数之后,c 函数里是不会检查栈空间是否够用的了。 - C 调用 go 的时候,如何绑定 M 和 P
go 调用 c
cgo 有两种用法:
- go 调用 c
- c 调用 go
我们先看 go 调用 c 这种简单的情况,如下面的例子。
为了方便,我们使用的内联 C 代码的方式,直接在 go 源码里定义了一个 c 函数。
1 | /* |
执行过程
这个示例的最终执行路径是这样子的:
1 | 1. main.main |
调用开销
我们可以到其中额外的开销:
2
和4
主要是对接 go 和 C 函数调用 ABI,将参数通过内存中转了一下- 因为 C 函数中可能会调用一些阻塞操作,所以需要
entersyscall
做好调度准备 - 同时因为 C 函数需要的栈空间未知,切到 g0 栈是更安全的做法
所以,go 调用 c 很频繁的话,这部分开销还是值得关注的。
如下是 add 的 go 函数实现,我对比了一下,go->go 调用是纳秒级别的开销,go->C 则是接近 100 纳秒级别的开销了。
当然,这个 add 函数只需要非常简单的一条 add 指令,真实情况下肯定不会这么简单,所以真实情况下的差距则肯定不会这么大了。
1 | //go:noinline |
实现细节
使用 go tool cgo test.go
可以看到 cgo 生成源文件,在 _obj
目录下。
_Cfunc_add
是 cgo 自动生成的 go 函数。
这里有一个细节,看起来只有 p0
的地址当做参数传递给了 _cgo_runtime_cgocall
也就是 runtime.cgo
,p1
并没有被用到。
实际上,p0
和 p1
并不是通过寄存器传参的,而是通过栈内存传递的,p1
总是跟随在 p0
后面。
1 | //go:cgo_unsafe_args |
_cgo_05b35a9f2a70_Cfunc_add
是 cgo 自动生成的 C 函数
这里的参数 v
这是上面的 p0
地址。这里定义了一个 struct 来描述 p0
和 p1
的内存布局。
1 | void |
p0
和p2
具体是由_Cfunc_add
的调用方来写入
也就是 main.main
这个入口函数了,通过 go tool objdump -s main.main test
我们可以看到如下的汇编,两个参数确实是被放入了栈内存中。
1 | MOVQ $0x2, 0(SP) |
1 | PS:示例中的 c 函数参数没有用 int 类型,因为用 int 的话,会有碰到一个优化,两个 int 合并到一个 long 了,汇编指令看起来就没那么直观了 |
Goroutine 调度
其中调度相关的逻辑主要在 runtime.cgocall
中的 entersyscall
,
- 将当前
P
和M
解除绑定,将 P 记录到 M.oldp - 将当前 P 的状态改为
_Psyscall
需要注意的是:
此时并没有将
P
释放给其他M
使用
而是在另外的 sysmon 线程中不定期检查所有P
的状态,具体逻辑在retake
函数中:
简单的点说,如果P
处于_Psyscall
超过一个 sysmon 的轮询周期(至少 20us)则会将P
切换到_Pidle
状态,供其他M
使用。M
线程还是由操作系统调度运行的
即使在P
被抢走的情况下,M
也还是继续运行的,毕竟操作系统只认识M
。
当 M 中的任务(syscall or C function call)完成后继续运行的,会执行到exitsyscall
。
此时会按照这个顺序去执行:绑定oldp
恢复执行,绑定其他空闲的P
恢复执行,放回到运行队列等待调度。
总结一下,简单来说:
entersyscall
标记进入可抢占状态- sysmon 轮询检查,将长期运行的 P 释放
exitsyscall
恢复 G 的运行
1 | PS:runtime.cgocall 中另外一个关键逻辑是:asmcgocall(fn, arg),这个是切换到 g0 栈执行 fn 这个 C 函数 |
C 调用 go
C 调用 go 又分为两种情况:
- 原生 C 调用 go
- go -> C -> go
这里我们看下第一种情况:
go 生成 so
第一步,我们需要从 go 代码生成 so,并且生成 .h 的头文件,供 C 代码使用。
如下的 go 源码:
1 | import "C" |
通过如下命令,会生成 libgo-hello.so
和 libgo-hello.h
头文件:
1 | go build -o libgo-hello.so -buildmode=c-shared hello.go |
使用 go 生成的 so
有了头文件和 so,我们就可以当做普通库来使用了,比如下面的例子:
1 | #include <stdio.h> |
如下方式编译为可执行文件:
1 | gcc -g -o hello hello.c -l go-hello -L. -Wl,-rpath,. |
执行流程
这个示例的最终执行路径是这样子的:
1 | 1. main |
函数参数这部分,和 go->C
没什么区别,都是将参数放入到一个 struct,然后固定传这个 struct 的地址。
如何获取 M 和 P
- libgo-hello.so 加载的时候,会触发 go runtime 的初始化,创建 M 和 P;是的,除了 c 主程序的线程,还会另外创建一些 go 的 runtime 线程。
AddFromGo
函数中会检查 go runtime 是否已经初始化好了- 执行
main.AddFromGo
的时候,并没有真的切换到新的线程。而是当前线程获取一个伪装的 M,extra M,具体过程这块还没细看。
总结
相对而言,目前的实现里,go 和 C 之间的调用开销也还是比较高的。应该比普通的 Lua C API 调用还是高,虽然传参都会经过内存,但是 go 还多了协程调度的逻辑。
优化的空间应该还是有的,对于某些 go 和 C 交互比较频繁,性能要求比较高的场景,应该还是可以搞一搞:
- 引入 unsafe C function call
这种 unsafe 的情况下,不再调用 entersyscall 和 exitsyscall,C function 阻塞的风险就甩给程序员了
不过还是需要注意下,Gorountine 的抢占调度在这个情况是否会踩坑 - 通过寄存器来传递参数,减少内存读写
这个应该也可以搞,如果 1 实现了的话,应该相对容易一些,如果 1 没有的话,可能还比较难,或者效果也不明显了,毕竟中间又隔了好多的函数调用了。 - 避免切到 g0 栈运行
这个看起来不太好搞。
如果在 unsafe 模式下,让程序员来指定 C 需要的 stack size 倒是可以搞;不过不可靠,很容易就指定错了,可能会出现诡异的问题,不可取。