深入学习CGO
Posted 惜暮
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深入学习CGO相关的知识,希望对你有一定的参考价值。
深入学习CGO
- 快速入门
- 基础知识
- GO与C的类型转换
- CGO函数调用
- CGO内部机制
- CGO内存模型
- C++类封装成C API
- CGO调用在go runtime 层面的处理
- CGO的静态/动态库封装以及编译链接参数
- CGO定位内存泄露
- 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"))
import "C"
这个是必须导入的,表示启用CGOimport "C"
上面的注释是内嵌的C代码(也可以通过.c和.h文件封装)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语言的桥梁,它使二者在二进制接口层面实现了互通,但是我们要注意因两种语言的内存模型的差异而可能引起的问题。
这里有一些关键点我们需要注意:
- C语言的内存在分配之后就是稳定的,只要不是被人为提前释放,那么在Go语言空间可以放心大胆地使用。
- Go语言的栈始终是可以动态伸缩的(动态栈)。
- GC 导致 Go语言内存生命周期不固定。
- cgo调用的C函数返回前, 传入的Go内存有效。
- cgo调用的C函数返回后, Go内存对C语言失效。
- CGO的的调用类似于系统调用,会阻塞原协程。并且C函数的执行会切换到g0,也就是C函数是在系统线程执行的,也就是内核线程栈。
- 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,所以无法监测。
CGO性能
我们使用CGO一般有几个场景考虑(个人观点:)
- 继承C/C++历史积累的优秀库;
- 历史遗留项目的改造。
从网上各路文章中能够回到,CGO通过go去调用C是有比较大的性能开销的。造成性能开销原因有很多:
- 必须切换go的协程栈到系统线程的主栈去执行C函数
- 涉及到系统调用以及协程的调度。
- 由于需要同时保留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的主要内容,如果未能解决你的问题,请参考以下文章