摘要:C++調用Go方法時,字符串參數的內存管理需要由Go側進行深度值拷貝。
現象
在一個APP技術項目中,子進程按請求加載Go的ServiceModule,將需要拉起的ServiceModule信息傳遞給Go的Loader,存在C++調用Go方法,傳遞字符串的場景。
方案驗證時,發現有奇怪的將std::string對象的內容傳遞給Go方法后,在Go方法協程中取到的值與預期不一致。
經過一段時間的分析和驗證,終於理解問題產生的原因並給出解決方案,現分享如下。
背景知識
- Go有自己的內存回收GC機制,通過make等申請的內存不需要手動釋放。
- C++中為std::string變量賦值新字符串后,.c_str()和.size()的結果會聯動變化,尤其是.c_str()指向的地址也有可能變化。
- go build -buildmode=c-shared .生成的.h頭文件中定義了C++中Go的變量類型的定義映射關系,比如GoString、GoInt等。其中GoString實際是一個結構體,包含一個字符指針和一個字符長度。
原理及解釋
通過代碼示例方式解釋具體現象及原因,詳見注釋
C++側代碼:
// // Created by w00526151 on 2020/11/5. // #include <string> #include <iostream> #include <unistd.h> #include "libgoloader.h" /** * 構造GoString結構體對象 * @param p * @param n * @return */ GoString buildGoString(const char* p, size_t n){ //typedef struct { const char *p; ptrdiff_t n; } _GoString_; //typedef _GoString_ GoString; return {p, static_cast<ptrdiff_t>(n)}; } int main(){ std::cout<<"test send string to go in C++"<<std::endl; std::string tmpStr = "/tmp/udsgateway-netconftemplateservice"; printf("in C++ tmpStr: %p, tmpStr: %s, tmpStr.size:%lu \r\n", tmpStr.c_str(), tmpStr.c_str(), tmpStr.size()); { //通過new新申請一段內存做字符串拷貝 char *newStrPtr = NULL; int newStrSize = tmpStr.size(); newStrPtr = new char[newStrSize]; tmpStr.copy(newStrPtr, newStrSize, 0); //調用Go方法,第一個參數直接傳std::string的c_str指針和大小,第二個參數傳在C++中單獨申請的內存並拷貝的字符串指針,第三個參數和第一個一樣,但是在go代碼中做內存拷貝保存。 //調用Go方法后,通過賦值修改std::string的值內容,等待Go中新起的線程10s后再將三個參數值打印出來。 LoadModule(buildGoString(tmpStr.c_str(), tmpStr.size()), buildGoString(newStrPtr, newStrSize), buildGoString(tmpStr.c_str(),tmpStr.size())); //修改tmpStr的值,tmpStr.c_str()得到的指針指向內容會變化,tmpStr.size()的值也會變化,Go中第一個參數也會受到影響,前幾位會變成新字符串內容。 //由於在Go中int是值拷貝,所以在Go中,第一個參數的長度沒有變化,因此實際在Go中已經出現內存越界訪問,可能產生Coredump。 tmpStr = "new string"; printf("in C++ change tmpStr and delete newStrPtr, new tmpStr: %p, tmpStr: %s, tmpStr.size:%lu \r\n", tmpStr.c_str(), tmpStr.c_str(), tmpStr.size()); //釋放新申請的newStrPtr指針,Go中對應第二個string變量內存也會受到影響,產生亂碼。 // 實際在Go中,已經在訪問一段在C++中已經釋放的內存,屬於野指針訪問,可能產生Coredump。 delete newStrPtr; } pause(); }
Go側代碼:
package main import "C" import ( "fmt" "time" ) func printInGo(p0 string, p1 string, p2 string){ time.Sleep(10 * time.Second) fmt.Printf("in go function, p0:%s size %d, p1:%s size %d, p2:%s size %d", p0, len(p0), p1, len(p1), p2, len(p2)) } //export LoadModule func LoadModule(name string, version string, location string) int { //通過make的方式,新構建一段內存來存放從C++處傳入的字符串,深度拷貝防止C++中修改影響Go tmp3rdParam := make([]byte, len(location)) copy(tmp3rdParam, location) new3rdParam := string(tmp3rdParam) fmt.Println("in go loadModule,first param is",name,"second param is",version, "third param is", new3rdParam) go printInGo(name, version, new3rdParam); return 0 }
Go側代碼通過-buildmode=c-shared的方式生成libgoloader.so及libgoloader.h供C++編譯運行使用
go build -o libgoloader.so -buildmode=c-shared .
程序執行結果:
test send string to go in C++ in C++ tmpStr: 0x7fffe1fb93f0, tmpStr: /tmp/udsgateway-netconftemplateservice, tmpStr.size:38 # 將C++的指針傳給Go,一開始打印都是OK的 in go loadModule,first param is /tmp/udsgateway-netconftemplateservice second param is /tmp/udsgateway-netconftemplateservice third param is /tmp/udsgateway-netconftemplateservice # 在C++中,將指針指向的內容修改,或者刪掉指針 in C++ change tmpStr and delete newStrPtr, new tmpStr: 0x7fffe1fb93f0, tmpStr: new string, tmpStr.size:10 # 在Go中,參數1、參數2對應的Go string變量都受到了影響,參數3由於做了深度拷貝,沒有受到影響。 in go function, p0:new string eway-netconftemplateservice size 38, p1: p��� netconftemplateservice size 38, p2:/tmp/udsgateway-netconftemplateservice size 38
結論
- 結論:C++調用Go方法時,字符串參數的內存管理需要由Go側進行深度值拷貝。即參數三的處理方式
- 原因:傳入的字符串GoString,實際是一個結構體,第一個成員p是一個char*指針,第二個成員n是一個int長度。
在C++代碼中,任何對成員p的char*指針的操作,都將直接影響到Go中的string對象的值。
只有通過單獨的內存空間開辟,進行獨立內存管理,才可以避免C++中的指針操作對Go的影響。
ps:不在C++中進行內存申請釋放的原因是C++無法感知Go中何時才能真的已經沒有對象引用,無法找到合適的時間點進行內存釋放。
本文分享自華為雲社區《C++調用Go方法的字符串傳遞問題及解決方案》,原文作者:王芾。