深入学习CGO

Posted 惜暮

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深入学习CGO相关的知识,希望对你有一定的参考价值。

深入学习CGO

很多场景下我们希望在Go中利用好已有的C/C++库,Go语言通过自带的一个叫CGO的工具来支持C语言函数调用。

本文主要focus在 Go调用C函数的场景。

快速入门

CGO 一般面向C 的接口编程,下面给出一个最基本的,通过CGO在GO中调用CGO的函数打印字符串:

这里我们定义一个 SayHello 的C函数来实现打印,然后从Go语言环境中调用这个SayHello函数:

package main

/*
#include <stdio.h>
static void SayHello(const char* s) 
    puts(s);

 */
import "C"

func main() 
	C.SayHello(C.CString("Hello, World\\n"))

  1. import "C" 这个是必须导入的,表示启用CGO
  2. import "C" 上面的注释是内嵌的C代码(也可以通过.c和.h文件封装)
  3. C.CString 函数可以将go中字符串通过拷贝的形式转换成C中的char*。这里实际上是通过C的malloc函数申请的内存,所以需要在Go中手动free掉。这里没有做free是因为程序退出会自动清理进程所有资源。

基础知识

import “C” 语句

要使用CGO特性,需要安装C/C++构建工具链,在macOS和Linux下是要安装GCC,在windows下是需要安装MinGW工具。同时需要保证环境变量CGO_ENABLED被设置为1,这表示CGO是被启用的状态。

在Go代码中出现了import "C"语句则表示使用了CGO特性,紧跟在这行语句前面的注释是一种特殊语法,里面包含的是正常的C语言代码。当确保CGO启用的情况下,还可以在当前目录中包含C/C++对应的源文件。

需要注意的是,import “C”导入语句需要单独一行,不能与其他包一同import。向C函数传递参数也很简单,就直接转化成对应C语言类型传递就可以。

#cgo语句

在import "C"语句前的注释中可以通过#cgo语句设置编译阶段和链接阶段的相关参数。编译阶段的参数主要用于定义相关宏和指定头文件检索路径。链接阶段的参数主要是指定库文件检索路径和要链接的库文件。

// #cgo CFLAGS: -DPNG_DEBUG=1 -I./include
// #cgo LDFLAGS: -L/usr/local/lib -lpng
// #include <png.h>
import "C"

上面的代码中,CFLAGS部分,-D部分定义了宏PNG_DEBUG,值为1;-I定义了头文件包含的检索目录。LDFLAGS部分,-L指定了链接时库文件检索目录,-l指定了链接时需要链接png库。

因为C/C++遗留的问题,C头文件检索目录可以是相对目录,但是库文件检索目录则需要绝对路径。

#cgo语句主要影响CFLAGS、CPPFLAGS、CXXFLAGS、FFLAGS和LDFLAGS几个编译器环境变量。LDFLAGS用于设置链接时的参数,除此之外的几个变量用于改变编译阶段的构建参数(CFLAGS用于针对C语言代码设置编译参数)。

GO与C的类型转换


为了提高C语言的可移植性,在<stdint.h>文件中,不但每个数值类型都提供了明确内存大小,而且和Go语言的类型命名更加一致。

CGO的C虚拟包提供了以下一组函数,用于Go语言和C语言之间数组和字符串的双向转换:

// Go string to C string
// The C string is allocated in the C heap using malloc.
// It is the caller's responsibility to arrange for it to be
// freed, such as by calling C.free (be sure to include stdlib.h
// if C.free is needed).
func C.CString(string) *C.char
// Go []byte slice to C array
// The C array is allocated in the C heap using malloc.
// It is the caller's responsibility to arrange for it to be
// freed, such as by calling C.free (be sure to include stdlib.h
// if C.free is needed).
func C.CBytes([]byte) unsafe.Pointer
// C string to Go string
func C.GoString(*C.char) string
// C data with explicit length to Go string
func C.GoStringN(*C.char, C.int) string
// C data with explicit length to Go []byte
func C.GoBytes(unsafe.Pointer, C.int) []byte

其中C.CString针对输入的Go字符串,克隆一个C语言格式的字符串;返回的字符串由C语言的malloc函数分配,不使用时需要通过C语言的free函数释放。C.CBytes函数的功能和C.CString类似,用于从输入的Go语言字节切片克隆一个C语言版本的字节数组,同样返回的数组需要在合适的时候释放。C.GoString用于将从NULL结尾的C语言字符串克隆一个Go语言字符串。C.GoStringN是另一个字符数组克隆函数。C.GoBytes用于从C语言数组,克隆一个Go语言字节切片。

该组辅助函数都是以克隆的方式运行。当Go语言字符串和切片向C语言转换时,克隆的内存由C语言的malloc函数分配,最终可以通过free函数释放。当C语言字符串或数组向Go语言转换时,克隆的内存由Go语言分配管理。通过该组转换函数,转换前和转换后的内存依然在各自的语言环境中,它们并没有跨越Go语言和C语言。克隆方式实现转换的优点是接口和内存管理都很简单,缺点是克隆需要分配新的内存和复制操作都会导致额外的开销。

CGO函数调用

这里给出一个例子,展示Go通过接口调用模块化的C函数:

我们可以抽象一个名为hello的模块,模块的全部接口函数都在hello.h头文件定义:

//hello.h
void SayHello(const char* s);

其中只有一个SayHello函数的声明。但是作为hello模块的用户来说,就可以放心地使用SayHello函数,而无需关心函数的具体实现。而作为SayHello函数的实现者来说,函数的实现只要满足头文件中函数的声明的规范即可。下面是SayHello函数的C语言实现,对应hello.c文件:

//hello.c
#include <stdio.h>
#include "hello.h"

void SayHello(const char* s) 
    puts(s);

在hello.c文件的开头,实现者通过#include "hello.h"语句包含SayHello函数的声明,这样可以保证函数的实现满足模块对外公开的接口。

接口文件hello.h是hello模块的实现者和使用者共同的约定,但是该约定并没有要求必须使用C语言来实现SayHello函数。我们也可以用C++语言来重新实现这个C语言函数:

#include <iostream>

//通过extern "C"语句指示该函数的链接符号遵循C语言的规则。
extern "C" 
    #include "hello.h"


void SayHello(const char* s) 
    std::cout << s;

在C++版本的SayHello函数实现中,我们通过C++特有的std::cout输出流输出字符串。不过为了保证C++语言实现的SayHello函数满足C语言头文件hello.h定义的函数规范,我们需要通过extern "C"语句指示该函数的链接符号遵循C语言的规则。

最后就是运行CGO的main函数:

package main

//#include "hello.h"
import "C"

func main() 
	C.SayHello(C.CString("Hello, World\\n"))

CGO内部机制

TODO

CGO内存模型

CGO是架接Go语言和C语言的桥梁,它使二者在二进制接口层面实现了互通,但是我们要注意因两种语言的内存模型的差异而可能引起的问题。

这里有一些关键点我们需要注意:

  1. C语言的内存在分配之后就是稳定的,只要不是被人为提前释放,那么在Go语言空间可以放心大胆地使用。
  2. Go语言的栈始终是可以动态伸缩的(动态栈)。
  3. GC 导致 Go语言内存生命周期不固定。
  4. cgo调用的C函数返回前, 传入的Go内存有效。
  5. cgo调用的C函数返回后, Go内存对C语言失效。
  6. CGO的的调用类似于系统调用,会阻塞原协程。并且C函数的执行会切换到g0,也就是C函数是在系统线程执行的,也就是内核线程栈。
  7. C中栈内存不能返回(函数调用返回就被回收)。

借助C语言内存稳定的特性,在C语言空间先开辟同样大小的内存,然后将Go的内存填充到C的内存空间;返回的内存也是如此处理。下面的例子是这种思路的具体实现:

package main

/*
#include <stdio.h>
#include <stdlib.h>
void printString(const char* s) 
    printf("%s", s);

*/
import "C"
import "unsafe"

func printString(s string) 
	var cs *C.char  = C.CString(s)
	C.printString(cs)
	C.free(unsafe.Pointer(cs))

func main() 
	s := "hello"
	printString(s)

在需要将Go的字符串传入C语言时,先通过C.CString将Go语言字符串对应的内存数据复制到新创建的C语言内存空间上。上面例子的处理思路虽然是安全的,但是效率极其低下(因为要多次分配内存并逐个复制元素),同时也极其繁琐。

为了简化并高效处理此种向C语言传入Go语言内存的问题,cgo针对该场景定义了专门的规则:在CGO调用的C语言函数返回前,cgo保证传入的Go语言内存在此期间不会发生移动,C语言函数可以大胆地使用Go语言的内存!

C++类封装成C API

TODO
参考:C++ 类包装

CGO调用在go runtime 层面的处理

CGO调用的入口在runtime.cgocall函数: cgocall.go(1.14)

这里先简单翻译下该文件里面的一些注释(只针对Go调用C的场景):

要从Go中调用C函数,cgo生成的代码会调用 runtime.cgocall(_cgo_Cfunc_f, frame),其中_cgo_Cfunc_f对应的C函数。

runtime.cgocall会调用 entersyscall 进入系统调用以避免阻塞其余协程的调度或则垃圾回收器。然后调用 runtime.asmcgocall(_cgo_Cfunc_f, frame)

runtime.asmcgocall 是汇编实现的,该函数会切换内核线程的 g0 栈(也就是操作系统分配的堆栈),因此可以安全的运行gcc编译的代码以及调用_cgo_Cfunc_f

_cgo_Cfunc_f会调用实际的C函数,并拿到执行的结果,然后返回给runtime.asmcgocall

等当前协程重新获取控制后,runtime.asmcgocall 会切换回原来的go协程的栈,并返回到runtime.cgocall.

等当前协程重新获取控制后, runtime.cgocall会调用exitsyscall,该函数会阻塞直到m能够运行当前协程。

这里粘贴出部分源码:

func cgocall(fn, arg unsafe.Pointer) int32 
	......
	mp := getg().m
	mp.ncgocall++
	mp.ncgo++

	// Reset traceback.
	mp.cgoCallers[0] = 0

	// 宣布正在进入系统调用,从而调度器会创建另一个 M 来运行 goroutine
	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)
 	// 宣告退出系统调用,等待runtime调度器重新M去执行
	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

CGO的静态/动态库封装以及编译链接参数

这块这里不准备细说,基本是一些编译静/动态库的参数,以及编译链接的参数的一些注意事项:
参考:
静态库和动态库
编译和链接参数

CGO定位内存泄露

valgrind 能够很方便的定位C/C++中的内存泄漏问题。对于CGO的场景,valgrind能够很快定位C函数中的内存泄漏;但是valgrind对Go代码中的内存泄漏(比如Go中调用C.CString函数不手动free),检测能力有限,只能提示内存泄漏大概位置,没法精准定位。

对于Go中的pprof工具,是没法定位CGO的内存泄漏问题,猜测是因为:Go的pprof只会检测Go垃圾回收器申请和释放的内存,C.CString以及c代码中的内存申请都没有经过gc,所以无法监测。

参考:Go语言使用cgo时的内存管理笔记

CGO性能

我们使用CGO一般有几个场景考虑(个人观点:)

  1. 继承C/C++历史积累的优秀库;
  2. 历史遗留项目的改造。

从网上各路文章中能够回到,CGO通过go去调用C是有比较大的性能开销的。造成性能开销原因有很多:

  1. 必须切换go的协程栈到系统线程的主栈去执行C函数
  2. 涉及到系统调用以及协程的调度。
  3. 由于需要同时保留C/C++的运行时,CGO需要在两个运行时和两个ABI(抽象二进制接口)之间做翻译和协调。这就带来了很大的开销。

这里我做了个测试,通过CGO调用一个空的C函数以及Go调用原生的空函数的性能损耗:

package main

/*
//#include <stdio.h>
//#include <stdlib.h>
void printString() 
*/
import "C"
import (
	"fmt"
	"time"
)

func main() 
	s := time.Now().UnixNano()
	for i := 0; i < 100000000; i++ 
		C.printString()
	
	e := time.Now().UnixNano()
	fmt.Println("cgo:", e-s, "ns")

	s = time.Now().UnixNano()
	for i := 0; i < 100000000; i++ 
		empty()
	
	e = time.Now().UnixNano()
	fmt.Println("go:", e-s, "ns")


func empty() 


测试机器:15款小MacPro;
这里的测试不是非常精准,因为涉及到go里面的循环,但是大概能够说明问题,测试结果显示:

cgo: 7765102000 ns
go:       52018000 ns

可以看到性能差距是非常明显的,每次CGO调用性能损耗在77ns左右。

所以CGO适用场景是有限制的,并不适合与高性能,高频调用场景。

CGO最佳使用场景总结

先说一下使用CGO的一些缺点:

1. 内存隔离
2. C函数执行切换到g0(系统线程)
3. 收到GOMAXPROC线程限制
4. CGO空调用的性能损耗(50+ns)
5. 编译损耗(CGO其实是有个中间层)

CGO 适合的场景:

1. C 函数是个大计算任务(不在乎CGO调用性能损耗)
2. C 函数调用不频繁
3. C 函数中不存在阻塞IO
4. C 函数中不存在新建线程(与go里面协程调度由潜在可能互相影响)
5. 不在乎编译以及部署的复杂性

参考文献:

CGO 和 CGO 性能之谜
why-cgos-performance-is-so-slow-is-there-something-wrong-with-my-testing-code
Go语言使用cgo时的内存管理笔记
如何把Go调用C的性能提升10倍?
Go原本-CGO
深入CGO编程

以上是关于深入学习CGO的主要内容,如果未能解决你的问题,请参考以下文章

深入学习USBusb软件层面组织层次

构建简单 cgo 模块的问题

FFI实战之对接GO(CGO)

CGO静态库和动态库

CGO静态库和动态库

yes带你深入算法与中间件设计的奥秘,源码层面