0%

Cgo学习

Cgo 的诞生是为了继承C/C++积累了半个世纪的软件财富,这样的话我们可以方便的在Go项目中使用这些财富!具体信息可以看官方文档 ,本文会介绍如何使用Cgo,如何将C++项目集成到Go中,有兴趣可以直接看我自己用Cgo写的一个项目,成熟度还可以: https://github.com/anthony-dong/protobuf

Cgo 真的完美吗

Cgo 顾名思义,是C与GO的一个桥梁,但是C与GO的调度模型、内存模型不太一样,就会导致这个桥梁会有一些性能、内存损耗,例如Go的用户代码都跑在goroutine(有栈协程)中,但是C跑在原生的线程中,就会导致要进行一次线程的切换,由 goroutine -> Native-Thread -> goroutine ,所以应该尽量避免使用一些耗时比较长的c程序!

TODO:后续补充JNI的性能开销!!

下面我们可以对比一下简单的Go和Cgo差异

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
package test

/*
int sum_c (int x, int y){
return x + y ;
}
*/
import "C"

func sum(x, y int) int {
return x + y
}

func sum_c(x, y int) int {
return int(C.sum_c(C.int(x), C.int(y)))
}

来看下benchmark的结果,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
goos: linux
goarch: amd64
pkg: github.com/anthony-dong/protobuf/internal/pb_gen
cpu: Intel(R) Xeon(R) Platinum 8260 CPU @ 2.40GHz
BenchmarkSUM
BenchmarkSUM-8 1000000000 0.3557 ns/op 0 B/op 0 allocs/op
BenchmarkSUM-8 1000000000 0.3567 ns/op 0 B/op 0 allocs/op
BenchmarkSUM-8 1000000000 0.3626 ns/op 0 B/op 0 allocs/op
BenchmarkSUM-8 1000000000 0.3588 ns/op 0 B/op 0 allocs/op
BenchmarkSUM-8 1000000000 0.3540 ns/op 0 B/op 0 allocs/op
BenchmarkSUM_C
BenchmarkSUM_C-8 14440388 79.73 ns/op 0 B/op 0 allocs/op
BenchmarkSUM_C-8 15093638 85.74 ns/op 0 B/op 0 allocs/op
BenchmarkSUM_C-8 14932076 85.33 ns/op 0 B/op 0 allocs/op
BenchmarkSUM_C-8 14808447 79.42 ns/op 0 B/op 0 allocs/op
BenchmarkSUM_C-8 13486689 78.92 ns/op 0 B/op 0 allocs/op
PASS
ok github.com/anthony-dong/protobuf/internal/pb_gen 8.531s

结论就是

  1. 大概调度上是3个数量级的损耗,差距近千倍!所以CGO不适合做那种简单的业务逻辑处理,如果代码可以很简单的通过Go程序实现,那么原则上不要用CGO去做,除非C性能要远高于GO或者GO去实现太过于麻烦!
  2. CGO使用原生的Native线程,如果你的C程序耗时比较严重,且并发较高,对于GO程序的影响也会很大!
  3. 注意GO里面可以通过 debug.SetMaxThreads(10) 来设置最大的线程数,但是假如CGO调度的线程不够了,那么会直接程序挂掉,所以不要使用 限制最大线程数的函数,可以通过channel等工具来限制最大并发数量 !
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
// main.go 文件
/*
#include <unistd.h>
int sum_c (int x, int y){
sleep(60);
return x + y ;
}
*/
import "C"

func sum_c(x, y int) int {
return int(C.sum_c(C.int(x), C.int(y)))
}


// main_test.go
func TestCThread(t *testing.T) {
t.Log("pid: ", os.Getpid())
currentLock := make(chan bool, 10)

wg := sync.WaitGroup{}
wg.Add(100)
for x := 0; x < 100; x++ {
cloneX := x
go func() {
currentLock <- true
defer func() {
<-currentLock
defer wg.Done()
}()
t.Log("sum-start: ", cloneX)
s := sum_c(cloneX, cloneX+1)
t.Log("sum-done: ", cloneX, s)
}()
}
wg.Wait()
}

// ps -mq ${pid} | wc -l

熟悉Cgo基本写法

例子

代码分为两部分,一部分是C代码(注意: 必须是C,不能是C++),一部分是Go代码,其次一定要 import "C"

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
package main

/*
#include <stdlib.h>
#include <stdio.h>
#include <string.h>

void c_print_str(const char* str){
printf("c_print_str: %s\n",str);
}

void c_print_str_size(const char* str,int len){
printf("c_print_str_size: ");
for (int x=0; x<len; x++){
printf("%c",*str);
str=str+1;
}
printf("\n");
}

int c_str_len(const char* str){
return strlen(str);
}

const char* c_new_str() {
char* str = (char*)malloc(5 * sizeof(char));
str[0] = 'a';
str[1] = '\0';
str[2] = 'b';
str[3] = '\0';
str[4] = 'c';
return str;
}

typedef struct __CStruct {
char* name;
int age;
} CStruct;

void print_CStruct(CStruct* ss) {
printf("name: %s, age: %d\n", ss->name, ss->age);
}
*/
import "C"
import (
"fmt"
"unsafe"
)

func main() {
{
// C语言中认为'\0'是一个字符串的结尾符,也就是说字符串会额外多一个size(char)来存储'\0',但是Go语言不是!
gstr := "hello world\u00001111"

// 创建一个C的 char* 字符串,这里会涉及到一次内存的拷贝,原因是为了安全,同时你也需要free掉!
cstr := C.CString(gstr)
defer C.free(unsafe.Pointer(cstr))

// 调用C函数
C.c_print_str(cstr)
C.c_print_str_size(cstr, C.int(len(gstr)))

// 注意: C中基本类型转换Go直接强转即可,最好转成对应类型
fmt.Println("int(C.c_str_len(cstr)): ", int(C.c_str_len(cstr)))
fmt.Println("len(gstr): ", len(gstr))
}

{
// 获取C的字符串
cstr := C.c_new_str()
defer C.free(unsafe.Pointer(cstr))

// 默认GoString遵循的C的实现,也就是遇到'\0'就截断了,所以输出了 a
printStr("C.GoString(cstr)", C.GoString(cstr))
// 就是由于上诉的原因,因此人家开发了一个C.GoStringN函数,就是你需要显示告诉我C中char*的长度!
printStr("C.GoStringN(cstr, 5)", C.GoStringN(cstr, 5))

// char* -> []byte 转换
var data []byte = C.GoBytes(unsafe.Pointer(cstr), C.int(5))
for _, elem := range data {
fmt.Printf("char: %U\n", elem)
}

// 它是一个切片!
// 注意:切片也可以转换成char数组
var data2 = data[:1]
printStr("data2", string(data2))
}

{
var ss C.CStruct
ss.name = C.CString("tom")
ss.age = C.int(1)
C.print_CStruct(&ss)
}
}

func printStr(name, value string) {
fmt.Printf(`%s: "%s", len: %d`+"\n", name, value, len(value))
}

执行 CGO_ENABLED=1 go run -v main.go ,输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
c_print_str: hello world
c_print_str_size: hello world1111
int(C.c_str_len(cstr)): 11
len(gstr): 16
C.GoString(cstr): "a", len: 1
C.GoStringN(cstr, 5): "abc", len: 5
char: U+0061
char: U+0000
char: U+0062
char: U+0000
char: U+0063
data2: "a", len: 1
name: tom, age: 1

总结

  1. func C.CString(string) *C.char 这个函数转换成C的字符串的时候,没有考虑 \0 结尾符号的问题,所以这点一定要注意!
  2. func C.GoString(*C.char) string 的实现考虑了\0结尾符号的问题,因此它实际上就是拷贝了 strlen 长度的C字符串到Go的字符串
  3. func C.GoStringN(*C.char, C.int) string 解决了\0结尾符号的问题,需要显示指定 C语言中字符串的长度!
  4. func C.GoBytes(unsafe.Pointer, C.int) []bytefunc C.CBytes([]byte) unsafe.Pointer 可以实现数组的转换
  5. 其他基础类型都支持转换,具体看文档:官方文档

如何降低开销

使用原生的API,go->c 和 c->go 都需要涉及到数据的拷贝!

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
import "C"
const cStrEnd = string('\u0000')

// unsafe string GO -> C
func unsafeCString(str string) *C.char {
// C语言的字符串是以\u0000 结尾的,所以这里注意了. 需要手动加一个结尾符号
if index := strings.IndexByte(str, '\u0000'); index == -1 {
str = str + cStrEnd
}
header := (*reflect.StringHeader)(unsafe.Pointer(&str))
return (*C.char)(unsafe.Pointer(header.Data))
}

// unsafe []byte GO -> C
// 注意 []byte 长度大于0
func unsafeCBytes(str []byte) *C.char {
return (*C.char)(unsafe.Pointer(&str[0]))
}

// unsafeGoBytes []byte C->GO
func unsafeGoBytes(arr *C.char, arrSize C.int) []byte {
header := reflect.SliceHeader{
Data: uintptr(unsafe.Pointer(arr)),
Len: int(arrSize),
Cap: int(arrSize),
}
return *(*[]byte)(unsafe.Pointer(&header))
}

调试工具

可以使用 go tool cgo main.go 查看cgo生成的文件, 其实我们用注释写C代码,编译器并不会识别,而是GO编译期间有个预处理的阶段 生成了 go tool cgo 的产物!

大概会生成一份 C -> GO 转换的代码,具体可以自己调试一下!

如何集成C++

C++ 与 C的关系

我们知道C++ 实际上是完全兼容 C的,其次C++与C是可以相互调用的,那么建立这些前提的就是 要明确告诉 c/c++ 编译器,我这个代码是C语言的,因此需要 extern "C" 来告诉 C++ 我这个代码是C语言的,编译器就会按照C语言的规范去链接!

返过来,C语言他没有 extern "C" 这个关键词,那么C++引用了C函数的代码,因此需要 extern "C" 可以修饰 #include ${c的头文件},也就是说告诉编译器,这些申明用C语言的规范去链接!

其实上面非常的绕,需要大家亲自体会一下!其次C与C++语法不完全一样,有些时候在做这种集成开发的时候容易混了!

下面这里有个例子,就是集成libprotobuf 实现解析 protobuf 文件,目前应该Go开源社区里面没有做集成的!

前置准备

  1. 下载 protobuf, 具体如何本地构建protobuf的链接库,可以直接看我们的这个项目
  2. 学会用CMake等工具构建代码
  3. 掌握C/C++/Go的基本语法
  4. 项目地址: https://github.com/anthony-dong/protobuf

项目结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
├── CMakeLists.txt
├── README.md
├── cgo.go # cgo go语言实现
├── cgo.h # cgo c头文件
├── deps # 依赖
│   ├── README.md
│   ├── darwin_x86_64
│   ├── include # 引用的第三方头文件
│   └── linux_x86_64 # 静态依赖
│   ├── libprotobuf.a
│   └── vendor.go # 解决go vendor 问题
├── errors.go
├── go.mod
├── go.sum
├── option.go
├── pb_include.h
├── pb_parser.cpp # 核心业务逻辑
├── pb_parser.go # 对外接口
├── pb_parser.h # 核心业务逻辑
├── utils.go
└── vendor.go # 解决go vendor 问题

大概就是C++写业务逻辑,然后 C++ 的接口 转成 C接口, C->GO的翻译!

实现功能

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
package main

import (
"fmt"

"github.com/anthony-dong/protobuf"
)

func main() {
tree, err := protobuf.NewProtobufDiskSourceTree("internal/test/idl_example")
if err != nil {
panic(err)
}
idlConfig := new(protobuf.IDLConfig)
idlConfig.IDLs = tree
idlConfig.Main = "service/im.proto"
idlConfig.IncludePath = []string{"desc", "."}

desc, err := protobuf.ParsePBMultiFileDesc(idlConfig,
protobuf.WithJsonTag(),
protobuf.WithSourceCodeInfo(),
protobuf.WithGoogleProtobuf(),
protobuf.WithRequireSyntaxIdentifier(),
)
if err != nil {
panic(err)
}
fmt.Println(protobuf.MessageToJson(desc, true))
}

// 运行: CGO_ENABLED=1 go run main.go

注意点

  1. 尽可能的使用 unsafe 操作避免内存拷贝,尤其是数据大的情况,效果优秀
  2. 简单函数尽可能的用GO实现
  3. 注意内存管理和回收,避免直接暴露给使用者
  4. C++的技巧可以参考我的这篇文章: https://anthony-dong.github.io/2023/04/06/fd8e40efcdb71f2be44fb720dc582d67/
  5. C 语言实际上没有太多要学习的,是最简单的语言了,没啥难度,无非注意内存分配!
  6. C++ 翻译 C 会存在有些类对象转换不来或者拷贝代价太高,尽可能的使用void指针避免拷贝!

参考

本人坚持原创技术分享,如果你觉得文章对您有用,请随意打赏! 如果有需要咨询的请发送到我的邮箱!