cgo 让 Go 调用 C 库,但每次跨边界都有固定成本,还会改变调度与构建方式。这篇量出这个成本,看 cgo 工具生成了什么代码,验证阻塞在 C 里的 goroutine 为什么会占用线程,最后列出构建与指针规则上的限制。
Go 函数 goAdd(1, 2)(//go:noinline) 中位 1.97 ns/op(最小 1.95 / 最大 2.07) C.c_add(1, 2)(cgo 调用) 中位 21.22 ns/op(最小 20.75 / 最大 21.54) C.CString + C.c_strlen + C.free 中位 74.73 ns/op(最小 74.54 / 最大 74.94) C.c_sum(&buf[0], 4096)(不拷贝,传 Go 内存的指针) 中位 72.03 ns/op(最小 71.10 / 最大 75.21) Go 循环对 4096 字节求和 中位 1266.25 ns/op(最小 1260.87 / 最大 1268.27) AllocsPerRun:C.c_add 0 次;C.CString + c_strlen + free 0 次
cgo 调用要从 Go 的栈切到 C 能用的栈、通知调度器这个 goroutine 进入了 C、调用结束再切回来,所以一个只做加法的 C 函数也要约 21 ns。这是固定成本,和 C 函数本身干多少活无关:所以把大块数据一次交给 C(传 Go 切片的指针,不拷贝)才划算,逐个元素调用 C 反而更慢。这里的 C 求和比 Go 循环快得多,我猜有一部分是 clang 的优化(比如向量化),没有去看汇编确认。
go tool cgo -objdir gen main.go:rc=0
生成的文件:_cgo_2.o _cgo_export.c _cgo_export.h _cgo_gotypes.go _cgo_main.c main.cgo1.go main.cgo2.c
_cgo_gotypes.go 里 C.c_add 的 Go 包装:
d byte
var _cgo_791a69f5e881_Cfunc_c_add = unsafe.Pointer(&__cgofn__cgo_791a69f5e881_Cfunc_c_add)
//go:cgo_unsafe_args
func _Cfunc_c_add(p0 _Ctype_int, p1 _Ctype_int) (r1 _Ctype_int) {
_cgo_runtime_cgocall(_cgo_791a69f5e881_Cfunc_c_add, uintptr(unsafe.Pointer(&p0)))
if _Cgo_always_false {
_Cgo_use(p0)
_Cgo_use(p1)
}
return
}
go tool cgo 把 import "C" 的文件拆成 Go 与 C 两部分,生成的 _Cfunc_c_add 很薄:调用 runtime.cgocall,参数打包成一个结构。_Cgo_use 防止参数在调用之前被回收。我没有读 runtime.cgocall 的实现,上面“切栈、通知调度器”是对它的名字与行为的概括,不是验证过的细节。
100 个 goroutine 各睡 300 ms(time.Sleep):线程创建数 7 → 8(增加 1),总耗时 301 ms 100 个 goroutine 各睡 300 ms(C.usleep):线程创建数 7 → 105(增加 98),总耗时 307 ms
实验的结果很直接:100 个 goroutine 阻塞在 usleep 里,线程数从 7 涨到 105;同样的 100 个 time.Sleep 只多一个线程。阻塞在 C 里的 goroutine 无法被调度器挂起复用线程,所以并发的阻塞 C 调用有多少就占多少线程。线程太多时要用信号量限制并发。
CGO_ENABLED=0 构建含 import "C" 的包:rc=1
package lab: build constraints exclude all Go files in <gtmp>/cg2
从 macOS 用 CGO_ENABLED=1 交叉编译到 linux/amd64:rc=1
# runtime/cgo
gcc_amd64.S:27:8: error: unknown token in expression
pushq %rbx
^
把含 Go 指针的结构体指针传给 C(默认 cgocheck=1):退出码 0;recover: runtime error: argument of cgo function has Go pointer to unpinned Go pointerCGO_ENABLED=0 时含 import "C" 的包整个被排除;在 macOS 上交叉编译到 Linux 时开着 cgo 会失败,因为需要目标平台的 C 工具链(这里是 clang 不认识 Linux 的汇编)。传给 C 的 Go 内存如果里面还含 Go 指针,运行时检查会 panic——需要先用 runtime.Pinner 固定,或者改成不含 Go 指针的数据结构(本文没有试 Pinner)。