相对来说,go=>c 是更简单的,是在 go runtime 创建的线程中,调用执行 c 函数。对 go 调度器而言,调用 c 函数,就相当于系统调用。 执行环境还是在本线程,只是调用栈有切换,还多了一个函数调用的 ABI 对齐,对于 go runtime 依赖的 GMP 环境,都是现有的,并没有太大的区别。
而 c=>go 则复杂很多,是在一个 c 宿主创建的线程上,调用执行 go 函数。这意味着,需要在 c 线程中,准备好 go runtime 所需要的 GMP 环境,才能运行 go 函数。 以及,go 和 c 对于线程掌控的不同,主要是信号这块。所以,复杂度又高了一级。
GMP 从哪里来
首先简单解释一下,为什么需要 GMP,因为在 go 函数运行的时候,总是假设是运行在一个 goroutine 环境中,以及绑定有对应的 M 和 P。 比如,要申请内存的时候,则会先从 P 这一层 cache 的 span 中的获取,如果这些没有的话,go runtime 就没法运行了。
虽然 M 是线程,但是具体实现上,其实就是一个 M 的数据结构来表示,对于 c 创建的协程,获取的是 extra M,也就是单独的表示线程的 M 数据结构。
上篇 cgo 实现机制,提过优化一些思路,不过主要针对 go => c 这个方向。 因为 c => go 的场景中,还有其他更重要的优化点。
复用 extra M
通常情况下,最大的性能消耗点在 获取/释放 M
上面提到,从 c 进入 go,需要通过 needm 来获取 M。 这期间有 5 个信号相关的系统调用。比如:避免死锁用的,临时屏蔽所有信号,以及开启 go 所需要的信号。
从 go 返回 c 的时候,通过 dropm 来释放 M。 这期间有 3 个信号相关的系统调用。目的是恢复到 needm 之前的信号状态(因为 needm 强制开启了 go 必须的信号)。
这两个操作,在 MOSN 新的 MOE 架构的测试中,可以看到约占整体 2~5% 的 CPU 占用,还是比较可观的。
了解了瓶颈之后,也就成功了一半。
优化思路也很直观,第一次从 go 返回 c 的时候,不释放 extra M,继续留着使用,下一次从 c 进入 go 也就不需要再获取 extra M 了。 因为 extra M 资源是无限的,c 线程一直占用一个 extra M 也无所谓。不过,在 c 线程退出的时候,还是需要释放 extra M,避免泄漏。 所以,这个优化,在 windows 就不能启用了,因为 windows 的 pthread API 没有线程退出的 callback 机制。
在 c 线程退出,callback 到 go 的时候,涉及到 c 和 go function call ABI 对齐。 这里主要的复杂度在于,需要处理好不同的 CPU 体系结构,以及操作系统上的差异。所以工作量还是比较大的。比如 arm,arm64, 期间有一个有意思的坑,Aarch64 的 stack pointer 必须是 16 byte 对齐的,否则会触发 bus error 信号。 (也因此 arm64 的压栈/出栈指令,都是两个两个操作的)
获取不到 P
从 c 进入 go,获取 GMP 的过程中,只有 P 资源是受限的,在负载较高时,获取不到 P 也是比较容易碰到的。
当获取不到 P 时,c 线程会挂起,等待进入全局队列的 g 被唤醒。 这个过程对于 go runtime 而言是比较合理的,但是对于 c 线程则比较危险,尤其当 c 线程中跑的是多路复用的逻辑,则影响更大了。
此时有两个优化思路:
类似 extra M,再给 c 线程绑一个 extra P,或者预先绑定一个 P。这样 c 线程就不需要被挂起了。 这个思路,最大的挑战在于 extra P,是不受常规 P 数量的限制,对于 go 中 P 的定义,是一个不小的挑战。
将 g 不放入全局队列,改为放到优先级更高的 P.runnext,这样 g 可以被快速的调度到,c 线程可以等待的时间更短了。 这个思路,最大的挑战则在于,对这个 g 加了优先级的判断,或许有一点有悖于 g 应该是平等的原则。 不过应该也还好,P.runnext 本来也是为了应对某些需要优先的场景的,这里只是多了一个场景。
这个优化方向,还没有 CL,不过我们有同学在搞了。
尽快释放 P
当从 go 返回 c 的时候,会调用 entersyscall,具体是,M 和 P 并没有完全解除绑定,而是让 P 进入 syscall 的状态。
接下来,会有两种情况:
很快又有了下一个 c => go 调用,则直接用这个 P,
sysmon 会强制解除绑定。对于进入 syscall 的 P,sysmon 会等 20 us => 10 ms,然后将 P 抢走释放掉。 等待时间跨度还是挺大的,具体多久就看命了,主要看 sysmon 是否之前已经长时间空闲了。
对于 go => c 这方向,一个 syscall 的等待时间,通常是比较小的,所以这套机制是合适的。 但是对于 c => go 这个方向,这种伪 syscall 的等待时间,取决于两个 c => go 调用的间隔时间,其实不太有规律的。 所以,可能会造成 P 资源被浪费 20us => 10ms。
所以,又有一个优化方向,两个思路:
从 go 返回 c 的时候,立即释放 P,这样不会浪费 P 资源。
调整下 sysmon,针对这种场景,有一种机制,能尽量在 20 us 就把 P 抢走。
其中,思路 1,这个 CL 411034 里顺便实现了。 这个本来是为了修复 go trace 在 cgo 场景下不能用的 bug,改到这个点,是因为跟 Michael 大佬讨论,引发的一个改动(一开始还没有意识到是一个优化)。
总结
不知道看到这里,你是否一样觉得,c => go 比 go => c 的复杂度又高了一级。反正我是有的。
首先,c 线程得拿到 GMP 才能运行 go 函数,然后,c 线程上的 g 发生了协程调度事件的时候,调度策略又跟普通的 go 线程不一样。 另外一个大坑则是信号处理,在 go runtime 接管了 sighandler 之后,我们还需要让 c 线程之前注册的 sighandler 一样有效,使 c 线程感觉不到被 go runtime 接管了一道。
优化这块,相对来说,比较好理解一些,主要是涉及到 go 目前的实现方式,并没有太多底层原理上的改进。 复用 extra M 属于降低 CPU 开销;P 相关的获取和释放,则更多涉及到延时类的优化(如果搞了 extra P,则也会有 CPU 的优化效果)。
最后
最后吐个槽,其实目前的实现方案中,从 c 调用 go 的场景,go runtime 的调度策略,更多是考虑 go 这一侧,比如 goroutine 和 P 不能被阻塞。 但是,对 c 线程其实是很不友好的,只要涉及到等待,就会把 c 线程挂起…
因为 go 的并发模型中,线程挂起通常是可以接受的,但是对于宿主 c 线程而言,有时候被阻塞挂起则是很敏感的。 比如,在 MOSN 的 MOE 架构中,对于这类可能导致 c 线程被挂起的行为,必须非常的小心。
那有没有办法改变呢,也是有的,只是改动相对要大一点,大体思路是,将 c 调用 go 的 API 异步化:
1 2
g = GoFunc(a, b) printf("g.status: %d, g.result: %d\n", g.status, g.result)
意思是,调用 Go 函数,不再同步返回函数返回值,而是返回一个带状态 g,这样的好处是,因为 API 异步了,所以执行的时候,也不必同步等待 g 返回了。 如果碰到 g 被挂起了,直接返回 status = yield 的 g 即可,goroutine 协程继续走 go runtime 的调度,c 线程也不必挂起等待了。
这样的设计,对于 c 线程是最友好的,当然也还得有一些配套的改动,比如缺少 P 的时候,得有个 extra P 更好一些,等其他的细节。 不过呢,这样子的改动呢,还是比较大的,让 go 官方接受这种设计,应该还是比较难度的,以后没准可以试试,万一接受了呢~
然后,标记 trace.enabled = true,此后各个 hook 点的判断条件 if trace.enabled,就开始生效了,陆续开始生产 event 了。
是的,即使没有开启 trace,hook 点的条件判断还是会跑的,只是由于 CPU 的分支预测,这点开销基本是可以忽略不计的了。 个人拍脑袋估计,即使去掉了这个判断条件,万分之一的提升都不一定有。go 在这种便利性和微弱的性能提升之间的取舍,我还是蛮喜欢的。 go 中可以看到类似很多的这种取舍,比如在 c 中会通过宏开启的 assert 检查,go 直接就给默认开启了。(不过 go 也没有宏 …)
从 c 到可执行文件,包含了 编译 和 链接 这两步。通常在编译构建 c 项目的时候,也可以在 make 的过程中,看到 编译 和 链接 这种中间步骤。
然而,go 在这方面,有更进一步的封装,直接跑 go build 就行了,也不知道背后干了个啥。
最近因为搞 cgo 的优化,需要了解这里面的过程,记录一下的。
编译过程
底层还是分为 编译 和 链接 这两步,go build 可以类比为 go 标准的 make 工具。
对于 go 编译器而言,go 是提供给用户的统一的命令,实际上它还包含了很多其他的执行程序,比如 compile, asm, cgo, link 等。
go build 的执行过程,跟常见的 make 是类似的,大致有这么些事情:
调用 compile 将 go 文件(以及依赖文件)编译为 .a 文件
注意,这一步也是有缓存的,原文件没变更,则直接 copy .a 文件
如果期间有 .s 文件,则用 asm 来编译
如果有 import C,则调用 cgo 先生成一段 go 文件
最后,通过 link 链接成最终的可执行文件
如果想看具体的编译过程,可以指定 -x,比如:
1 2
# -work 表示保留编译时的临时目录 go build -x -work .
值得一提的是,go test 也是先 build 生成一个,封装了测试框架的可执行文件,所以,build 的参数也同样可用。 比如:
1
go test -x -work
PS: go test 默认编译出来的是不带调试符号的,如果需要调试,可以加上 -o file 指定可执行文件,这样可以启用调试符号。 (貌似这个在 MacOS 上并不有效,还是 Linux 上可靠 :<)
自举过程
go 语言是完成自举了的,自举大致过程:
首先需要一个老版本的 go,1.4+,先用老的 go,编译 cmd/dist,生成 dist 可执行文件;再用这个 dist 来完成新版本 go 的编译。
在输出日志中,可以看到一下主要步骤:
1 2 3 4 5 6 7 8 9 10 11 12 13
# 老的 go 编译 cmd/dist Building Go cmd/dist using /path/to/old/go. (go1.14.15 linux/amd64) # 接下来的几步,都是 cmd/dist 来执行的 # 老的 go,编译新代码的工具链,compile, asm, link, cgo Building Go toolchain1 using /path/to/old/go. # 新工具链,编译 go 命令,这里叫 go_bootstrap Building Go bootstrap cmd/go (go_bootstrap) using Go toolchain1. # 新的 go_bootstrap,重新编译工具链,以为 toolchain1 没有 buildid Building Go toolchain2 using go_bootstrap and Go toolchain1. # 再来一回,还是因为 buildid,为了更加一致 Building Go toolchain3 using go_bootstrap and Go toolchain2. # 使用 go_bootstrap,编译完整的 go Building packages and commands for linux/amd64.
简单解释一下:
dist 只是一个封装的临时,编译的时候,还是用的 compile, link 这种编译工具(也就是 toolchain)
先用老的 go 重新编译新的 toolchain,然后再用新的 toolchain 编译新的 go(中间有一些重复编译 toolchain,不是那么重要)
如果想看具体的编译过程,可以指定 -v=5,比如:
1
bash -x all.bash -v=5
总结
go 对编译构建工具都提供了完整的封装,这个对于使用者而言,确实是更方便了,不需要自己折腾 Makefile,或者 bazel 这种构建工具了。
其具体过程,则跟常见的构建工具是类似的,分开编译,中间结果缓存,最后链接。
平常开发倒是用不着了解这些,不过要是修改 go 本身的话,对这些过程还是得比较清楚才行了;尤其是跑测试的时候,dist 命令中的 test,又是封装了一层编译的测试过程,这里面一环套一环的,还是比较多细节的。