This article also has an English version.
一年前我写了一个叫 Rust2Go 的开源项目(相关 blog:Rust-Golang FFI 框架设计与实现),提供 Rust 到 Go 的高性能异步和同步调用支持。这个项目是多个社区项目,也是公司内部多个的项目的重要基础,我也在持续地优化其性能并开发新的功能。
我将在 Rust Asia Conf 2025 上分享相关内容,欢迎有兴趣的朋友参与!
最近我做了一些 CGO 相关的探索,并基于新开发的高性能 CGO 为 Rust2Go 支持了 Go 到 Rust 的主动调用,本文主要介绍前者。
注:本文非 Rust 限定内容,所有 Go 跨语言项目都可以使用或参考。本文也提供了对应的仓库和例子,欢迎有需要的用户接入使用。
本文将按以下顺序展开:
- 介绍 CGO 调用的原理和性能问题,也是本文的优化目标;
- 介绍如何通过最简单的汇编优化 CGO 调用;
- 指出栈空间问题,并介绍如何通过切换 G0 栈解决该问题;
- 介绍 Async Preemption,以及如何阻止它以保证 G0 栈不被污染;
- 优化效果和应用场景。
CGO 调用
Golang 通过 CGO 提供了遵循 C 调用约定的函数调用和被调用能力。无论对哪个方向的调用,我们都需要这两个能力以完成调用发起和返回值传递。
与常规 Native Language 的 FFI 不同,Golang 代码执行需要 Runtime,所以其他语言到 Go 的函数调用本质上与其说是 FFI,不如说是一次 task dispatch。这里有较大的成本,但很多成本也是必要的:例如操作 task queue 等必要的竞争开销、唤醒 Go threads 的跨线程开销等。这一步的实现取决于 Go Runtime 内部实现,较难以深入做优化。
从 Golang 发起的调用是一个容易优化的地方,本文要介绍的优化主要针对这个 case。
我们以一个简单的函数调用入手:
1 | package main |
这段代码自带了一个空的 C 函数实现,并在 main 函数中通过 C.
前缀来调用它。
通常,这种代码在 Native Language 中是可以以极低的成本发起的。因为无论是 C 函数还是其他形式,其产物都是二进制指令,编译器只需要识别出目标 ABI 并对齐即可,而在这个例子中调用约定的转换应当是成本极低的,因为它没有参数和返回值。
但如果你对其做 benchmark,你会发现这个过程会耗费接近 30ns:这是一个相对昂贵的开销。
汇编优化
前面我们观测到 CGO 的昂贵开销,并明白其本质都是二进制指令,那么我们为什么不能直接插入汇编发起调用呢?
事实上,在 7 年前 fastcgo 和 rustgo 就做了这种尝试,但这份代码无法在较新的 Golang 中使用。
如果不考虑一些别的因素,我们只需要写一段汇编,将参数从 Go ABI 转换为 C ABI,并执行 CALL 指令。
事实上这是不够的,下文会有解释。
让我们从零按照这个思路尝试一遍!
Golang ABI
提到汇编调用,首先我们需要留意的是 ABI。ABI 是对两段二进制代码之间的接口的规范,它定义了函数调用时参数和返回值的传递方式、寄存器的使用、栈的使用等。
早期版本的 Golang 没有明确且公开的 ABI,在 2018 年团队意识到这个问题,将其书面化为 ABI0
;并提出一个新的 ABI 标准 ABIInternal
。ABIInternal
的定位是滚动更新的内部 ABI 标准,ABI0
和后续可能的 ABI{n}
是这份 ABI 的 snapshot。
该提案的链接:https://github.com/golang/proposal/blob/master/design/27539-internal-abi.md 。
使用新的 ABI 可以带来潜在的性能提升。例如,ABI0 下所有的参数和返回值均通过栈传递,这会使其性能显著差于寄存器传递;ABI0 下在 CALL 时所有寄存器均视为 clobbered,这会导致在一些 case 下,即便被调用方没有破坏某个寄存器,调用方也要保存和恢复寄存器,这是毫无必要的。
Golang 中手写汇编遵循的 ABI 是 ABI0。
很可惜!Plan9 和 Go ABI 都阻止我们使用寄存器传参和手动标记 clobber register 来进一步压榨性能,好在我们的核心逻辑并不复杂,即便是最简单实现也足够了。
扩展阅读:如果你对 clobber register 感兴趣,这里还有一个有趣的例子,它通过手动标记 clobber register 来把 stackful coroutine 切换时的寄存器操作交给编译器,以减少非必要的寄存器保存和恢复。
最简 ASM
下面我以两个入参为例,实现 Go ABI 到 System V ABI 的转换。这里主要解决参数传递问题,我们需要将参数按 Go ABI 从栈上读出,并按 System V ABI 写入约定的寄存器。
amd64.go
:
1 | //go:noescape |
amd64.s
:
1 | #include "textflag.h" |
这段代码将参数从栈上读出,并按照 System V ABI 将其分别保存到 RDI、RSI 寄存器;并将目标函数地址加载到 RAX;之后执行 CALL AX。
PLAN9 汇编代码需要遵循一定的约定,其中 NOSPLIT 避免函数被插入栈大小检查代码;NOPTR 标记不含指针,GC 无需扫描;NOFRAME 避免分配栈帧(要求声明栈大小为 0);$0
表示声明所需栈大小为 0。
PLAN9 有一个伪寄存器概念,例如参数实际上是通过 SP 和一个编译期确定的偏移来访问的,但在使用汇编代码访问时是通过 FP
寄存器访问:symbol+offset(FP)
,FP
在这里就是伪寄存器,同样的,SB
、PC
、TLS
也是。
如果我们对其做 benchmark,可以观察到这种方式可以将单次 CGO 调用耗时从接近 30ns 降低到 1ns 左右!
栈切换
到这里你可能会疑惑,如果真的这么简单,为什么 CGO 实现的这么差?事实上,前面的这份实现是存在问题的,其中一个问题是栈大小问题。
插桩扩栈 vs 主动换栈
Golang 为每个 goroutine 在堆上分配较小的初始栈空间,并通过编译器在函数头自动插入栈大小检查代码,并在栈空间不够时,移动并扩张栈来使得函数在执行时一定有足够的栈空间可用。
但是这种插桩检查无法在 foreign language 里实现,即便以某种方式完成了插桩检查,foreign language 的指针标记和内存管理也需要与 Go 使用同一套以做到在栈扩张时指针目标可以被正确修改,这几乎是不可能的事情,且毫无收益。
如果我们忽视这个栈大小问题,就只能让外部代码自行分配、管理并切换栈,这样才能不占用任何 goroutine 的栈空间,避免栈空间不够用的问题。可是,这样会给对端实现者带来巨大的负担。有没有更好的办法呢?
切换 G0 栈
我们不妨思考一下,Golang 内部在 goroutine 栈空间不够时,它自身执行逻辑来管理内存、扩栈等,就不需要栈空间吗?它是从哪弄来的栈呢?
答案是 G0 栈。
Golang 使用 GMP 模型。G=Goroutine,M=Thread,P=逻辑上的 Worker。G0 是每个 M 启动时创建的第一个 G,用于执行调度相关的代码;G0 栈对应的空间位于线程栈中,容量小于线程栈,但大于 goroutine 栈。
1 | MOVD $runtime·g0(SB), g |
这段汇编在线程栈中分配 64K 的空间作为 G0 栈空间。
64K 可以认为是一个相对较大的栈空间,足够执行常规函数。我们可以在汇编函数入口将 SP 寄存器切换到 G0 栈对应地址。
从 runtime2.go 中我们可以拿到一些结构定义,我们需要一些相应的偏移量来访问字段。
1 | type g struct { |
可以看出,我们可以通过 g 偏移 0x30 处拿到 m 指针,并通过 m 偏移 0x0 处拿到 g0 指针,最后通过 g0 偏移 0x38 处拿到 gobuf,而 gobuf 的第一个字段即 SP。
此时我们可以写出切换到 G0 栈的汇编代码:
amd64.go
:
1 | //go:noescape |
amd64.s
:
1 | #include "textflag.h" |
这段代码依次完成:
- 将参数从栈上读到寄存器中
- 保存旧的 SP 到 R8
- 将 G0 栈地址加载到 SP 寄存器
- 完成栈对齐,推入 R8(旧的 SP)并保持栈对齐状态
- 执行 CALL
- 从栈上恢复 SP 并 RET
此时如果我们调用一个需要较大栈空间的简单外部函数,可以验证其正确执行。
很好,可这就结束了吗?如果你对照前文提到的 fastcgo 代码(事实上我就是在 PoC 写到这里时才发现的前人文章),会发现确实差不多。
栈大小 Probe & Crash?
我还想做一些不那么切实际的尝试,例如,我是否可以有条件地切换栈?只有当 goroutine 栈空间不够时才切换,有可能可以降低 cache miss 达到更好的性能?
这时需要回答一个问题:多大才算够大?为此,我想探测在我当前场景下的外部函数空间占用(通常我会使用 CGO 执行 Rust 侧的 callback,该函数内部通常行为是 wake 一个 task)。
我想到一个 idea:我可以在执行 CALL 之前向 SP-${n}
处写入一个 canary,并在 CALL 执行完成后再次读取一下,如果与写入值相同,则代表 untouched,表示栈使用小于 n。这甚至不需要真的写代码,实际上我在 lldb 中使用 memory write/read
完成了这项任务。
可是它 crash 了。也许是我的想法太过离谱?在几次 debug 之后,即便我只用 lldb 打断点,而在暂停时不执行内存写入,它也会 crash。
Crash 的报错是 Rust 侧内存异常,在查看结构体对应内存后,我意识到问题并不简单,真正的问题出在 Golang 的抢占式调度上。
Async Preemption
Golang 的调度通常被认为是协作式的,这也是 coroutine 一词中 co 的来源。从较早期的版本开始,Golang 编译器会在函数的入口、内存分配等时机,检查当前 coroutine 的连续执行时长。一旦超过某个阈值,则应当切换到别的 coroutine 执行。
这个机制的坏处是任务切换依赖函数或内存操作,而如果是死循环纯计算,则无法完成切换。Golang 的开发者当然意识到了这个问题,从 Go 1.14 开始,引入了主动抢占机制,代码中称之为异步抢占。
Async Preemption 是基于信号机制实现的。当 monitor 线程发现某个 goroutine 耗时太久时,它会发送信号 SIGURG
到目标线程,此时会导致内核执行暂停该线程执行,切换至信号处理函数。在信号处理函数中,Go 有机会完成 goroutine 切换,或将当前 M 与 P 解绑(常见于用户直接发起阻塞 syscall 时)。
前面提到的 crash,则就是由于 Go 发送信号到目标线程,目标线程执行信号处理函数是在独立的 gsignalStack 执行的,这个没什么问题。但如果 Go Runtime 决定做切换或执行了任何依赖 G0 栈的代码,就会写坏 G0。虽然信号处理函数结束后,将寄存器状态还原,它认为毫无痕迹地恢复了先前的状态,但它并没有预料到我们实际上也依赖了 G0 untouched 这个条件。
阻止 Async Preemption
我们必须阻止 Async Preemption(或者我们也可以临时屏蔽信号,但每次调用都涉及 syscall 的话性能会很差)。
下面以 unix amd64 为例分析 Go Async Preemption 触发过程:
1 | TEXT runtime·sigtramp(SB),NOSPLIT|NOFRAME,$0 |
1 | const sigPreempt = _SIGURG |
sigtramp
是信号处理函数入口,在将 G 切换为 gsignal
后,执行 runtime.sighandler
。sighandler
执行抢占需要满足以下条件:
- 信号是 SIGURG
- asyncpreempt 功能未被关闭
- 信号未被 delay
满足条件后判定 wantAsyncPreempt
和 isAsyncSafePoint
,符合则执行抢占调度。
1 | func wantAsyncPreempt(gp *g) bool { |
可以看出,修改 G 的状态可以打破抢占条件;修改 M 的状态也可以。这里使用一个最简单的修改方式:
1 | // Only user Gs can have safe-points. We check this first |
这里将 g 修改为 G0 即可打破该条件。
代码实现
amd64.go
:
1 | //go:noescape |
amd64.s
:
1 | #include "textflag.h" |
这段代码中,在将参数加载到寄存器后,更新 g 为 g0,切换 SP 到 G0 栈,并在 G0 栈中保存旧的 SP 和 g。
这样可以保证在任意时刻被信号打断后,要么此时尚未操作 g0 栈并可被抢占,要么此时抢占条件已被打破,此时可能操作了 g0 栈。这样我们就可以安全地运行任意函数,不用担心由于函数耗时过久被信号打断导致 g0 栈被污染。
Benchmark
通过 benchmark,可以得到以下数据:
1 | go1.18: |
可以看出,对比 CGO 版本,ASM G0 版本调用可以大幅降低单次调用耗时(28ns -> 2ns);而不切换栈的原地操作版本相比切换 G0 的版本并没有节省太多开销。
基于这个数据重新思考前面提到的条件化切换栈的思路,其分支开销和爆栈风险是远大于性能收益的(栈空间占用只能被不精确地估计,且其取决于编译器版本),所以我决定暴露切换 G0 版本和原地版本两个调用接口:当用户明确目标函数不占用栈空间时,可以使用原地版本,否则需要使用 G0 版本。
CGO or ASM?
从前面的数据可以看出,CGO 相比解决了栈切换和异步抢占后的汇编实现还是存在很大 overhead。那么我们是否可以在任何时候都使用 ASM 来发起函数调用呢?
事实上,前面的方案是屏蔽抢占,而非使其可被抢占。所以如果函数耗时较久(较重的纯计算或阻塞 syscall),这可能导致 Go 的 Worker(P)被占用过久,导致其 local task 延迟升高,所以这并不是一个通用的方案。
ASM 方案也存在一些风险,当前已知的是当 Golang 版本变动时,无法保证偏移量与旧版一致(但好消息是这部分不常变更,我测试了 Go 1.18、1.22 和 1.23 都是正常工作的)。
结论:对于耗时较短的函数,在高频调用且对性能有极高要求时,建议使用 ASM 形式发起调用。
一个调用的例子:
go:
1 | /* |
rust/C/…:
1 |
|
- 在 Go 中通过
import "C"
来引入 C 函数声明 - 在 Go 代码中通过
asmcall.CallFuncG0P{n}
发起调用 - 在对端(Rust/C/C++/…)实现函数,并导出为 C ABI 函数,保持函数名不变
- 以合适的方式链接即可(可以参考 Rust2Go 中的 examples)
使用 asmcall 预计可以为每次调用可以节省 25ns 以上的开销。而如果你需要使用 CGO 的方式完成调用,那么你只需要将上述代码中的 asmcall
包更换为 cgocall
即可。
应用场景 & 总结
本技术适用于从 Golang 发起的跨语言调用,可用于在 Golang 中调用 Rust、C、C++ 等语言实现的函数。当频繁调用轻度或中等复杂度的外部函数时,通过 ASM 替代 CGO 可以显著提升性能。
使用 ASM 替代 CGO 可以将单次调用耗时从 28ns 降低到 2ns 左右。
欢迎有需要的用户接入使用!