前言
最近做了一些关于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.go
test cString
这是最一开始的实现方式。测试发现,当字符串s很大时候,整个进程的内存使用量是约等于两倍的字符串s的内存使用量,字符串s发生了一次内存拷贝。如果这个字符串只是想做读操作,并不想对其本身做修改,很明显这一次的拷贝是多余。
多余的拷贝操作很可能就是C.CString构造的时候,又重新发生了一次拷贝,看一下cgo中golang string转换c string的文档,其中有这么一段(line 254):
……
A few special functions convert between Go and C types
by 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 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
……
可以看到,确实在构造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.Pointer
len 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.char
Len 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.char
Len 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