为什么 cgo 会有性能开销
为什么 cgo 会有性能开销
很多人第一次听到这件事,直觉都会有点不服。
Go 调 C,明明还在同一个进程里,没有网络,没有磁盘,没有系统调用风暴,为什么就会比普通 Go 函数调用贵这么多?
如果把这个问题说得更尖一点,就是下面这句:
同样是“调用一个函数”,为什么 goFunc() 可以快到几乎看不见,而 C.func() 一下子就有了明显的固定成本?
我的结论先放前面:
cgo 慢,不是因为“跨语言”这四个字自带魔法,而是因为 Go runtime 在进入 C 代码之前,要先把调度器、栈、ABI 和指针规则都安顿好。真正贵的,不是那次函数跳转本身,而是跳转前后的整套交接手续。
这也是为什么很多人会把 cgo 的性能问题看偏。真正需要盯住的,往往不是“C 语言是不是比 Go 快”,而是“你是不是在高频小调用里反复穿越这条边界”。
先看一个数字
我在 Apple M4 Pro、macOS arm64、Go 1.25.12 上做了一个最小基准:一边是 //go:noinline 的 Go 加法函数,一边是 __attribute__((noinline)) 的 C 加法函数。
结果是这样:
| 调用 | 耗时范围 | Go 堆分配 |
|---|---|---|
| Go 函数调用 | 0.69–0.74 ns/op |
0 B/op |
| cgo 调用 C 加法 | 22.40–22.82 ns/op |
0 B/op |
| 一次 cgo 内执行 1000 次加法 | 491.6–509.1 ns/op |
0 B/op |
这个数字不该被理解成“cgo 永远就是 22ns”,因为平台、编译器、调用签名和 C 函数行为都会影响结果。它真正说明的是另一件事:cgo 有一笔和业务逻辑强弱关系不大的固定过桥成本。你跨一次,就交一次;你把 1000 次小操作合并成一次跨界,这笔费用就被摊薄了。
基准源码和原始输出我放在这里:
- 本地配套目录:
cgo-benchmark - GitHub 仓库:moyin1004/cgo-benchmark
- 核心测试文件:cgo_bench_test.go
一次 Go 调 C,到底发生了什么
假设你在 Go 里写了一句:
ret := C.add(a, b)
很多人脑子里默认的画面是:
Go 函数
-> C 函数
但真实路径要长得多。cmd/cgo 会先生成一层 Go wrapper 和一层 C wrapper。Go 侧先把参数和返回值放进一个 frame,再调用 runtime.cgocall;随后 runtime 切到合适的执行上下文,再由 C wrapper 去调用真正的 C 函数,最后再把结果写回来。
Go 源码中给出的路径基本就是:
Go wrapper
-> runtime.cgocall
-> runtime.asmcgocall
-> cgo 生成的 C wrapper
-> 真正的 C 函数
对应源码可以直接看:
问题的关键已经出来了:cgo 并不是“普通函数调用换了个语法”,而是 Go runtime 主动把一段执行权临时交给外部 C 代码,然后再把它接回来。
第一笔成本:调度器要知道你去了 C
这一步最容易被忽略。
Go 运行时不是裸跑在操作系统线程上,它中间还有 G、M、P 这一层调度模型。你一旦进入一段耗时未知、runtime 无法直接管理的 C 代码,调度器就必须知道这件事。否则当前线程如果一直卡在 C 里,P 也跟着被占住,其他 goroutine 就可能无端挨饿。
所以 runtime.cgocall 在进 C 之前会先执行 entersyscall,回来之后再执行 exitsyscall。
这两个动作看起来像记账,实际上非常关键:
- 进入 C 前,runtime 要把当前执行状态标记成“外部调用中”
- 让 P 有机会被别的 M 拿去继续跑 Go 代码
- 从 C 返回后,再想办法重新回到 Go 调度体系
也就是说,cgo 的一个固定成本,不是“真的一定发生了线程切换”,而是“每次都要完成一次调度状态转换”。这件事在 C 函数很短时格外扎眼,因为你的业务工作量还没开始,runtime 的边界手续已经先走了一遍。
flowchart TD
subgraph G_Layer["Goroutine 层"]
G["当前 G<br/>使用可增长、可移动的 Go 栈"]
OtherG["其他可运行 G"]
end
subgraph Runtime_Layer["Go runtime"]
M["M:操作系统线程"]
P["P:执行 Go 代码所需的调度资源"]
G0["g0:M 的系统栈<br/>用于调度及执行外部 C 代码"]
end
subgraph Native_Layer["原生代码"]
Wrapper["cgo 生成的 C wrapper"]
CFunc["目标 C 函数"]
end
M -->|"关联"| P
M -->|"持有"| G0
P -->|"调度"| G
P -->|"也可调度"| OtherG
G -->|"runtime.cgocall"| RuntimeCall["entersyscall"]
RuntimeCall -->|"asmcgocall 切换栈与 ABI"| G0
G0 --> Wrapper
Wrapper --> CFunc
RuntimeCall -.->|"P 进入 syscall 状态<br/>必要时可被调度器回收"| P
style G fill:#dbeafe,stroke:#2563eb
style G0 fill:#ffedd5,stroke:#ea580c,stroke-width:2px
style CFunc fill:#dcfce7,stroke:#16a34a
第二笔成本:Go 栈不能直接拿给 C 用
Go 的 goroutine 栈和 C 习惯的线程栈不是一回事。
goroutine 使用的是可增长、可移动的 Go 栈。C 编译器生成的代码默认面对的是普通系统线程栈,并按平台 ABI 去取参数、压栈、恢复寄存器。两边的假设不同,所以 Go 不能直接把当前 goroutine 栈原封不动交给 C。
普通 goroutine 发起 cgo 调用时,runtime.asmcgocall 会把执行切到当前 M 的 g0 系统栈,再按平台 C ABI 调用目标包装函数。返回后再把原来的 G 和栈恢复回来。
这一步会带来两个含义很重的后果:
- 你不是直接从 Go 栈跳到 C,而是先切到了
g0 - Go 编译器也很难像优化纯 Go 调用那样,对这段边界做内联和深度优化
这就是为什么很多人用一句“cgo 有栈切换开销”概括它,方向是对的,但还不够具体。真正发生的是:runtime 在 goroutine 栈和系统栈之间做了切换,同时适配 Go ABI 和 C ABI。
sequenceDiagram
participant G as Goroutine(Go 栈)
participant RT as runtime.cgocall
participant S as 调度器 / P
participant G0 as asmcgocall(g0 栈)
participant C as C wrapper / C 函数
G->>RT: 调用 cgo 生成的 Go wrapper
activate RT
RT->>S: entersyscall
Note over RT,S: 保存 syscall PC/SP<br/>P 进入 syscall 状态,可被调度器回收
RT->>G0: asmcgocall(fn, frame)
activate G0
Note over G,G0: 保存原 Go 栈位置<br/>必要时切换到 m.g0 并适配 C ABI
G0->>C: 从 frame 取参并调用 C
activate C
C-->>G0: 写回返回值
deactivate C
G0-->>RT: 恢复原 G 与 Go 栈
deactivate G0
RT->>S: exitsyscall
Note over RT,S: 尝试重新获得 P<br/>必要时等待或重新调度
RT-->>G: 返回 Go wrapper
deactivate RT
第三笔成本:参数和指针都有规矩
很多文章一讲 cgo,就会直接说“因为要复制数据,所以慢”。这话只对了一半。
有些数据确实会复制,比如 C.CString、C.CBytes、C.GoBytes 这种显式转换,本身就带着分配和拷贝。但这不代表每次 cgo 调用都一定发生大对象复制。你如果传的是满足规则的缓冲区指针,完全可以零拷贝。
真正更棘手的,是 Go 和 C 对内存生命周期的管理方式不同。
Go 的垃圾回收器必须知道 Go 指针指向哪里、什么时候还能被访问;C 不受 Go GC 管理。所以当 Go 指针传给 C 时,runtime 和编译器都得格外保守:
- 这块内存在调用期间要保证稳定
- C 默认不能在调用结束后继续偷偷保存它
- 某些对象可能因为编译器证明不了安全性而逃逸到堆上
- 运行时还会做一部分动态检查
也就是说,cgo 在这一步的成本,不只是“参数搬过去”,而是“把参数搬过去的同时,保证 GC 和指针规则不被破坏”。
Go 1.25 提供了 #cgo noescape 和 #cgo nocallback 这类优化提示,可以告诉编译器或 runtime:某些更保守的准备没必要做。但这种优化本质上是在向 runtime 立军令状。写对了能省一点边界成本,写错了代价不是“慢一点”,而是内存破坏、panic,甚至直接崩溃。
官方规则在这里:
哪些开销不是 cgo 自带的
还有一个很常见的误区,是把所有性能问题都一股脑算到 cgo 头上。
其实最好把成本拆成两类来看。
第一类是边界固定成本,基本每次都有:
- 调度状态转换
- 切到
g0系统栈 - Go/C ABI 适配
- cgo 生成 wrapper 和参数 frame
第二类是条件性成本,要看你的 API 和 C 库本身怎么写:
- 是否发生字符串或字节复制
- 是否触发指针检查和逃逸
- C 库内部有没有全局锁
- 有没有 C 回调 Go
- C 函数自己是否做了分配、阻塞或系统调用
比如“锁竞争”这件事,就不该被写成 cgo 的固定税。C 库内部如果有大锁,它当然会慢;但那是这段 C 代码的行为,不是每次穿越 Go/C 边界就自动赠送的成本。
这件事想清楚以后,你会更容易看懂 profile。很多场景里,真正需要优化的不是“用不用 cgo”,而是“是不是把过多细碎调用拆得太碎了”。
为什么 C 调 Lua 的感觉又不一样
这也是原问题里一个特别好的追问。
很多人会说:C 调 Lua 也在传参数、拿返回值,看起来好像没 cgo 这么让人警惕。那区别到底在哪?
关键不在于“一个用栈,一个不用栈”,而在于跨越的边界不同。
Lua 的 C API 里确实有一套“栈”式接口:
lua_getglobal(L, "add");
lua_pushinteger(L, 1);
lua_pushinteger(L, 2);
lua_pcall(L, 2, 1, 0);
result = lua_tointeger(L, -1);
lua_pop(L, 1);
但这里的“栈”是 lua_State 维护的虚拟栈,不是 CPU 调用栈,也不是 goroutine 栈。C 调 Lua,跨过去的是“原生 C ABI -> Lua VM”这条边界;Go 调 C,则跨的是“Go runtime -> 原生 C”这条边界。
两者都不是零成本,但成本来源不同:
| 调用 | 主要边界 | 典型固定工作 |
|---|---|---|
| Go→Go | 同一语言运行时 | 普通调用约定,可能内联 |
| C→C | 同一原生 ABI | 保存寄存器、传参与返回 |
| C→Lua | C ABI→Lua VM | Lua 虚拟栈、动态类型、VM 调用 |
| Go→C(cgo) | Go runtime→原生 C | 调度记账、g0 栈切换、ABI 与 wrapper |
| Go→Lua(经 cgo) | 两条边界叠加 | cgo 固定成本 + Lua C API/VM 成本 |
所以 C 调 Lua 不是没有成本,而是它不需要向 Go 调度器交接,不需要从 goroutine 栈切到 g0,也不需要遵守 Go 那套 GC 指针规则。反过来,如果你是在 Go 里通过 cgo 去嵌入 Lua,那两边的成本会叠加,而不是二选一。
真正该怎么优化
到这里,问题其实已经从“cgo 为什么慢”变成了“什么时候它会慢到值得你动手”。
经验上最容易踩坑的,是下面这种场景:
你在 Go 里有一个大循环,循环里每次都调用一个特别短的 C 函数。C 函数只做一点点运算,结果 runtime 的边界成本反而成了主角。
这种情况下,最有效的优化往往不是去抠 2ns、5ns,而是改接口设计:
- 把 1000 次小调用合成 1 次批处理
- 把循环整体下沉到 C
- 避免来回创建
CString或反复拷贝[]byte - 尽量减少 Go→C→Go 的往返回调
相反,如果你的 C 函数本身就在做图像编解码、压缩、密码学运算、数据库驱动、推理内核调用,那么几十纳秒量级的固定过桥成本通常不是主要矛盾。那时更该查的是:
- 数据是不是被你自己多拷了一遍
- Go 对象是不是大面积逃逸了
- C 库有没有大锁
- 有没有高频回调 Go
换句话说,cgo 的问题往往不是“能不能用”,而是“怎么用得不蠢”。
最后一句话
如果非要把这篇文章压缩成一句能记住的话,我会这么说:
cgo 的性能开销,本质上是 Go runtime 为了安全地把执行权临时交给外部 C 代码,而支付的一笔固定边界成本。
它来自调度器交接、系统栈切换、ABI 适配和指针规则维护。真正危险的不是这笔成本存在,而是你在一个高频、小粒度、边界来回穿越的场景里,反复为它买单。
边界消不掉,但可以少过几次。只要每次过去都多干点正事,cgo 往往就没那么可怕了。