cgo 机制 - 从 c 调用 go

2022-10-08 · ·

图片

文|朱德江(GitHub ID:doujiang24)

MOSN 项目核心开发者蚂蚁集团技术专家

图片

专注于云原生网关研发的相关工作

本文 4656 字 阅读 12 分钟

一、前言

去年刚学 go 语言的时候,写了这篇 cgo 实现机制[1] ,介绍了 cgo 的基本情况。主要介绍的是 go=>c 这个调用方式,属于比较浅的层次。随着了解的深入,发现 c=>go 的复杂度又高了一级,所以有了这篇文章。

二、两个方向

首先,cgo 包含了两个方向, c=>go ,go=>c 。

相对来说,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 数据结构。

简单来说,c 线程需要获取的 GMP ,就是三个数据对象。在具体的实现过程中,是分为两步来的:

1. needm 获取一个 extra M

开启了 cgo 的情况下,go runtime 会预先创建好额外的 M ,同时还会创建一个 goroutine,跟这个 M 绑定。所以,获取到 M,也就同时得到了 G。

而且,go runtime 对于 M 并没有限制,可以认为是无限的,也就不存在获取不到 M 的情况。

2.exitsyscall 获取 P

是的,这个就是 go=>c 的反向过程。只是 P 资源是有限的,可能会出现抢不到 P 的情况,此时就得看调度机制了。

四、调度机制

简单情况下,M 和 P 资源都顺利拿到了,这个 c 线程,就可以在 M 绑定的 goroutine 中运行指定的 go 函数了。更进一步,如果 go 函数很简单,只是简单的做点纯 CPU 计算就结束了,那么这期间则不依赖 go 的调度了。

有两种情况,会发生调度:

1. exitsyscall 获取不到 P

此时没法继续执行了,只能:

1.将当前 extra M 上绑定的 g ,放入全局 g 等待队列

2.将当前 c 线程挂起,等待 g 被唤起执行

在 g 被唤起执行的时候,因为 g 和 M 是绑定关系:

1.执行 g 的那个线程,会挂起,让出 P ,唤起等待的 c 线程

2.c 线程被唤起之后,拿到 P 继续执行

2. go 函数执行过程中发生了协程挂起

比如,go 函数中发起了网络调用,需要等待网络响应,按照之前介绍的文章,Goroutine 调度 - 网络调用[2] 。当前 g 会挂起,唤醒下一个 g ,继续执行。

但是,因为 M 和 g 是绑定关系,此时会:

1. g 放入等待队列

2.当前 c 线程被挂起,等待 g 被唤醒

3. P 被释放

在 g 被唤醒的时候,此时肯定不是在原来的 c 线程上了

1.当前线程挂起,让出 P,唤醒等待的 c 线程

2.c 线程被唤醒后,拿到 P,继续执行

直观来说,也就是在 c 线程上执行的 goroutine,并不像普通的 go 线程一样,参与 go runtime 的调度。对于 go runtime 而言,协程中的网络任务,还是以非阻塞的方式在执行,只是对于 c 线程而言,则完全是以阻塞的方式来执行了。

为什么需要这样,还是因为线程的调用栈,只有一个,没有办法并发,需要把线程挂起,保护好调用栈。

PS:这里的执行流程,其实跟上面抢不到 P 的流程,很类似,底层也是同一套函数在跑(核心还是 schedule)。

五、信号处理

另外一大差异是,信号处理。

  1. c 语言世界里,把信号处理的权利/责任,完全交给用户了。

  2. go 语言,则在 runtime 做了一层处理。

比如,一个具体的问题,当程序运行过程中,发生了 segfault 信号,此时是应该由 go 来处理,还是 c 来响应信号呢?

答案是,看发生 segfault 时的上下文:

1.如果正在运行 go 代码,则交给 go runtime 来处理

2.如果正在运行 c 代码,则还是 c 来响应

那具体是怎么实现的呢?信号处理还是比较复杂的,有比较多的细节,这里我们只介绍几个核心点。

1. sighandler 注册

首先,对于操作系统而言,同一个信号,只能有一个 handler 。再看 go 和 c 发生 sighandler 注册的时机:

  1. go 编译产生的 so 文件,被加载的时候,会注册 sighandler(仅针对 go 需要用的信号),并且会把原始的 sighandler 保存下来。

  2. c 可以在任意的时间,注册 sighandler,可以是任意的信号。

所以,推荐的做法是,在加载 go so 之前,c 先完成信号注册,在 go so 加载之后,不要再注册 sighandler 了,避免覆盖 go 注册 sighandler。

2.信号处理

对于最简单的情况,如果一个信号,只有 c 注册了 sighandler,那么还是按照常规 c 信号处理的方式来。

对于 sigfault 这种,go 也注册了 sighandler 的信号,按照这个流程来:

1.操作系统触发信号时,会调用 go 注册的 sighandler(最佳实践中,go 的信号注册在后面);

2.go sighandler 先判断是否在 c 上下文中(简单的理解,也就是没有 g,实际上还是挺复杂的);

3.如果,在 c 上下文中,会调用之前保存的原始 sighandler(没有原始的 sighandler,则会临时恢复 signal 配置,重新触发信号);

4.如果,在 go 上下文中,则会执行普通的信号处理流程。

其中,2 和 3 是最复杂的,因为 cgo 包含了两个方向,以及信号还有 sigmask 等等额外的因素,所以这里细节是非常多的,不过思路方向还是比较清晰的。

六、优化

上篇 cgo 实现机制[1] ,提过优化一些思路,不过主要针对 go => c 这个方向。因为 c => go 的场景中,还有其他更重要的优化点。

1.复用 extra M

通常情况下,最大的性能消耗点在获取/释放 M

1.上面提到,从 c 进入 go,需要通过 needm 来获取 M 。这期间有 5 个信号相关的系统调用。比如:避免死锁用的,临时屏蔽所有信号,以及开启 go 所需要的信号。

2.从 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 机制。

目前实现了一版在 CL 392854[3] 。虽然通过了一个大佬的初步 review,以及跑通了全部测试,不过,估计要合并还要很久…因为这个 PR 已经比较大了,被标记 L size 了,这种 CL 估计大佬们 review 起来也头大…

在简单场景的测试中,单次 c => go 的调用,从 ~1600ns 优化到了 ~140ns,提升 10 倍,达到了接近 go => c 的水平( ~80ns )效果还是挺明显的。

实现上主要有两个较复杂的点:

1.接收到信号时,判断在哪个上下文里,以及是否应该转发给 c。因为 cgo 有两个方向,而且这两个方向又是可以在一个调用栈中同时发生的,以及信号还有 mask ,系统默认 handler 之分。这里面已经不是简单的状态机可以描述的,go runtime 在这块有约 100 + 行的核心判断代码,以应对各式各样的用法。估计没几个人可以全部记住,只有碰到具体场景临时去分析。或者在跑测试用例失败的时候,才具体去分析。

2.在 c 线程退出,callback 到 go 的时候,涉及到 c 和 go function call ABI 对齐。这里主要的复杂度在于,需要处理好不同的 CPU 体系结构,以及操作系统上的差异。所以工作量还是比较大的。比如 arm ,arm64 , 期间有一个有意思的坑,Aarch64 的 stack pointer 必须是 16 byte 对齐的,否则会触发 bus error 信号。(也因此 arm64 的压栈/出栈指令,都是两个两个操作的)

2.获取不到 P

从 c 进入 go,获取 GMP 的过程中,只有 P 资源是受限的,在负载较高时,获取不到 P 也是比较容易碰到的。

当获取不到 P 时,c 线程会挂起,等待进入全局队列的 g 被唤醒。这个过程对于 go runtime 而言是比较合理的,但是对于 c 线程则比较危险,尤其当 c 线程中跑的是多路复用的逻辑,则影响更大了。

此时有两个优化思路:

1.类似 extra M ,再给 c 线程绑一个 extra P ,或者预先绑定一个 P 。这样 c 线程就不需要被挂起了。这个思路,最大的挑战在于 extra P ,是不受常规 P 数量的限制,对于 go 中 P 的定义,是一个不小的挑战。

2.将 g 不放入全局队列,改为放到优先级更高的 P.runnext ,这样 g 可以被快速的调度到,c 线程可以等待的时间更短了。这个思路,最大的挑战则在于,对这个 g 加了优先级的判断,或许有一点有悖于 g 应该是平等的原则。不过应该也还好, P.runnext 本来也是为了应对某些需要优先的场景的,这里只是多了一个场景。

这个优化方向,还没有 CL,不过我们有同学在搞了。

3.尽快释放 P

当从 go 返回 c 的时候,会调用 entersyscall ,具体是,M 和 P 并没有完全解除绑定,而是让 P 进入 syscall 的状态。

接下来,会有两种情况:

1.很快又有了下一个 c=>go 调用,则直接用这个 P ;

2.sysmon 会强制解除绑定。对于进入 syscall 的 P ,sysmon 会等 20 us => 10 ms,然后将 P 抢走释放掉。等待时间跨度还是挺大的,具体多久就看命了,主要看 sysmon 是否之前已经长时间空闲了。

对于 go => c 这方向,一个 syscall 的等待时间,通常是比较小的,所以这套机制是合适的。但是对于 c => go 这个方向,这种伪 syscall 的等待时间,取决于两个 c => go 调用的间隔时间,其实不太有规律的。所以,可能会造成 P 资源被浪费 20us => 10ms。

所以,又有一个优化方向,两个思路:

1.从 go 返回 c 的时候,立即释放 P ,这样不会浪费 P 资源。

2.调整下 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 异步化:

意思是,调用 Go 函数,不再同步返回函数返回值,而是返回一个带状态 g,这样的好处是,因为 API 异步了,所以执行的时候,也不必同步等待 g 返回了。如果碰到 g 被挂起了,直接返回 status = yield 的 g 即可,goroutine 协程继续走 go runtime 的调度,c 线程也不必挂起等待了。

这样的设计,对于 c 线程是最友好的,当然也还得有一些配套的改动,比如缺少 P 的时候,得有个 extra P 更好一些,等其他的细节。

不过,这样子的改动还是比较大的,让 go 官方接受这种设计,应该还是比较难的,以后没准可以试试,万一接受了呢~

九、相关链接

[1] cgo 实现机制:https://uncledou.site/2021/go-cgo/

[2] Goroutine 调度 - 网络调用:https://uncledou.site/2021/goroutine-schedule-network/

[3] CL 392854 : https://go-review.googlesource.com/c/go/+/392854

本周推荐阅读

MOSN 反向通道详解

Go 原生插件使用问题全解析

Go 内存泄漏,pprof 够用了么?

从规模化平台工程实践,我们学到了什么?