专栏首页01ZOOCGO 和 CGO 性能之谜
原创

CGO 和 CGO 性能之谜

cgo 的黑暗面

当我们最开始准备了解 go,并且认识到 golang 在一些场合不可避免的缺乏性能优势的时候(和 c/c++比较),很多人第一想法是:我为什么不从 go 语言中调用 c 呢,就像在 lua/python 里面做的那样。

go 语言提供了这样的工具,叫做 "cgo", 你也可以用 swig 之类的工具生成大量胶水代码,但是它的核心还是 cgo,但是很快你会发现,事情其实没那么简单 (不同于 lua 和 cpython 等使用 c 开发的解释语言)。最广泛流传的一篇警告来自 go 语言的作者之一 Dave Cheney, cgo is not Go, 这篇文章告诫我们,cgo 的缺点很多:

  1. 编译变慢,实际会使用 c 语言编译工具,还要处理 c 语言的跨平台问题
  2. 编译变得复杂
  3. 不支持交叉编译
  4. 其他很多 go 语言的工具不能使用
  5. C 与 Go 语言之间的的互相调用繁琐,是会有性能开销的
  6. C 语言是主导,这时候 go 变得不重要,其实和你用 python 调用 c 一样
  7. 部署复杂,不再只是一个简单的二进制

另几篇警告:一篇来自 GopherCon2016 的一篇演讲 From cgo back to Go,还有一篇来自 cockroachdb的作者 这两篇文章作者讲述了其在实际使用中遇到的其他困难(还有一些和上面的重复):

  1. 内存管理变得复杂,C 是没有垃圾收集的,而 go 有,两者的内存管理机制不同,可能会带来内存泄漏
  2. Cgoroutines != Goroutines,如果你使用 goroutine 调用 c 程序,会发现性能不会很高:Excessive cgo usage breaks Go’s promise of lightweight concurrency.

这些困难可以分为几种

  1. 编译问题:慢、不能交叉变异
  2. 配套工程问题:go 工具链不能完全使用 如 profile,doc 等等
  3. 性能问题:调用的性能开销
  4. 开发问题:需要细心管理 C 指针,否则很容易带来泄漏

其中 1,2 是我们可以容忍的,4 在 From cgo back to Go 作者给了一些提示,实际上我相信如果不是特别大量的交叉使用,也是可以避免的。那么最核心的就是 3了,性能到底开销有多大呢。毕竟 我们想使用 cgo 的出发点就是为了性能.

cgo 到底干了什么

想了解这一点除了官方文档,我们还需要知道一点汇编, 了解相关的技术1, 2

首先 Cgo isn't an FFI system 这点就和 python,lua 等调用 c 的方式很不同. Cgo在编译的时候会为代码生成大量的中间文件。 在一个Go源文件中,如果出现了import "C"指令则表示将调用cgo命令生成对应的中间文件。

比如一个 main.go 文件

package main

/*
package main

//int sum(int a, int b) { return a+b; }
import "C"

func main() {
	println(C.sum(1, 1))
}

执行 go tool cgo main.go (go 版本 1.14), 会生成

.
├── _obj
│   ├── _cgo_.o
│   ├── _cgo_export.c
│   ├── _cgo_export.h
│   ├── _cgo_flags
│   ├── _cgo_gotypes.go
│   ├── _cgo_main.c
│   ├── main.cgo1.go
│   └── main.cgo2.c
└── main.go

先看 main.cgo1.go, 这是展开虚拟C包相关函数和变量后的Go代码, 内部会实际调用 (_Cfunc_sum)(1, 1), 每一个C.xxx形式的函数都会被替换为_Cfunc_xxx格式的纯Go函数,其中前缀_Cfunc_表示这是一个C函数,对应一个私有的Go桥接函数。

//line main.go:1:1
package main

//int sum(int a, int b) { return a+b; }
import _ "unsafe"

func main() {
	println(( /*line :7:10*/_Cfunc_sum /*line :7:14*/)(1, 1))
}

桥接函数 _Cfunc_sum 定义为:

// _cgo_gotypes.go
func _Cfunc_sum(p0 _Ctype_int, p1 _Ctype_int) (r1 _Ctype_int) {
	_cgo_runtime_cgocall(_cgo_e119c51a7968_Cfunc_sum, uintptr(unsafe.Pointer(&p0)))
	if _Cgo_always_false {
		_Cgo_use(p0)
		_Cgo_use(p1)
	}
	return
}

其中_cgo_runtime_cgocall 对应runtime.cgocall函数,函数的声明如下, 其中调用的第一个参数为 cgo 函数,第二个参数未所有的参数:

func runtime.cgocall(fn, arg unsafe.Pointer) int32

被传入C语言函数_cgo_506f45f9fa85_Cfunc_sum也是cgo生成的中间函数。函数在main.cgo2.c定义, 可以看出所有参数都封装到一个结构里面了:

void
_cgo_e119c51a7968_Cfunc_sum(void *v)
{
	struct {
		int p0;
		int p1;
		int r;
		char __pad12[4];
	} __attribute__((__packed__)) *_cgo_a = v;
	char *_cgo_stktop = _cgo_topofstack();
	__typeof__(_cgo_a->r) _cgo_r;
	_cgo_tsan_acquire();
	_cgo_r = sum(_cgo_a->p0, _cgo_a->p1);
	_cgo_tsan_release();
	_cgo_a = (void*)((char*)_cgo_a + (_cgo_topofstack() - _cgo_stktop));
	_cgo_a->r = _cgo_r;
	_cgo_msan_write(&_cgo_a->r, sizeof(_cgo_a->r));
}

然后从参数指向的结构体获取调用参数后开始调用真实的C语言版sum函数,并且将返回值保持到结构体内返回值对应的成员。

因为Go语言和C语言有着不同的内存模型和函数调用规范。其中_cgo_topofstack函数相关的代码用于C函数调用后恢复调用栈。_cgo_tsan_acquire和_cgo_tsan_release则是用于扫描CGO相关的函数则是对CGO相关函数的指针做相关检查。

其中runtime.cgocall函数是实现Go语言到C语言函数跨界调用的关键。

runtime.cgocall 的定义在 runtime/cgocall.go:97 (go 1.14) 核心代码如下

//go:nosplit
func cgocall(fn, arg unsafe.Pointer) int32 {
	// ...省略部分代码
	
	mp := getg().m
	mp.ncgocall++
	mp.ncgo++

	// Reset traceback.
	mp.cgoCallers[0] = 0
    
    // 宣布正在进入系统调用,从而调度器会创建另一个 M 来运行 goroutine
	//
	// 对 asmcgocall 的调用保证了不会增加栈并且不分配内存,
	// 因此在 $GOMAXPROCS 计数之外的 "系统调用内" 的调用是安全的。
	//
	// fn 可能会回调 Go 代码,这种情况下我们将退出系统调用来运行 Go 代码
	//(可能增长栈),然后再重新进入系统调用来复用 entersyscall 保存的
	// PC 和 SP 寄存器
	entersyscall()

	// Tell asynchronous preemption that we're entering external
	// code. We do this after entersyscall because this may block
	// and cause an async preemption to fail, but at this point a
	// sync preemption will succeed (though this is not a matter
	// of correctness).
	osPreemptExtEnter(mp)

	mp.incgo = true
	// asmcgocall 是汇编实现, 它会切换到m的g0栈,然后调用_cgo_Cfunc_main函数
	errno := asmcgocall(fn, arg)

	// Update accounting before exitsyscall because exitsyscall may
	// reschedule us on to a different M.
	mp.incgo = false
	mp.ncgo--

	osPreemptExtExit(mp)
    
    // 宣告退出系统调用
	exitsyscall()

	// Note that raceacquire must be called only after exitsyscall has
	// wired this M to a P.
	if raceenabled {
		raceacquire(unsafe.Pointer(&racecgosync))
	}

	// 从垃圾回收器的角度来看,时间可以按照上面的顺序向后移动。
	// 如果对 Go 代码进行回调,GC 将在调用 asmcgocall 时能看到此函数。
	// 当 Go 调用稍后返回到 C 时,系统调用 PC/SP 将被回滚并且 GC 在调用
	// enteryscall 时看到此函数。通常情况下,fn 和 arg 将在 enteryscall 上运行
	// 并在 asmcgocall 处死亡,因此如果时间向后移动,GC 会将这些参数视为已死,
	// 然后生效。通过强制它们在这个时间中保持活跃来防止这些未死亡的参数崩溃
	KeepAlive(fn)
	KeepAlive(arg)
	KeepAlive(mp)

	return errno
}

asmcgocall 的定义(go1.14 amd64)在 runtime/asm_amd64.s:615

// 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

C.sum的整个调用流程图如下

Go --> runtime.cgocall --> runtime.entersyscall --> runtime.asmcgocall --> _cgo_Cfunc_f
                                                                                 |
                                                                                 |
Go <-- runtime.exitsyscall <-- runtime.cgocall <-- runtime.asmcgocall <----------+

cgo 的性能到底如何

这里有一个性能测试 不过时间比较久远了,内部显示 如果是单纯的 emtpy call,使用 cgo 耗时 55.9 ns/op, 纯 go 耗时 0.29 ns/op,相差了 192 倍。

而实际上我们在使用 cgo 的时候不太可能进行空调用,一般来说会把性能影响较大,计算耗时较长的计算放在 cgo 中,如果是这种情况,每次条用额外 55.9 ns 的额外耗时应该是可以接受的访问。

为了测试这种情况,这里设计了更全面的一种测试.

package main

import (
	"fmt"
	"time"
)

/*

void calSum(int c) {
	int sum = 0;
	for(int i=0; i<=c; i++ ){
        sum=sum+i;
    }
}

*/
// #cgo LDFLAGS: 
import "C"

func calSum(c int) {
	sum := 0
	for i := 0; i <= c; i++ {
		sum += i
	}
}

func main() {
	cycles := []int{500000, 1000000, 5000000, 10000000}
	counts := []int{10, 50, 100, 500, 1000, 5000, 10000}
	for _, count := range counts {
		for _, cycle := range cycles {
			startCgo := time.Now()
			for i := 0; i < cycle; i = i + 1 {
				C.calSum(C.int(count))
			}
			costCgo := time.Now().Sub(startCgo)

			startGo := time.Now()
			for i := 0; i < cycle; i = i + 1 {
				calSum(count)
			}
			costGo := time.Now().Sub(startGo)

			fmt.Printf("count: %d, cycle: %d, cgo: %s, go: %s, cgo/cycle: %s, go/cycle: %s cgo/go: %.4f \n",
				count, cycle, costCgo, costGo, costCgo/time.Duration(cycle), costGo/time.Duration(cycle), float64(costCgo)/float64(costGo))
		}
	}
}

在我的电脑

MacBook Pro (16-inch, 2019) 2.6 GHz 6 core Intel Core i7; 32 GB 2667 MHz DDR4 下的测试结果如下

count: 10, cycle: 500000, cgo: 34.420728ms, go: 2.8631ms, cgo/cycle: 68ns, go/cycle: 5ns cgo/go: 12.0222
count: 10, cycle: 1000000, cgo: 54.821951ms, go: 5.143633ms, cgo/cycle: 54ns, go/cycle: 5ns cgo/go: 10.6582
count: 10, cycle: 5000000, cgo: 276.215279ms, go: 23.72644ms, cgo/cycle: 55ns, go/cycle: 4ns cgo/go: 11.6417
count: 10, cycle: 10000000, cgo: 547.493103ms, go: 47.903742ms, cgo/cycle: 54ns, go/cycle: 4ns cgo/go: 11.4290
count: 50, cycle: 500000, cgo: 27.33682ms, go: 11.255556ms, cgo/cycle: 54ns, go/cycle: 22ns cgo/go: 2.4287
count: 50, cycle: 1000000, cgo: 55.332225ms, go: 22.576177ms, cgo/cycle: 55ns, go/cycle: 22ns cgo/go: 2.4509
count: 50, cycle: 5000000, cgo: 275.125825ms, go: 111.686179ms, cgo/cycle: 55ns, go/cycle: 22ns cgo/go: 2.4634
count: 50, cycle: 10000000, cgo: 548.110691ms, go: 221.686657ms, cgo/cycle: 54ns, go/cycle: 22ns cgo/go: 2.4725
count: 100, cycle: 500000, cgo: 26.850102ms, go: 17.105866ms, cgo/cycle: 53ns, go/cycle: 34ns cgo/go: 1.5696
count: 100, cycle: 1000000, cgo: 55.683324ms, go: 34.013477ms, cgo/cycle: 55ns, go/cycle: 34ns cgo/go: 1.6371
count: 100, cycle: 5000000, cgo: 274.983861ms, go: 175.353445ms, cgo/cycle: 54ns, go/cycle: 35ns cgo/go: 1.5682
count: 100, cycle: 10000000, cgo: 565.807779ms, go: 332.529274ms, cgo/cycle: 56ns, go/cycle: 33ns cgo/go: 1.7015
count: 500, cycle: 500000, cgo: 28.107866ms, go: 67.736173ms, cgo/cycle: 56ns, go/cycle: 135ns cgo/go: 0.4150
count: 500, cycle: 1000000, cgo: 55.675557ms, go: 132.092526ms, cgo/cycle: 55ns, go/cycle: 132ns cgo/go: 0.4215
count: 500, cycle: 5000000, cgo: 274.076029ms, go: 662.014685ms, cgo/cycle: 54ns, go/cycle: 132ns cgo/go: 0.4140
count: 500, cycle: 10000000, cgo: 549.303546ms, go: 1.339623927s, cgo/cycle: 54ns, go/cycle: 133ns cgo/go: 0.4100
count: 1000, cycle: 500000, cgo: 27.844244ms, go: 129.589541ms, cgo/cycle: 55ns, go/cycle: 259ns cgo/go: 0.2149
count: 1000, cycle: 1000000, cgo: 55.454138ms, go: 256.596273ms, cgo/cycle: 55ns, go/cycle: 256ns cgo/go: 0.2161
count: 1000, cycle: 5000000, cgo: 277.258613ms, go: 1.286156417s, cgo/cycle: 55ns, go/cycle: 257ns cgo/go: 0.2156
count: 1000, cycle: 10000000, cgo: 547.58263ms, go: 2.529370786s, cgo/cycle: 54ns, go/cycle: 252ns cgo/go: 0.2165
count: 5000, cycle: 500000, cgo: 27.813485ms, go: 623.126501ms, cgo/cycle: 55ns, go/cycle: 1.246µs cgo/go: 0.0446
count: 5000, cycle: 1000000, cgo: 54.529121ms, go: 1.232225252s, cgo/cycle: 54ns, go/cycle: 1.232µs cgo/go: 0.0443
count: 5000, cycle: 5000000, cgo: 276.45882ms, go: 6.182891022s, cgo/cycle: 55ns, go/cycle: 1.236µs cgo/go: 0.0447
count: 5000, cycle: 10000000, cgo: 610.406629ms, go: 12.620529682s, cgo/cycle: 61ns, go/cycle: 1.262µs cgo/go: 0.0484
count: 10000, cycle: 500000, cgo: 28.357581ms, go: 1.343704285s, cgo/cycle: 56ns, go/cycle: 2.687µs cgo/go: 0.0211
count: 10000, cycle: 1000000, cgo: 58.956701ms, go: 2.688045373s, cgo/cycle: 58ns, go/cycle: 2.688µs cgo/go: 0.0219
count: 10000, cycle: 5000000, cgo: 280.817687ms, go: 12.719011833s, cgo/cycle: 56ns, go/cycle: 2.543µs cgo/go: 0.0221
count: 10000, cycle: 10000000, cgo: 562.932582ms, go: 25.596832236s, cgo/cycle: 56ns, go/cycle: 2.559µs cgo/go: 0.0220

将 cgo 和 go 调用的耗时对比统计为图表:

image1.png
image2.png

可以看出 随着 count 数量增加,即实际计算量的增加,cgo 的性能优势逐渐体现,这时候 cgo 的性能 overhead 变得可以忽略不计了 (备注:这里 gcc 默认有编译优化,当关闭编译优化时,还是能看出显著 cgo/go 下降趋势的)。

相关的代码和数据在 https://github.com/u2takey/cgo-bench

cgo 相关的项目

使用了 cgo 的项目

项目

介绍

Gonum is a set of numeric libraries for the Go programming language

todo

todo

go binding 项目

项目

介绍

Qt binding for Go

Go binding for GTK

Git to Go; bindings for libgit2. Like McDonald's but tastier.

Golang bindings to the Qt cross-platform application framework.

Go bindings for Discord

Go bindings to systemd socket activation, journal, D-Bus, and unit files

Golang bindings for FFmpeg

Go binding to ImageMagick's MagickWand C API

Go bindings for OpenCV / 2.x API in gocv / 1.x API in opencv

参考

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • Golang Annotation 系统 - Gengo 实战

    代码生成的技术在各种语言中都很常用,尤其是静态语言,利用代码生成的技术可以实现一些大幅提高生产效率的工具。

    王磊-AI基础
  • 数据库压缩技术简介

    最近接触到一些海量数据存储的需求,为了解决这样的需求,一个想法是对数据进行一定程度的聚合。在应用层的聚合方式,这里不展开。但是让我联想到的是以前学习 prome...

    王磊-AI基础
  • 扩展 Kubernetes 之 Scheduler

    由于当前的主流扩展方式 Webhook(Scheduler Extender)方式有一些限制:

    王磊-AI基础
  • Java LinqCollection 仿Linq的list常用函数

    目前支持find,findAll,sort,select,remove等,java不支持lamda函数,因此用接口代替 public interface Fun...

    JadePeng
  • Leetcode 171 Excel Sheet Column Number

    Related to question Excel Sheet Column Title Given a column title as appear in...

    triplebee
  • 初学java之接口基础

    1 /* 2 长城牌电视机 3 联想奔月5008PC机 4 */ 5 6 7 package st; 8 //接口回调实例 9 in...

    Gxjun
  • 电磁兼容

    林清猫耳
  • Java知识点——第六周总结

    数据发送: 使用输出流发送数据给服务器 遵从Runnable接口 数据接收: 使用输入流从服务器端接收数据 遵从Runnable接口

    用户7073689
  • 图解Java设计模式之建造者模式

    1)需要建房子 :这一过程为打桩、砌墙、封顶 2)房子有各种各样的,比如普通房、高楼、别墅,各种房子的过程虽然一样,但是要求不要相同的。

    海仔
  • Enterprise Library Policy Injection Application Block 之四:如何控制CallHandler的执行顺序

    一、为什么CallHandler需要进行排序 PIAB为我们提供了一个很好地实现AOP的方式。AOP旨在实现Business Logic和Non-Busines...

    蒋金楠

扫码关注云+社区

领取腾讯云代金券