7.2 内存与指针规则
上一节说 cgo 的固定开销是几十纳秒;这一节说一个更贵的东西——一次指针规则违例,可能让你丢掉一整晚的调试时间。
cgo 的指针规则不是「建议」,而是硬性契约。违反了,程序的崩溃点常常离肇因十万八千里:你在 A 处把一个 Go 指针交给了 C,C 把它存进了自己的结构体,三分钟后 GC 回收了那块内存,C 在 B 处访问它——段错误发生在 B,而真正的错误在 A。
本节要回答的问题是:哪些指针能跨过 Go/C 边界,运行时如何拦截违例。结论先行:Go 指针可以传给 C,但前提是它指向的内存不含任何 Go 指针;C 不得长期保存 Go 指针,除非先用
runtime.Pinner钉住(Go 1.21 引入,已用api/go1.21.txt核实);cgocheck默认开启并会在越界时直接 panic,而cgocheck=2已从运行期开关迁移到编译期GOEXPERIMENT=cgocheck2(实测确认)。
7.2.1 四条核心规则
cgo 的指针规则可以压缩成四条:
| 规则 | 内容 | 后果 |
|---|---|---|
| R1 | Go 指针可传给 C,但它指向的内存不得含 Go 指针 | 违例直接 panic |
| R2 | C 不得在调用返回后保存 Go 指针 | GC 后悬垂,随机崩溃 |
| R3 | Go 指针不得指向 C 内存里的 Go 指针 | 编译期/运行期报错 |
| R4 | C 指针(C.malloc 等)返回 Go 后,由 Go 负责释放 | 不释放即泄漏 |
这四条背后只有一个目的:保证 GC 能看清所有还活着的 Go 指针。GC 是并发的、非移动的,但它需要准确知道哪些对象可达。如果 C 悄悄持有 Go 指针,GC 就看不见这条引用,可能提前回收。
7.2.2 规则 R1 实测:一个必然的 panic
R1 说的是「指针可以传,但它指向的内存里不能再有 Go 指针」。先看合规的情况——传一个 *int:
package main
/*
#include <stdlib.h>
static void store_ptr(void *p) { (void)p; }
*/
import "C"
import (
"fmt"
"unsafe"
)
func main() {
x := 42
C.store_ptr(unsafe.Pointer(&x)) // 合规:x 的内存里只有一个 int,不含指针
fmt.Println("passed *int to C, x =", x)
}
实测输出:
$ GOTOOLCHAIN=go1.27.0 go run .
passed *int to C, x = 42
C string ok
现在把指针换成「一个指向 Go 指针的结构体」,这就踩中 R1:
type box struct{ p *int }
func main() {
n := 7
b := &box{p: &n}
fmt.Println("about to pass struct containing a Go pointer...")
C.store_ptr(unsafe.Pointer(b)) // 违例:b 的内存里含 Go 指针
fmt.Println("no panic")
}
实测:
$ GOTOOLCHAIN=go1.27.0 go run .
about to pass struct containing a Go pointer...
panic: runtime error: argument of cgo function has Go pointer to unpinned Go pointer
goroutine 1 [running]:
main.main.func1(...)
注意 panic 的措辞:“Go pointer to unpinned Go pointer”——「指向未钉住的 Go 指针的 Go 指针」。这是运行时 cgocheck 在跨越边界的一瞬间拦截的。它拦得越早,你越幸运:如果它不拦,崩溃会推迟到 GC 之后。
7.2.3 关掉 cgocheck 会发生什么
cgocheck 是可以关的。用 GODEBUG=cgocheck=0 再跑一次同一个程序:
$ GODEBUG=cgocheck=0 GOTOOLCHAIN=go1.27.0 go run .
about to pass struct containing a Go pointer...
no panic
违例不再被拦截,程序「正常」跑完了。 这正是危险之处:违例没有消失,只是没有人再替你看着。它会在生产环境的某个 GC 周期之后,以一次随机的段错误现身。
生产环境永远不要关 cgocheck。唯一的例外是性能极端敏感的、且你已经用测试充分覆盖的边界——即便如此,也应该把它当作临时措施。
7.2.4 cgocheck=2 去哪了:迁移到 GOEXPERIMENT
cgocheck 有两档:1(默认,边界检查)和 2(更激进的检查,会验证 C 内存里是否被塞入 Go 指针)。历史上 cgocheck=2 是一个运行期开关。但在本机的 1.26 与 1.27 上实测,它已经不能这么用了:
$ GODEBUG=cgocheck=2 GOTOOLCHAIN=go1.27.0 go run .
fatal error: cgocheck > 1 mode is no longer supported at runtime. Use GOEXPERIMENT=cgocheck2 at build time instead.
runtime: panic before malloc heap initialized
报错信息本身给出了答案:改成编译期开关 GOEXPERIMENT=cgocheck2。实测验证它被接受(不报 unknown GOEXPERIMENT):
$ GOEXPERIMENT=cgocheck2 GOTOOLCHAIN=go1.27.0 go build .
$ echo $?
0
对照实验确认它不是 1.27 才有的新东西——在 1.26 上同样可用:
$ GOEXPERIMENT=cgocheck2 GOTOOLCHAIN=local go build . # local = go1.26.0
$ echo $?
0
顺带确认 1.27 的 GOEXPERIMENT 默认为空:
$ GOTOOLCHAIN=go1.27.0 go env GOEXPERIMENT
(空行)
结论:cgocheck=2 的运行期写法在 1.26 与 1.27 上都会 fatal error;要启用必须 GOEXPERIMENT=cgocheck2 编译。CI 里如果需要最严格的检查,就在构建命令前加这个环境变量。
7.2.5 runtime.Pinner:把 Go 对象钉住
R2 说「C 不得在返回后保存 Go 指针」。但有些 C 库的设计就是需要你交出一个长期有效的缓冲区——比如异步 I/O、回调上下文。怎么办?答案是钉住(pin):告诉 GC「这块内存不许动」。
Go 1.21 引入了 runtime.Pinner。核实它的引入版本:
$ grep -ln "type Pinner struct" /usr/local/go/api/go1.*.txt
/usr/local/go/api/go1.21.txt
用法如下——先 Pin,交给 C,用完再 Unpin:
func pinDemo() {
n := 99
var pinner runtime.Pinner
pinner.Pin(&n) // 钉住,允许 C 持有
defer pinner.Unpin()
C.store_ptr(unsafe.Pointer(&n))
fmt.Println("pinned Go pointer passed to C, n =", n)
}
实测输出:
$ GOTOOLCHAIN=go1.27.0 go run .
pinned Go pointer passed to C, n = 99
注意 R1 仍然生效:被钉住的指针如果指向含有 Go 指针的内存,cgocheck 依旧会 panic。Pinner 解决的是「对象被回收」的问题,不解决「GC 看不清引用」的问题。钉住的应当是不含 Go 指针的叶子内存。
7.2.6 C.CString / C.malloc / C.free 与 GC
从 C 侧申请的内存不受 Go GC 管理,必须手动释放。最常见的两种:
// 方式一:C.CString —— 在 C 堆上复制一份字符串
cs := C.CString("hello from C heap")
defer C.free(unsafe.Pointer(cs)) // 必须手动 free
// 方式二:C.malloc —— 在 C 堆上申请任意大小
p := C.malloc(1024)
defer C.free(p)
两条铁律:
C.CString的返回指针必须C.free,否则泄漏。C.CString复制的是内容,不是借用 Go 的字符串内存——所以它是安全的,但也是昂贵的(一次分配 + 一次拷贝)。C.free只能释放 C 侧申请的内存。用C.free去释放一个 Go 指针是未定义行为,反之亦然。
| 内存来源 | 谁分配 | 谁释放 | GC 管吗 |
|---|---|---|---|
Go 堆(new/make) | Go | Go GC | 是 |
C.CString | C | C.free | 否 |
C.malloc | C | C.free | 否 |
| 钉住的 Go 内存 | Go | GC(Unpin 后) | 是 |
7.2.7 常见崩溃模式速查
| 现象 | 根因 | 修法 |
|---|---|---|
Go pointer to unpinned Go pointer | 传了含 Go 指针的内存 | 只传叶子内存,或改用 C.malloc |
| GC 后随机段错误 | C 保存了 Go 指针(R2) | 用 Pinner 钉住,或复制到 C 堆 |
| 内存只涨不降 | C.CString/C.malloc 忘了 free | 成对写 defer C.free |
free(): invalid pointer | 用 C.free 释放了 Go 内存 | 只 free C 侧申请的内存 |
| 关掉 cgocheck 后「好了」 | 违例仍在,只是没人拦 | 别关,修根因 |
7.2.8 一张判断流程
把规则落成可执行的判断顺序:
- 要传给 C 的指针,指向的内存里有 Go 指针吗?有 → 不能直接传(R1)。
- C 会在这次调用返回后还持有它吗?会 → 必须
Pinner.Pin或复制到 C 堆(R2)。 - 这块内存是 C 申请的吗?是 → 由你负责
C.free(R4)。 - 有任何一个答案不确定 → 默认用
C.malloc复制一份,这是最安全也最容易理解的路径。
7.2.9 反向边界:C 回调 Go
前面讨论的都是「Go 调 C」。反过来的方向——C 回调 Go(//export 的函数)——指针规则同样成立,而且更隐蔽。
当 C 库需要一个回调时,你会在 Go 侧写:
//export goCallback
func goCallback(p unsafe.Pointer, n C.int) {
// p 是 C 传进来的指针,通常指向 C 内存,可以读
}
要记住两点:
- C 传给 Go 回调的指针,如果指向 C 内存,Go 可以读;但不能把它当作 Go 指针长期保存——它不在 Go 堆里,GC 不知道它。
- Go 回调不能把 Go 指针通过这个通道回传给 C,除非遵守 R1/R2。回调一旦返回,运行时对这次调用的簿记就结束了。
回调还有一个隐含成本:每次 C 调 Go 都要走一遍完整的边界切换,和 7.1 测的 24.5 ns 同一量级。高频回调(比如每处理一个字节就回调一次)几乎必然成为瓶颈——设计上应当让 C 侧「攒一批再回调一次」。
指针规则是 cgo 的底线。守住它,cgo 就是一条可靠的通道;守不住,它就是随机崩溃的温床。下一节我们跳出 cgo 本身,看看在纯 Go、cgo、汇编、WASM、FFI 之间到底该怎么选。
阅读导航:上一节:7.1 调用开销与栈切换实测 · 下一节:7.3 cgo 替代方案与性能权衡 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。