0%

Cgo实践

什么是cgo

一句话概括,cgo是golang提供的一种能力。借用这种能力,能够使得开发者在golang代码中调用c++/c语言的库(代码逻辑),甚至是在c++/c语言中调用golang的能力。

如何调用

golang调用c++/c语言的方式可以粗略分为三种:1)代码调用,2)动态编译(通过运行时动态链接)3)静态编译。下面介绍一些实战例子。

代码链接

代码调用

直接调用

通过代码调用,是最简洁明了的一种方式,可以看下面的例子

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
// code_c.go
package main



/*

#include <stdio.h>



int Call(){

printf("hello cgo\n");

return 0;

}

*/

import "C"



func main() {

C.Call()

}

我们使用import "C"表明引入了C虚拟包,然后紧贴上面若干行,可以用注释的方式,直接引入c语言代码。执行上面的代码,可以在控制台看到正常输出。

那么能否直接调用c++的代码呢,比方说使用STL之类的能力。答案是不行的,有兴趣的可能看下code_cpp文件夹下的代码,尝试编译运行一下,看下报错信息如何。

通过文件调用

在介绍完直接通过代码方式调用后,很自然的就会想知道:如何代码逻辑复杂了,能否通过文件的方式调用呢?通过引入头文件的方式调用c/c++的函数。cgo也提供了这种能力,可以移步file_c目录下查看具体的调用方法。

1
2
3
4
├── file_c.c
├── file_c.go
├── file_c.h
└── Makefile

可以看到我们有一组头文件及其函数实现和golang的调用逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// file_c.go
package main



/*

#include "file_c.h"

*/

import "C"



func main() {

C.Call()

}

可以看到,在go文件里,通过include的方式引入头文件,就可以调用这个头文件里所定义的函数了。特别地,当头文件位于其他文件目录下时,例如:extlib,也可以通过CFLAGSCXXFLAGSCPPFLAGS来定义该头文件的位置。具体调用方式可以参考static里的代码。

这里要特别注意一个bad case

1
2
3
4
5
.
├── file_c.cpp
├── file_c.go
├── file_c.h
└── Makefile

我只将file_c.c改为file_c.cpp,其内容不做任何改变,就会出现如下报错:

报错信息的意思基本是指:在链接阶段,没有找到_cgo_xxxxx这个函数。这是因为使用cgo编译c/c++文件时,golang会根据文件名后缀选择不同的编译器,而gcc编译器和g++编译器在编译阶段生成的目标文件符号名称是不同的。所以导致的链接失败。因此,我们要用extern "C"关键字来修饰头文件,以此来满足两者符号名称一致。

为什么仅支持c语言的编译规范呢,我在网上找到了如下的解释:

CGO 是 C 语言和 Go 语言之间的桥梁,原则上无法直接支持 C++ 的类。CGO 不支持 C++ 语法的根本原因是 C++ 至今为止还没有一个二进制接口规范 (ABI)。一个 C++ 类的构造函数在编译为目标文件时如何生成链接符号名称、方法在不同平台甚至是 C++ 的不同版本之间都是不一样的。

如果需要调用c++的某些特性或者依赖库,必须要使用c语言封装一层胶水层。具体实现可以参考file_cpp_good目录下的代码。

引入函数库

通过代码调用的方式是最基础的一种方法来使用cgo,但是实际情况下,我们需要更加复杂的c/c++逻辑,这时候通过代码的方式就不是那么的合适。同时,绝大多数情况下,我们使用的SDK只提供了库文件和头文件。所以在这种场景下,我们就需要使用引入函数库的方式来使用cgo。

1
2
#cgo LDFLAGS: -L${SRCDIR}/../extlib/ -lcomm
#cgo CFLAGS: -I/../extlib/

通过定义库文件和头文件的引入路径来引入。

动态编译

动态编译故名思意就是在运行时动态链接进进程的内存当中。其好处就是可以避免引入多个库时的符号冲突问题。但是其弊端也是十分明显的,可移植性有些差。

静态编译

与动态编译相反,静态编译就是在链接阶段,直接讲静态库链接到二进制中。这样做的好吃时移植性好,如果不涉及跨平台的话,只需要一个二进制文件可以直接拉起服务。坏处是产出物占用磁盘空间大。

在编写代码上,两者的方法基本上通用的。差异在执行逻辑上:采用动态编译时,需要提前将所依赖的动态库拷贝进系统路径里或者在运行时指明动态库的具体路径,否则会因找不到动态库文件报错。

性能比较

我手搓了一下在纯计算逻辑上,使用c、cgo和golang三者在性能比较。结果如下截图所示:

具体计算逻辑是计算菲波那切数。可以明显看出来,使用cgo和c名没有明显的差异。在计算第50位数字时,只是略微慢了300ms左右。而使用纯golang时,耗时陡增至76s,慢了足足有43s。

跑一下benchmark验证一下:

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
goos: linux
goarch: amd64
pkg: performance/cgo
cpu: Intel(R) Xeon(R) Platinum 8255C CPU @ 2.50GHz
BenchmarkCgoFibonacci
BenchmarkCgoFibonacci-2 544 2236085 ns/op 0 B/op 0 allocs/op
BenchmarkCgoFibonacci-2 531 2217419 ns/op 0 B/op 0 allocs/op
BenchmarkCgoFibonacci-2 548 2187116 ns/op 0 B/op 0 allocs/op
BenchmarkCgoFibonacci-2 520 2178817 ns/op 0 B/op 0 allocs/op
BenchmarkCgoFibonacci-2 550 2428741 ns/op 0 B/op 0 allocs/op
PASS
ok performance/cgo 7.196s
goos: linux
goarch: amd64
pkg: performance/golang
cpu: Intel(R) Xeon(R) Platinum 8255C CPU @ 2.50GHz
BenchmarkFibonacci
BenchmarkFibonacci-2 225 5121532 ns/op 0 B/op 0 allocs/op
BenchmarkFibonacci-2 235 5008128 ns/op 0 B/op 0 allocs/op
BenchmarkFibonacci-2 236 4970322 ns/op 0 B/op 0 allocs/op
BenchmarkFibonacci-2 241 4969721 ns/op 0 B/op 0 allocs/op
BenchmarkFibonacci-2 232 4992128 ns/op 0 B/op 0 allocs/op
PASS
ok performance/golang 8.463s
make: Leaving directory '/home/ubuntu/cgo_playload/performance'

可以看到,纯计算场景下,使用cgo确实要比使用golang要快很多。那么涉及系统调用呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
goos: linux
goarch: amd64
pkg: performance/cgo
cpu: Intel(R) Xeon(R) Platinum 8255C CPU @ 2.50GHz
BenchmarkReadWriteCgoCalls
BenchmarkReadWriteCgoCalls-2 412370 2858 ns/op 64 B/op 3 allocs/op
BenchmarkReadWriteCgoCalls-2 421786 2841 ns/op 64 B/op 3 allocs/op
BenchmarkReadWriteCgoCalls-2 425576 2789 ns/op 64 B/op 3 allocs/op
BenchmarkReadWriteCgoCalls-2 416859 2785 ns/op 64 B/op 3 allocs/op
BenchmarkReadWriteCgoCalls-2 401479 2927 ns/op 64 B/op 3 allocs/op
PASS
ok performance/cgo 7.093s
goos: linux
goarch: amd64
pkg: performance/golang
cpu: Intel(R) Xeon(R) Platinum 8255C CPU @ 2.50GHz
BenchmarkReadWriteNetCalls
BenchmarkReadWriteNetCalls-2 411382 3768 ns/op 16 B/op 1 allocs/op
BenchmarkReadWriteNetCalls-2 385462 2816 ns/op 16 B/op 1 allocs/op
BenchmarkReadWriteNetCalls-2 398431 2799 ns/op 16 B/op 1 allocs/op
BenchmarkReadWriteNetCalls-2 402884 2882 ns/op 16 B/op 1 allocs/op
BenchmarkReadWriteNetCalls-2 403792 3103 ns/op 16 B/op 1 allocs/op
PASS
ok performance/golang 7.082s

可以看到运行速度基本没有差别,但是因为使用cgo时,多了一步从golang拷贝到c语言里的过程。所以使用cgo时,应该需要考虑内存拷贝的耗时。

内部原理

为了查看cgo是如何调用c语言库,可以在code/code_c目录下执行make analysis得到编译的中间文件,具体产物可以如下:

1
2
3
4
5
6
7
8
├── _cgo_export.c
├── _cgo_export.h
├── _cgo_flags
├── _cgo_gotypes.go
├── _cgo_main.c
├── _cgo_.o
├── code_c.cgo1.go
└── code_c.cgo2.c

首先查看code_c.cgo1.go

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Code generated by cmd/cgo; DO NOT EDIT.
//line /home/ubuntu/cgo_playload/code/code_c/code_c.go:1:1
package main
/*
#include <stdio.h>
int Call(){
printf("hello cgo\n");
return 0;
}
*/

import _ "unsafe"
func main() {
( /*line :14:2*/_Cfunc_Call /*line :14:7*/)()
}

可以看到_Cfunc_Call,这个函数的定义在_cgo_gotypes.go中。

go:linknamegolang提供的一种特殊的指令,通过这种声明go:linkname <localname> <importpath.name>,可以指示编译器使用 importpath.name 作为源代码中声明为 localname 的变量或函数的目标文件符号名称。特别地,可以用来使用某些包内的不可以导出函数(变量)。在下面这个例子中,_cgo_runtime_cgocall这个函数就把指定未runtime.cgocall的别名,所以在调用_cgo_runtime_cgocall时,实际是在调用后者。

go:cgo_import_static <local>golang提供的一种特殊的指令,它可以允许<local>作为一个未定义的引用被链接,它假设<local>的定义存在于其他目标文件中。在下面这个例子中,_cgo_e80b8e4145b4_Cfunc_Call就被定义在了code_c.cgo2.c文件中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//go:cgo_import_static _cgo_e80b8e4145b4_Cfunc_Call
//go:linkname __cgofn__cgo_e80b8e4145b4_Cfunc_Call _cgo_e80b8e4145b4_Cfunc_Call
var __cgofn__cgo_e80b8e4145b4_Cfunc_Call byte
var _cgo_e80b8e4145b4_Cfunc_Call = unsafe.Pointer(&__cgofn__cgo_e80b8e4145b4_Cfunc_Call)



//go:cgo_unsafe_args
func _Cfunc_Call() (r1 _Ctype_int) {
_cgo_runtime_cgocall(_cgo_e80b8e4145b4_Cfunc_Call, uintptr(unsafe.Pointer(&r1)))
if _Cgo_always_false {
}
return
}

可以看到其中调用_cgo_runtime_cgocall,参数uintptr(unsafe.Pointer(&r1))就是_cgo_e80b8e4145b4_Cfunc_Call函数的入参。

查看cgocall的实现,其中最关键的就算三条语句:

1
2
3
4
5
6
// 声明要进入系统调用
entersyscall()
// 调用C
asmcgocall(fn, arg)
// 退出系统调用
exitsyscall()

执行entersyscall后,会将协程g的状态扭转为_Psyscall,并且为了不阻塞本地队列中其他g的执行,会将处理器p会为本地队列中其他剩余的g找一个空闲的m。这里需要注意,通过cgo调用的逻辑被阻塞阻了或者执行时间过长,可能会生成很多线程。而golang并不会回收系统线程,所以最终可能会导致oom。

asmcgocall的作用主要是两个

  • 切换协程栈到g0
  • 执行c函数调用

这里解释一下为啥要切换到g0,众所周知,golang的协程初始只有分配了2k的栈大小,后续视实际情况,进行扩容。而每次扩容,都会修改栈上分配的变量、函数内存地址。而c语言的栈大小是固定的,首次分配后,就不会再变化了。所以一定要切换成g0

参考

【1】cgo编程
【2】编译指令
【3】性能对比
【4】cgocall
【5】cgocall2