《Go 语言高级编程》7.2 内存与指针规则

cgo 最危险的从来不是性能,而是指针:一个不合规的指针传递就能让程序在几分钟后随机崩溃。本节实测 cgocheck 的拦截行为、Go 1.21 引入的 runtime.Pinner、C.CString/C.free 与 GC 的边界,并给出一张「什么能传、什么不能传」的规则表。

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 的指针规则可以压缩成四条:

规则内容后果
R1Go 指针可传给 C,但它指向的内存不得含 Go 指针违例直接 panic
R2C 不得在调用返回后保存 Go 指针GC 后悬垂,随机崩溃
R3Go 指针不得指向 C 内存里的 Go 指针编译期/运行期报错
R4C 指针(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)

两条铁律:

  1. C.CString 的返回指针必须 C.free,否则泄漏。C.CString 复制的是内容,不是借用 Go 的字符串内存——所以它是安全的,但也是昂贵的(一次分配 + 一次拷贝)。
  2. C.free 只能释放 C 侧申请的内存。用 C.free 去释放一个 Go 指针是未定义行为,反之亦然。
内存来源谁分配谁释放GC 管吗
Go 堆(new/make)GoGo GC是
C.CStringCC.free否
C.mallocCC.free否
钉住的 Go 内存GoGC(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 一张判断流程

把规则落成可执行的判断顺序:

  1. 要传给 C 的指针,指向的内存里有 Go 指针吗?有 → 不能直接传(R1)。
  2. C 会在这次调用返回后还持有它吗?会 → 必须 Pinner.Pin 或复制到 C 堆(R2)。
  3. 这块内存是 C 申请的吗?是 → 由你负责 C.free(R4)。
  4. 有任何一个答案不确定 → 默认用 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 替代方案与性能权衡 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练