bigywater.tech
Go · 40

cgo 调用的开销:一次 C 调用比 Go 调用慢多少

cgo 让 Go 调用 C 库,但每次跨边界都有固定成本,还会改变调度与构建方式。这篇量出这个成本,看 cgo 工具生成了什么代码,验证阻塞在 C 里的 goroutine 为什么会占用线程,最后列出构建与指针规则上的限制。

21.22 ns
一次 cgo 调用的耗时,Go 函数调用是 1.97 ns
+98 个线程
100 个 goroutine 阻塞在 C 里时多创建的线程数(time.Sleep 只多 1 个)
72 ns vs 1266 ns
对 4096 字节求和:一次 C 调用与 Go 循环

一次调用的成本

实测 · 本机执行输出
  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 pointer

CGO_ENABLED=0 时含 import "C" 的包整个被排除;在 macOS 上交叉编译到 Linux 时开着 cgo 会失败,因为需要目标平台的 C 工具链(这里是 clang 不认识 Linux 的汇编)。传给 C 的 Go 内存如果里面还含 Go 指针,运行时检查会 panic——需要先用 runtime.Pinner 固定,或者改成不含 Go 指针的数据结构(本文没有试 Pinner)。

分步演示

cgo 调用的开销
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。系统 clang 21.0.0(Apple clang);cgo 用默认设置(CGO_ENABLED=1);基准测试每个变体 7 轮取中位数;线程数来自 runtime/pprof 的 threadcreate 计数(创建过的线程,不是当前线程);生成的文件来自 go tool cgo -objdir
    • 没有验证:runtime.cgocall 的内部实现、C 调用 Go 回调(//export)的成本、runtime.Pinner 的用法、Linux 上的数字与交叉工具链(zig cc 等)的做法、GODEBUG=cgocheck=0 的行为、cgo 对构建时间与二进制大小的影响
    京ICP备2026067502号-1京公网安备11010502063487号