共计 5799 个字符,预计需要花费 15 分钟才能阅读完成。
文|朱德江(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
)。
五、信号处理
另外一大差别是,信号处理。
- c 语言世界里,把信号处理的权力 / 责任,齐全交给用户了。
- go 语言,则在 runtime 做了一层解决。
比方,一个具体的问题,当程序运行过程中,产生了 segfault 信号,此时是应该由 go 来解决,还是 c 来响应信号呢?
答案是,看产生 segfault 时的上下文:
1. 如果正在运行 go 代码,则交给 go runtime 来解决
2. 如果正在运行 c 代码,则还是 c 来响应
那具体是怎么实现的呢?信号处理还是比较复杂的,有比拟多的细节,这里咱们只介绍几个外围点。
1. sighandler 注册
首先,对于操作系统而言,同一个信号,只能有一个 handler。再看 go 和 c 产生 sighandler 注册的机会:
- go 编译产生的 so 文件,被加载的时候,会注册 sighandler(仅针对 go 须要用的信号),并且会把原始的 sighandler 保留下来。
- 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 异步化:
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 官网承受这种设计,应该还是比拟难的,当前没准能够试试,万一承受了呢~
九、相干链接
[1] cgo 实现机制:https://uncledou.site/2021/go…
[2] Goroutine 调度 – 网络调用:https://uncledou.site/2021/go…
[3] CL 392854 :https://go-review.googlesourc…
本周举荐浏览
MOSN 反向通道详解
Go 原生插件应用问题全解析
Go 内存透露,pprof 够用了么?
从规模化平台工程实际,咱们学到了什么?