Cgo学习

983 阅读4分钟

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

Cgo 真的完美吗

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

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

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

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的结果,

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等工具来限制最大并发数量 !
// 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"

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 ,输出:

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 都需要涉及到数据的拷贝!

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语言的规范去链接(这里涉及到ABI规范问题)!

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

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

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

前置准备

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

项目结构

├── 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的翻译!

实现功能

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++的技巧可以参考我的这篇文章: anthony-dong.github.io/2023/04/06/…
  5. C 语言实际上没有太多要学习的,是最简单的语言了,没啥难度,无非注意内存分配!
  6. C++ 翻译 C 会存在有些类对象转换不来或者拷贝代价太高,尽可能的使用void指针避免拷贝!

参考