10.4 cgo

10.4 cgo

「cgo 不是银弹」,cgo 是连接 Go 与 C (乃至其他任何语言)之间的桥梁。 cgo 性能远不及原生 Go 程序的性能,执行一个 cgo 调用的代价很大。 下图展示了 cgo, go, c 之间的性能差异(网络 I/O 场景):

图1: cgo/Go/C/net 包 在网络 I/O 场景下的性能对比,图取自 changkun/cgo-benchmarks

本文则具体研究 cgo 在运行时中的实现方式。

入口

先来编写一个最简单的 cgo 程序:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package main

/*
#include "stdio.h"
void print() {
	printf("hellow, cgo");
}
*/
import "C"

func main() {
	C.print()
}

我们先观察一下汇编的结果:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// 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 中看到这个函数的定义:

1
2
3
4
5
6
7
8
//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 的定义:

1
2
//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 运行时进行交互了。

cgocall

原理概述: Go 调用 C

从 Go 调用 C 函数 f,cgo 生成的代码会调用 runtime.cgocall(_cgo_Cfunc_f, frame), 其中 _cgo_Cfunc_f 为由 cgo 编写的并由 gcc 编译的函数。

runtime.cgocall 会调用 entersyscall(参见 syscall.*), 从而不会阻塞其他 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 <----------+

实际代码

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// 从 Go 调用 C
//go:nosplit
func cgocall(fn, arg unsafe.Pointer) int32 {
	(...)

	// 运行时会记录 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()

	(...)

	// 宣告退出系统调用
	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 是由汇编写成的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
// 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

在这段调用中本质上有两种情况:

  1. 不在系统栈上:这种情况下,由于 goroutine 栈的移动,判断当前是否在系统栈上,如果不在,则切换到系统栈上调用 C。
  2. 在系统栈上:无论是否有 g ,如果已经在系统栈上,所以保存现场不需要自行处理,因此进入 nosave 直接调用 C,当返回时会自行恢复现场。

cgocallbackg

本质上我们忽略了一个过程,如果一个调用是从 C 进入 Go 那么情况完全不一样。

原理概述: 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.cgocallbackm.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 <-----------------------------+

实际代码

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
// 从 C 调用 Go
//go:nosplit
func cgocallbackg(ctxt uintptr) {
	gp := getg()
	(...)

	// 从 C 发起的调用运行在 gp.m 的 g0 栈上,因此我们必须确认我们停留在这个 M 上
	// 在调用 exitsyscall 之前,必须调用这个操作,否则它会被释放且将我们移动到其他的 M 上
	// 当前调用结束前,会调用 unlockOSThread
	lockOSThread()

	// 保存当前系统调用的参数,因此 m.syscall 可以在进行系统调用时候进而回调使用
	syscall := gp.m.syscall

	// entersyscall 保存了调用方 SP 并允许 GC 能够跟踪 Go 栈。然而因为我们会返回到
	// 前一个栈帧并需要与 cgocall 的 entersyscall() 匹配,我们必须保存 syscall*
	// 并让 reentersyscall 恢复它们
	savedsp := unsafe.Pointer(gp.syscallsp)
	savedpc := gp.syscallpc
	exitsyscall() // 离开 cgo 调用
	gp.m.incgo = false

	cgocallbackg1(ctxt)

	// 这时,unlockOSThread 已经被调用,下面的代码不能修改到其他的 m 上。
	// incgo 检查位于 schedule 函数中,这是强制的

	gp.m.incgo = true
	// 返回到 cgo 调用going back to cgo call
	reentersyscall(savedpc, uintptr(savedsp))

	gp.m.syscall = syscall
}

func cgocallbackg1(ctxt uintptr) {
	gp := getg()
	if gp.m.needextram || atomic.Load(&extraMWaiters) > 0 {
		gp.m.needextram = false
		systemstack(newextram)
	}

	if ctxt != 0 {
		s := append(gp.cgoCtxt, ctxt)

		// 现在我们需要设置 gp.cgoCtxt = s,但我们可以得到操纵切片时的 SIGPROF 信号,和
		// SIGPROF 处理程序可以获取 gp.cgoCtxt
		// 追踪堆栈。我们需要确保 handler 总是看到一个有效的切片,所以设置
		// 按顺序排列的值,以便始终如此。
		p := (*slice)(unsafe.Pointer(&gp.cgoCtxt))
		atomicstorep(unsafe.Pointer(&p.array), unsafe.Pointer(&s[0]))
		p.cap = cap(s)
		p.len = len(s)

		defer func(gp *g) {
			// 安全地减少切片的长度。
			p := (*slice)(unsafe.Pointer(&gp.cgoCtxt))
			p.len--
		}(gp)
	}

	if gp.m.ncgo == 0 {
		// 对 Go 的 C 调用来自一个当前没有运行任何 Go 的线程。
		// 在 -buildmode=c-archive 或 c-shared 的情况下,
		// 此调用可能在包初始化完成之前进入,等待完成。
		<-main_init_done
	}

	// 增加 defer 栈的入口来防止 panic
	restore := true
	defer unwindm(&restore)

	(...)

	type args struct {
		fn      *funcval
		arg     unsafe.Pointer
		argsize uintptr
	}
	var cb *args

	// Location of callback arguments depends on stack frame layout
	// and size of stack frame of cgocallback_gofunc.
	sp := gp.m.g0.sched.sp
	switch GOARCH {
	(...)
	case "amd64":
		// On amd64, stack frame is two words, plus caller PC.
		if framepointer_enabled {
			// In this case, there's also saved BP.
			cb = (*args)(unsafe.Pointer(sp + 4*sys.PtrSize))
			break
		}
		cb = (*args)(unsafe.Pointer(sp + 3*sys.PtrSize))
	(...)
	}

	// 调用的回调函数
	// 注意(rsc): 传递 nil 为 argtype 意味着复制
	// 结果回到 cb.arg 没有任何相应的写入障碍。
	// 对于 cgo,cb.arg 指向 C 堆栈帧,因此不会
	// 保留 GC 可以找到的任何指针 - 写屏障将是一个空操作。
	reflectcall(nil, unsafe.Pointer(cb.fn), cb.arg, uint32(cb.argsize), 0)

	(....)

	// 不对 m->g0->sched.sp 进行 unwind
	// cgocallback 会进行处理
	restore = false
}

小结

TODO:

进一步阅读的参考文献

  1. Command cgo
  2. LINUX SYSTEM CALL TABLE FOR X86 64