CGO封装CPP库的一些最佳实践

目录

背景

最近业务上需要复用CPP编写的客户端SDK库,为了让团队主力语言Golang能够顺利接入SDK,因此使用了CGO桥接技术将C++11编写的SDK库封装成生产环境可用的Golang SDK,在翻阅了网上大部分关于cgo的中英文资料后,发现其中尤其是实现Go调用CPP库还是有非常多需要注意的细节,大部分中文资料都是以简单的C++的STL库函数封装为例点到为止,本文在前人基础之上总结了一系列封装复杂CPP库时的最佳实践的tips,希望能填补相关资料的空白。

如非必要,勿用CGO

将这一条列为第一条作为警示,因为许多cgo新手可能都会陷入执行效率上的误区,即:使用cgo调用而不是使用普通的Go函数会获得底层CPU单核执行性能上的大幅提升。这点其实需要具体情况具体分析,根据标准库中cgocall的源码来看,每个cgo的调用本质上都会有函数调用栈切换(传参,PC以及SP需要设置成外部的C程序)的过程,这些操作相比原生Go的调用来说都是额外的开销,在简单的计算函数调用上,cgo相比原生的运行效率单次调用的耗时可能相差能有上百倍。

// Call from Go to C.
func cgocall(fn, arg unsafe.Pointer) int32 {
    // ...
	// Reset traceback.
	mp.cgoCallers[0] = 0

	// 进入系统调用,这时go会新起一个系统线程M来执行后面的操作
	entersyscall()

	osPreemptExtEnter(mp)

	mp.incgo = true
	errno := asmcgocall(fn, arg)

	// ...
	osPreemptExtExit(mp)

	exitsyscall()

	// ...

	// 可能会有C到Go回调,相关的对象需要在GC上进行保活后再由GC决定是否能进行释放
	KeepAlive(fn)
	KeepAlive(arg)
	KeepAlive(mp)

	return errno
}

那么如何判断什么时候使用cgo呢?个人经验是看投入产出比,如果使用cgo能大大复用已有的C代码,降低后续的开发维护成本,亦或是CPU计算密集型的函数,使用C库对处理性能的提升能够很好地补偿cgo带来的副作用,那么cgo也是不错的选择。需要注意的是,如果一些第三方库已经使用纯Go和Plan9汇编达到到80~90%左右C库的运行性能时,也不推荐使用cgo的桥接库,尤其是当库的使用者需要在Go协程内大量并发调用库函数时,在用户态复用系统线程所带来的计算扩展性远比仅在C层执行略快的cgo调用进行内核上下文切换要有优势的多。

C ABI接口封装遵循最小化原则

如果我们要手工封装一个CPP库,实际执行的调用链为 Go runtime -> cgocall -> C ABI -> CPP func,ABI是应用二进制接口,只要符合这种二进制桥接接口的规范和约定,就能够很好的实现外部语言调用C接口。因此在实现桥接接口前建议先从Go层开始定义好符合Go语言语义的结构体和方法,再将Go对象映射到对应的C结构体或CPP对象,Go方法映射到对应的C函数和CPP方法,从而确定桥接的C库头文件,确定了中间桥接层的函数签名后,再着手使用C++语言实现头文件具体的逻辑,最终将CPP的实现和C的头文件编译链接在一起形成一个整体,这也是go能够调用cpp的底层逻辑。

不要使用SWIG生成C桥接代码

不想手工维护C接口,因此会有人会想到在python调用C++动态库中的常用套路,那就是使用SWIG自动生成CPP库的stub函数,在前期我们准备编写桥接代码时其实也有类似的想法,不过最终还是放弃了,一个很重要的原因是swig的Go生成器在社区维护的热度并不高且很多C++11语法特性都是不支持的,比如智能指针等。而且生成的大量桥接代码都是不可读的,反而增加了后期维护和问题排查的心智负担,还可能会有内存泄露的风险,因此在生产环境下还是建议手工进行封装。

Batching your call

既然每次cgo调用都会有一定额外的开销,那么这种调用开销就会与调用次数成一个线性的关系,一种很自然的优化办法就是将多个cgo调用合并为一个,从而在处理同样多内容下减少降低cgo调用的额外开销,调用N次变为1次后,在一定周期内调用本身的额外的开销就近似变为原来的N分之一,在频繁调用的时候能够有效降低整体的调用时延。比如我封装的CPP调用内部涉及大量的大段字符串处理解析压缩序列化等操作,因此每次传递8M的大字符切片的处理时间通常来说就会比每次1M调用8次的整体处理时延要更低。

减少不必要的数据拷贝

cgo默认提供了一些常见的帮助函数来将Go中的内置类型转换成C中相对应的数据类型,在内部保证传入参数只读的情况下往往能做一些zero copy的tricks

例如传入只读的byte切片但是避免进行数据拷贝

b := buf.Bytes()
rc := C.the_function((*C.char)(&b[0]), C.int(buf.Len()))

实际等效于

str := buf.String()
p := C.CString(str)
defer C.free(p)
rc = C.the_function(p, C.int(len(str)))

第一种方式显然在大切片下避免了拷贝带来的额外开销

小心内存泄露

跨语言调用时拷贝传参通常来说都是比较安全的做法。不过当带垃圾回收的Go语言和手动管理内存的C语言再加上RAII的CPP相互混用时,一定要注意防止C或CPP层的堆内存泄露,Go调用C接口一般遵循谁申请,谁就负责释放的原则。

比如Go申请了一个C的堆内存给char*并将string变量的内容拷贝至C对应char*的内存空间中,那么就要由Go层来主动进行内存的释放。

package main

/*
#include <stdlib.h>
*/
import "C"

import "unsafe"

func HelloWorld() error {
    cs := C.CString("Hello World!")
    defer C.free(unsafe.Pointer(cs)) // 由Go层负责显式地调用C中的free进行释放

    // 安全地使用cs进行cgo调用
    // ...
    // 桥接层内部无需关心参数的释放
    err := C.my_c_func(cs)
    if err != nil {
		// 不为空的C字符串返回值由接收者释放
        defer C.free(unsafe.Pointer(err))
		return errors.New(C.GoString(err))
    }
	return nil
}

以此类推,在层层递进向下传参的时候,要保证接收者无需关心入参内存的释放,从而避免遗漏或者重复释放导致整个程序coredump。

需要注意的是在向上返回结果时是相反的,调用的返回者负责申请内存,而接收者负责主动释放内存。

正确处理智能指针

在许多的CPP库中,尤其是使用C++11编写的库,会使用大量的智能指针特性。很多的第三方库在创建对象的时候都会习惯性地将new出来的对象指针包一层shared_ptr用于自动引用计数,以便在对象脱离其作用域后能够正确的调用析构函数进行自动销毁并释放底层堆内存。但在cgo中智能指针的引用计数几乎毫无用武之地,因为在CPP层面无法感知Go层的引用情况,更别指望说能够正确地计数并自动销毁对象了。因此,如果想要在Go结构体持有CPP对象的情况下正确的释放对象资源,最好的办法就是将智能指针的引用计数在Go层中实现,最后由Go层来主动进行对象的释放。

如果CPP对象与Go对象是一一映射的话,我们就能够在应用层优雅地实现上层无感知的CPP对象引用计数。因为持有CPP对象裸指针的Go对象的引用数就等于CPP对象的引用数,这时 runtime.SetFinalizer 就完美地派上用场了,它相当于是Go对象被GC时会进行回调的钩子函数,利用这个钩子我们能轻松实现Go对象释放的同时安全地释放其底层持有的CPP对象。

package myClient

/*
#include <stdlib.h>

#ifdef __cplusplus
extern "C" {
#endif

void* client_create(const char* a); // 在CPP层调用构造函数new出对象指针返回给上层
void client_release(void* obj); // 在CPP层调用delete释放对象

#ifdef __cplusplus
}
#endif
*/
import "C"
import (
	"errors"
	"unsafe"
)

type Client struct {
	_ptr unsafe.Pointer
}

func New(a string) *Client {
	_a := C.CString(a)
	defer C.free(unsafe.Pointer(_a))

	_ptr := C.client_create(_a)

	cli := &Client{
		_ptr: _ptr
	}

	runtime.SetFinalizer(cli, func(c *Client) {
		// 保证Client对象在GC时自动将CPP对象进行销毁
		c.free()
	})

	return cli
}

func (c *Client) free() {
	C.client_release(c._ptr);
}

需要注意的是C语言中是没有类和对象这种语义的,因此为了保证ABI的兼容性在桥接的时候需要统一使用void*这种万能指针定义头文件,只有在.cpp实现时才能将其强转为对应的C++对象指针并进一步使用。

善用异常提高代码鲁棒性

底层实现是使用的C++语言,因此我们能在调用时使用try-catch语句包装一层将异常捕获并进行异常处理,无法处理也能将错误信息返回给上层调用方。

func (c *Client) DoSomething() error {
	err := C.do_something(c._ptr);
	if err != nil {
        defer C.free(unsafe.Pointer(err))
		return errors.New(C.GoString(err))
	}

	return nil
}
#include <string.h>

#define CAST_T(_T) reinterpret_cast<_T>

const char* do_something(void* obj) {
	try {
		CAST_T(CppClient*)(obj)->Do();
	}
	catch(MyException &e)
	{
		// handle failure
		// ...
		return NULL;
	}
	catch(const exception& e)
	{
		return strdup(e.what());
	}

	return NULL;
}

CGO编译工程化

通常我们CPP的SDK是在另外一个代码库进行维护的,因此cgo编译所依赖的静态库和动态库会在CPP库中的一个单独分支进行维护,这样保证了主库的升级不影响维护cgo的桥接cpp代码,同时也能轻松获取主库后续的更新。

Go sdk包的大致目录结构如下,假设包名称为myclient

➜  myclient
[]  .
├── []  lib
├── []  client.go
├── []  client_mock.go
└── []  wrapper.h

go层相关的对象和cgo调用的封装都会实现在 client.go 文件中,用于桥接的C ABI头文件定义一般会放在 wrapper.h 中,lib 目录用于存放编译链接 wrapper.h 头文件所需要的依赖,包括静态库以及相关的动态库。依赖由编译CPP库的特定分支得到并事先拷贝到目录中。整个工程目录也看起来会比较清晰自然,wrapper.h 的CPP实现我们选择在原始CPP库中的分支中维护,相当于通过C定义的接口彻底解耦服务调用方和服务提供方。

client_mock.go 用于条件编译适配在本地mac环境的编译调试,因为引入cgo有可能会破坏跨平台编译调试的便利性,使用条件编译能一定程度上解决平台隔阂导致整个二进制程序无法编译的问题。

调试以及pprof调优

这点可能是许多使用cgo的人吐槽最多的点,目前也没有一个比较好用的debug cgo程序的办法,prof cpu的话使用 perf 生成火焰图还是能分析的,GCC编译时使用低优化等级是能够保留具体调用堆栈信息的。

如果cgo调用内出现内存泄露,那就比较麻烦了。对于cgo程序debug或者是内存分析我自己是没有找到比较好的办法,也欢迎大家建言献策。

实际踩过的坑

在使用封装好的go sdk在生产环境实际运行的时候,我们发现所有核的CPU有异常打满的情况,通过go的cpu pprof最终锁定到唯一一个重计算的cgo调用上,上层大约会有持续上百个协程在并发调用。

go func() {
	C.heavy_process_func()
}

但是实际使用perf top命令分析进程cpu的时候,我们发现占用CPU时间最多的操作竟然是内核层的_raw_spin_lock,而在heavy_process_func的全过程中我们的业务逻辑中并没有任何使用自旋锁的痕迹,且调用上层的协程之间也毫无交集,何来的竞态抢互斥锁?

最终通过主动输出CPP层的所有异常日志,真相才得以浮出水面:原来C++在多线程各自并发抛大量异常时,执行栈展开会在glibc中抢全局锁。因此,在将异常正确收敛后,问题才最终解决。新版本的glibc中已经修复了多线程抛异常抢全局锁的这个问题,详情可以见对应的issue和patch。

Bug - Concurrently throwing exceptions is not scalable

参考资料

商业转载请联系作者获得授权,非商业转载请注明出处,谢谢合作!

联系方式:[email protected]