[TOC]
「cgo 不是银弹」,cgo 是连接 Go 与 C (乃至其他任何语言)之间的桥梁。 cgo 性能远不及原生 Go 程序的性能,执行一个 cgo 调用的代价很大。 下图展示了 cgo, go, c 之间的性能差异(网络 I/O 场景):
图1: cgo/Go/C/net 包 在网络 I/O 场景下的性能对比,图取自 changkun/cgo-benchmarks
本文则具体研究 cgo 在运行时中的实现方式。
先来编写一个最简单的 cgo 程序:
package main
/*
#include "stdio.h"
void print() {
printf("hellow, cgo");
}
*/
import "C"
func main() {
C.print()
}
我们先观察一下汇编的结果:
TEXT main._Cfunc_print(SB) _cgo_gotypes.go
_cgo_gotypes.go:40 0x40503c0 65488b0c2530000000 MOVQ GS:0x30, CX
(...)
_cgo_gotypes.go:41 0x40503e7 488b053aca0600 MOVQ main._cgo_fd63072f180f_Cfunc_print(SB), AX
_cgo_gotypes.go:41 0x40503ee 48890424 MOVQ AX, 0(SP)
_cgo_gotypes.go:41 0x40503f2 488b442418 MOVQ 0x18(SP), AX
_cgo_gotypes.go:41 0x40503f7 4889442408 MOVQ AX, 0x8(SP)
_cgo_gotypes.go:41 0x40503fc e8ff3bfbff CALL runtime.cgocall(SB)
(...)
TEXT main.main(SB) /Users/changkun/dev/go-under-the-hood/demo/10-cgo/main.go
main.go:11 0x4050420 65488b0c2530000000 MOVQ GS:0x30, CX
(...)
main.go:12 0x405043b e880ffffff CALL main._Cfunc_print(SB)
(...)
说明 Go 代码在进入 C 代码前,最终以用编译器配合的形式,进入了运行时的 runtime.cgocall
。
再来看一下整个编译过程中的临时文件,临时文件中的入口文件为 main.cgo1.go
:
// Code generated by cmd/cgo; DO NOT EDIT.
//line main.go:1:1
package main
/*
#include "stdio.h"
void print() {
printf("hellow, cgo");
}
*/
import _ "unsafe"
func main() {
(_Cfunc_print)()
}
可以看到 Go 编译器会将我们原有的 cgo 调用替换为:_Cfunc_print
。
我们可以在 _cgo_gotypes.go
中看到这个函数的定义:
//go:cgo_unsafe_args
func _Cfunc_print() (r1 _Ctype_void) {
// 调用 _cgo_runtime_cgocall 传递 C 函数的入口地址以及相关参数
_cgo_runtime_cgocall(_cgo_222b4724d882_Cfunc_print, uintptr(unsafe.Pointer(&r1)))
if _Cgo_always_false {
}
return
}
而 _cgo_runtime_cgocall
的定义:
//go:linkname _cgo_runtime_cgocall runtime.cgocall
func _cgo_runtime_cgocall(unsafe.Pointer, uintptr) int32
可以看到编译器通过编译标志 go:linkname
将这个调用链接为了 runtime.cgocall
。
因此,从 Go 进入 C 空间的 cgo 调用,以 Go 程序为主体(运行时依然存在),通过编译器的配合,
当需要调用 C 代码时,会向运行时传递 C 函数的入口地址及所需传递的参数。
那么剩下的工作就是去分析 runtime.cgocall
这个调用如何与 Go 运行时进行交互了。
从 Go 调用 C 函数 f,cgo 生成的代码会调用 runtime.cgocall(_cgo_Cfunc_f, frame)
,
其中 _cgo_Cfunc_f
为由 cgo 编写的并由 gcc 编译的函数。
runtime.cgocall
会调用 entersyscall
,从而不会阻塞其他 goroutine 或垃圾回收器
而后调用 runtime.asmcgocall(_cgo_Cfunc_f, frame)
。
runtime.asmcgocall
会切换到 m->g0 栈(操作系统分配的栈,因此能安全的在运行 gcc 编译的代码)
并调用 _cgo_Cfunc_f(frame)
。
_cgo_Cfunc_f
获取了帧结构中的参数,调用了实际的 C 函数 f,在帧中记录其结果,
并返回到 runtime.asmcgocall
。
在重新获得控制权后,runtime.asmcgocall
会切换回原来的 g (m->curg
) 的执行栈
并返回 runtime.cgocall
。
在重新获得控制权后,runtime.cgocall
会调用 exitsyscall
,并阻塞,直到该 m 运行能够在不与
$GOMAXPROCS
限制冲突的情况下运行 Go 代码。
Go --> runtime.cgocall --> runtime.entersyscall --> runtime.asmcgocall --> _cgo_Cfunc_f
|
|
Go <-- runtime.exitsyscall <-- runtime.cgocall <-- runtime.asmcgocall <----------+
// 从 Go 调用 C
//go:nosplit
func cgocall(fn, arg unsafe.Pointer) int32 {
if !iscgo && GOOS != "solaris" && GOOS != "windows" {
throw("cgocall unavailable")
}
// cgo 调用不允许为空
if fn == nil {
throw("cgocall nil")
}
if raceenabled {
racereleasemerge(unsafe.Pointer(&racecgosync))
}
// 运行时会记录 cgo 调用的次数
mp := getg().m
mp.ncgocall++
mp.ncgo++
// 重置回溯信息
mp.cgoCallers[0] = 0
// 宣布正在进入系统调用,从而调度器会创建另一个 M 来运行 goroutine
//
// 对 asmcgocall 的调用保证了不会增加栈并且不分配内存,
// 因此在 $GOMAXPROCS 计数之外的 "系统调用内" 的调用是安全的。
//
// fn 可能会回调 Go 代码,这种情况下我们将退出系统调用来运行 Go 代码
//(可能增长栈),然后再重新进入系统调用来复用 entersyscall 保存的
// PC 和 SP 寄存器
entersyscall()
// 将 m 标记为正在 cgo
mp.incgo = true
// 进入调用
errno := asmcgocall(fn, arg)
// 在 exitsyscall 之前进行计数,因为 exitsyscall 可能会把
// 我们重新调度到不同的 M 中
//
// 取消运行 cgo 的标记
mp.incgo = false
// 正在进行的 cgo 数量减少
mp.ncgo--
// 宣告退出系统调用
exitsyscall()
// race 相关,raceacquire 必须在 exitsyscall 连接了 M 和 P 之后调用
if raceenabled {
raceacquire(unsafe.Pointer(&racecgosync))
}
// 宣告退出系统调用
exitsyscall()
// 从垃圾收集器的角度来看,时间可以按照上面的顺序向后移动。
// 如果对 Go 代码进行回调,GC 将在调用 asmcgocall 时能看到此函数。
// 当 Go 调用稍后返回到 C 时,系统调用 PC/SP 将被回滚并且 GC 在调用
// enteryscall 时看到此函数。通常情况下,fn 和 arg 将在 enteryscall 上运行
// 并在 asmcgocall 处死亡,因此如果时间向后移动,GC 会将这些参数视为已死,
// 然后生效。通过强制它们在这个时间中保持活跃来防止这些未死亡的参数崩溃。
KeepAlive(fn)
KeepAlive(arg)
KeepAlive(mp)
return errno
}
//go:noescape
func asmcgocall(fn, arg unsafe.Pointer) int32
从名字上我们可以看出 asmcgocall
是由汇编写成的:
// func asmcgocall(fn, arg unsafe.Pointer) int32
// 在调度器栈上调用 fn(arg), 已为 gcc ABI 对齐,见 cgocall.go
TEXT ·asmcgocall(SB),NOSPLIT,$0-20
MOVQ fn+0(FP), AX
MOVQ arg+8(FP), BX
MOVQ SP, DX
// 考虑是否需要切换到 m->g0 栈
// 也用来调用创建新的 OS 线程,这些线程已经在 m->g0 栈中了
get_tls(CX)
MOVQ g(CX), R8
CMPQ R8, $0
JEQ nosave
MOVQ g_m(R8), R8
MOVQ m_g0(R8), SI
MOVQ g(CX), DI
CMPQ SI, DI
JEQ nosave
MOVQ m_gsignal(R8), SI
CMPQ SI, DI
JEQ nosave
// 切换到系统栈
MOVQ m_g0(R8), SI
CALL gosave<>(SB)
MOVQ SI, g(CX)
MOVQ (g_sched+gobuf_sp)(SI), SP
// 于调度栈中(pthread 新创建的栈)
// 确保有足够的空间给四个 stack-based fast-call 寄存器
// 为使得 windows amd64 调用服务
SUBQ $64, SP
ANDQ $~15, SP // 为 gcc ABI 对齐
MOVQ DI, 48(SP) // 保存 g
MOVQ (g_stack+stack_hi)(DI), DI
SUBQ DX, DI
MOVQ DI, 40(SP) // 保存栈深 (不能仅保存 SP, 因为栈可能在回调时被复制)
MOVQ BX, DI // DI = AMD64 ABI 第一个参数
MOVQ BX, CX // CX = Win64 第一个参数
CALL AX // 调用 fn
// 恢复寄存器、 g、栈指针
get_tls(CX)
MOVQ 48(SP), DI
MOVQ (g_stack+stack_hi)(DI), SI
SUBQ 40(SP), SI
MOVQ DI, g(CX)
MOVQ SI, SP
MOVL AX, ret+16(FP)
RET
nosave:
// 在系统栈上运行,可能没有 g
// 没有 g 的情况发生在线程创建中或线程结束中(比如 Solaris 平台上的 needm/dropm)
// 这段代码和上面类似,但没有保存和恢复 g,且没有考虑栈的移动问题(因为我们在系统栈上,而非 goroutine 栈)
// 如果已经在系统栈上,则上面的代码可被直接使用,但而后进入这段代码的情况非常少见的 Solaris 上。
// 使用这段代码来为所有 "已经在系统栈" 的调用进行服务,从而保持正确性。
SUBQ $64, SP
ANDQ $~15, SP // ABI 对齐
MOVQ $0, 48(SP) // 上面的代码保存了 g, 确保 debug 时可用
MOVQ DX, 40(SP) // 保存原始的栈指针
MOVQ BX, DI // DI = AMD64 ABI 第一个参数
MOVQ BX, CX // CX = Win64 第一个参数
CALL AX
MOVQ 40(SP), SI // 恢复原来的栈指针
MOVQ SI, SP
MOVL AX, ret+16(FP)
RET
在这段调用中本质上有两种情况:
- 不在系统栈上:这种情况下,由于 goroutine 栈的移动,判断当前是否在系统栈上,如果不在,则切换到系统栈上调用 C。
- 在系统栈上:无论是否有 g ,如果已经在系统栈上,所以保存现场不需要自行处理,因此进入 nosave 直接调用 C,当返回时会自行恢复现场。
本质上我们忽略了一个过程,如果一个调用是从 C 进入 Go 那么情况完全不一样。
上面的描述跳过了当 gcc 编译的函数 f 调用回 Go 的情况。如果此类情况发生,则下面描述了 f 执行期间的调用过程。
为了 gcc 编译的 C 代码调用 Go 函数 p.GoF
成为可能,cgo 编写了以 GoF
命名的 gcc 编译的函数
(不是 p.GoF
,因为 gcc 没有包的概念)。然后 gcc 编译的 C 函数 f 调用 GoF
。
GoF 调用了 crosscall2(_cgoexp_GoF, frame, framesize)
,而
Crosscall2(gcc 编译的汇编文件)为一个具有两个参数的从 gcc 函数调用 ABI 到 6c 函数调用 API 的适配器。
gcc 通过调用它来调用 6c 函数。这种情况下,它会调用 _cgoexp_GoF(frame, framesize)
,
仍然会在 m->g0 栈上运行,且不受 $GOMAXPROCS
的限制。因此该代码不能直接调用任意的 Go 代码,
并且必须非常小心的分配内存以及小心的使用 m->g0 栈。
_cgoexp_GoF
调用了 runtime.cgocallback(p.GoF, frame, framesize, ctxt)
。
(使用 _cgoexp_GoF
而不是编写 crosscall3
直接进行此调用的原因是 _cgoexp_GoF
是用 6c 而不是 gcc 编译的,可以引用像 runs.cgocallback 和 p.GoF 这样的带点的名称。)
runtime.cgocallback
从 m->g0
的堆切换到原始 g(m->curg
)的栈,
并在在栈上调用 runtime.cgocallbackg(p.GoF,frame,framesize)
。
作为栈切换的一部分,runtime.cgocallback
将当前 SP 保存为 m->g0->sched.sp
,
因此在执行回调期间任何使用 m->g0
的栈都将在现有栈帧之下完成。
在覆盖 m->g0->sched.sp
之前,它会在 m->g0
栈上将旧值压栈,以便以后可以恢复。
runtime.cgocallbackg
现在在一个真正的 goroutine 栈上运行(不是 m->g0
栈)。
首先它调用 runtime.exitsyscall
,它将阻塞到不与 $GOMAXPROCS
限制冲突的情况下运行此 goroutine。
一旦 exitsyscall
返回,就可以安全地执行调用内存分配器或调用 Go 的 p.GoF
回调函数等操作。
runtime.cgocallbackg
首先推迟一个函数来 unwind m->g0.sched.sp
,这样如果 p.GoF
发生 panic
m->g0.sched.sp
将恢复到其旧值:m->g0
栈和 m->curg
栈将在 unwind 步骤中展开。
接下来它调用 p.GoF
。最后它弹出但不执行 defer 函数,而是调用 runtime.entersyscall
,
并返回到 runtime.cgocallback
。
在重新获得控制权后,runtime.cgocallback
切换回 m->g0
栈(指针仍然为 m->g0.sched.sp
),
从栈中恢复原来的 m->g0.sched.sp
的值,并返回到 _cgoexp_GoF
。
_cgoexp_GoF
直接返回 crosscall2
,从而为 gcc 恢复调用方寄存器,并返回到 GoF
,从而返回到 f
中。
f --> GoF --> crosscall2 --> _cgoexp_GoF --> runtime.cgocallbackg --> runtime.cgocallback --> runtime.exitsyscall --> p.GoF
|
|
f <-- GoF <-- crosscall2 <-- _cgoexp_GoF <-- runtime.cgocallback <-- runtime.entersyscall <-----------------------------+
TODO:
Go under the hood | CC-BY-NC-ND 4.0 & MIT © changkun