谈谈cgo字符串传递过程中的一些优化
- 前言
- 正文
- v1.0实现
- v1.1实现
- golang string数据结构
- 最终代码
- 注意
前言
最近做了一些关于golang的开发,由于有些相关模块是c++编译得到的动态库so,且不打算用golang重构一遍,想通过golang直接调用so的方式,来完成模块搭建。
查阅了一些golang调用so的方式,最终决定通过cgo的方式去调用so。
cgo是golang调用C语言的一个自带工具,由于这边有的so是c++编译的,所以使用cgo之前,对这些c++ so又做了一层c的封装。
因为业务关系,golang、c之间会传递一串很大的字符串。在性能调优的过程中,发现代码内存使用量发生了翻倍(因为传递的字符串很大,理想情况下进程内存使用≈字符串的内存使用,但此处≈n*字符串的内存使用)。
很明显字符串发生了多余的拷贝操作,下面就围绕cgo字符串传递过程分析问题的原因以及解决方案。
正文
v1.0实现
package main// #include <stdio.h>// #include <stdlib.h>//// void TestCString(char* s) {// printf(\"%s\\n\", s);// }import \"C\"import \"unsafe\"func main() {var s string = \"test cString\"cString := C.CString(s)C.TestCString(cString)C.free(unsafe.Pointer(cString))}
14:14 $ go run cstring.gotest cString
这是最一开始的实现方式。测试发现,当字符串s很大时候,整个进程的内存使用量是约等于两倍的字符串s的内存使用量,字符串s发生了一次内存拷贝。如果这个字符串只是想做读操作,并不想对其本身做修改,很明显这一次的拷贝是多余。
多余的拷贝操作很可能就是C.CString构造的时候,又重新发生了一次拷贝,看一下cgo中golang string转换c string的文档,其中有这么一段(line 254):
……A few special functions convert between Go and C typesby making copies of the data. In pseudo-Go definitions:// 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 stringfunc C.GoString(*C.char) string// C data with explicit length to Go stringfunc C.GoStringN(*C.char, C.int) string// C data with explicit length to Go []bytefunc C.GoBytes(unsafe.Pointer, C.int) []byte……
可以看到,确实在构造CString的时候发生了拷贝。(这里额外提一句,文档里说当使用了C.CString,需要用户自行调用C.free释放内存,否则会内存泄漏。)
如果我们不会对传入的字符串做修改操作,
- 如何避免这一次多余的拷贝呢?
- 是否能直接把字符串的首指针直接传递给c呢?
v1.1实现
golang string数据结构
先看一下go内置类型string的定义
// string is the set of all strings of 8-bit bytes, conventionally but not// necessarily representing UTF-8-encoded text. A string may be empty, but// not nil. Values of string type are immutable.type string string
其中,string对象的值不可修改,这一点很重要,后面会涉及到。
再看一下go内置类型string的数据结构
type stringStruct struct {str unsafe.Pointerlen int}
结构很简单,字符串首地址+字符串长度。
到这,既然golang string有字符串首地址,直接当参数通过cgo传给c,不在构造CString,那就可以解决多余拷贝问题了吧?看一下下面这个代码(先说一下下面这段代码是有问题的):
package main// #include <stdio.h>// #include <stdlib.h>//// void TestCString(char* s) {// printf(\"%s\\n\", s);// }import \"C\"func main() {var s string = \"test cString\"C.TestCString(s.str)}
按上面这么写是有问题的:是因为golang结构体中首字母没有大写,别的包是无法调用的,因为golang string本意就不想让用户直接对str、len进行访问,防止用户对其做修改导致一些不安全的问题。那如何获取golang string的字符串首地址呢?
最终代码
直接看这段代码:
package main// #include <stdio.h>// #include <stdlib.h>//// void TestCString(char* s, unsigned int l) {// unsigned int i=0;// for(;i<l;i++){// printf(\"%c\", *s);// s += 1;// }// printf(\"\\n\");// }import \"C\"import \"unsafe\"type MyString struct {Str *C.charLen int}func main() {var s string = \"test cString\"ms := (*MyString)(unsafe.Pointer(&s))C.TestCString(ms.Str, C.uint(ms.Len))}
我们通过定义一个和string结构体几乎一样的MyString结构体,且MyString的成员是可访问的。然后将golang string对象转换成MyString对象,然后就能通过访问MyString.Str拿到原来字符串的首地址,传递给c,既没有发生多余的拷贝操作,c中打印出来也能完全无误地得到传递过来的内容。
- golang其他一些数据类型和c的相互转换参考这里
注意
这边我们额外多传了一个字符串长度给c,为什么?先看下面这个不传长度的例子:
package main// #include <stdio.h>// #include <stdlib.h>//// void TestCString1(char* s) {// printf(\"%s\\n\", s);// }//// void TestCString2(char* s, unsigned int l) {// unsigned int i=0;// for(;i<l;i++){// printf(\"%c\", *s);// s += 1;// }// printf(\"\\n\");// }import \"C\"import \"unsafe\"type MyString struct {Str *C.charLen int}func test1() {var s string = \"test cString\"ms := (*MyString)(unsafe.Pointer(&s))C.TestCString2(ms.Str, C.uint(ms.Len))}func test2() {var s string = \"test cString\"ms := (*MyString)(unsafe.Pointer(&s))C.TestCString1(ms.Str)}func test3() {var s string = \"test c\\000String\"ms := (*MyString)(unsafe.Pointer(&s))C.TestCString1(ms.Str)}func main() {test1()test2()test3()}
- test1:正确的使用方式
- test2:因为c自动判断字符串结束的方式是以\”\\0\”为结尾的,如果不给它显式指明读多长,它会自动读到\”\\0\”前所有的字符出来。运气好一点会抛异常发现;运气不好,读出来\”test cString\”后面再拖一大堆未知字符,不可预期且非常危险。
- test3:道理同test2,这边test3会打印\”test c\”。如果传递的只是可见ascii字符串,又不想传长度过去,在原来的字符串后面加个\”\\0\”,第一种打印方式也是可以行得通的。但如果传递的内容是所有ascii字符都有可能出现的那种内容,记得再传递个长度过去。
参考
- https://github.com/golang/go/blob/master/src/runtime/string.go
- https://github.com/golang/go/blob/master/src/builtin/builtin.go
- https://github.com/golang/go/blob/master/src/cmd/cgo/doc.go
- https://zhuanlan.zhihu.com/p/73505526
- https://blog.csdn.net/weixin_36771703/article/details/89003014