天天看點

CGO 和 CGO 性能之謎cgo 的黑暗面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 調用的耗時對比統計為圖表:

CGO 和 CGO 性能之謎cgo 的黑暗面cgo 到底幹了什麼cgo 的性能到底如何cgo 相關的項目參考

image1.png

CGO 和 CGO 性能之謎cgo 的黑暗面cgo 到底幹了什麼cgo 的性能到底如何cgo 相關的項目參考

image2.png

可以看出 随着 count 數量增加,即實際計算量的增加,cgo 的性能優勢逐漸展現,這時候 cgo 的性能 overhead 變得可以忽略不計了 (備注:這裡 gcc 預設有編譯優化,當關閉編譯優化時,還是能看出顯著 cgo/go 下降趨勢的)。

相關的代碼和資料在 https://github.com/u2takey/cgo-bench

cgo 相關的項目

使用了 cgo 的項目

項目 介紹
gonum/gonum Gonum is a set of numeric libraries for the Go programming language
todo todo

go binding 項目

項目 介紹
therecipe/qt Qt binding for Go
mattn/go-gtk Go binding for GTK
libgit2/git2go Git to Go; bindings for libgit2. Like McDonald's but tastier.
visualfc/goqt Golang bindings to the Qt cross-platform application framework.
bwmarrin/discordgo Go bindings for Discord
coreos/go-systemd Go bindings to systemd socket activation, journal, D-Bus, and unit files
giorgisio/goav Golang bindings for FFmpeg
gographics/imagick Go binding to ImageMagick's MagickWand C API
go-opencv/go-opencv Go bindings for OpenCV / 2.x API in gocv / 1.x API in opencv

參考

  • Adventures With cgo
  • The Cost and Complexity of Cgo
  • cgo程式設計
  • cgo
  • Go 語言原本