原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch2-cgo/ch2-07-memory.html
CGO 是架接 Go 語言和 C 語言的橋梁,它使二者在二進制接口層面實現(xiàn)了互通,但是我們要注意因兩種語言的內(nèi)存模型的差異而可能引起的問題。如果在 CGO 處理的跨語言函數(shù)調(diào)用時涉及到了指針的傳遞,則可能會出現(xiàn) Go 語言和 C 語言共享某一段內(nèi)存的場景。我們知道 C 語言的內(nèi)存在分配之后就是穩(wěn)定的,但是 Go 語言因為函數(shù)棧的動態(tài)伸縮可能導致棧中內(nèi)存地址的移動 (這是 Go 和 C 內(nèi)存模型的最大差異)。如果 C 語言持有的是移動之前的 Go 指針,那么以舊指針訪問 Go 對象時會導致程序崩潰。
C 語言空間的內(nèi)存是穩(wěn)定的,只要不是被人為提前釋放,那么在 Go 語言空間可以放心大膽地使用。在 Go 語言訪問 C 語言內(nèi)存是最簡單的情形,我們在之前的例子中已經(jīng)見過多次。
因為 Go 語言實現(xiàn)的限制,我們無法在 Go 語言中創(chuàng)建大于 2GB 內(nèi)存的切片(具體請參考 makeslice 實現(xiàn)代碼)。不過借助 cgo 技術(shù),我們可以在 C 語言環(huán)境創(chuàng)建大于 2GB 的內(nèi)存,然后轉(zhuǎn)為 Go 語言的切片使用:
package main
/*
#include <stdlib.h>
void* makeslice(size_t memsize) {
return malloc(memsize);
}
*/
import "C"
import "unsafe"
func makeByteSlice(n int) []byte {
p := C.makeslice(C.size_t(n))
return ((*[1 << 31]byte)(p))[0:n:n]
}
func freeByteSlice(p []byte) {
C.free(unsafe.Pointer(&p[0]))
}
func main() {
s := makeByteSlice(1<<32+1)
s[len(s)-1] = 255
print(s[len(s)-1])
freeByteSlice(s)
}
例子中我們通過 makeByteSlice 來創(chuàng)建大于 4G 內(nèi)存大小的切片,從而繞過了 Go 語言實現(xiàn)的限制(需要代碼驗證)。而 freeByteSlice 輔助函數(shù)則用于釋放從 C 語言函數(shù)創(chuàng)建的切片。
因為 C 語言內(nèi)存空間是穩(wěn)定的,基于 C 語言內(nèi)存構(gòu)造的切片也是絕對穩(wěn)定的,不會因為 Go 語言棧的變化而被移動。
cgo 之所以存在的一大因素是為了方便在 Go 語言中接納吸收過去幾十年來使用 C/C++ 語言軟件構(gòu)建的大量的軟件資源。C/C++ 很多庫都是需要通過指針直接處理傳入的內(nèi)存數(shù)據(jù)的,因此 cgo 中也有很多需要將 Go 內(nèi)存?zhèn)魅?C 語言函數(shù)的應(yīng)用場景。
假設(shè)一個極端場景:我們將一塊位于某 goroutine 的棧上的 Go 語言內(nèi)存?zhèn)魅肓?C 語言函數(shù)后,在此 C 語言函數(shù)執(zhí)行期間,此 goroutinue 的棧因為空間不足的原因發(fā)生了擴展,也就是導致了原來的 Go 語言內(nèi)存被移動到了新的位置。但是此時此刻 C 語言函數(shù)并不知道該 Go 語言內(nèi)存已經(jīng)移動了位置,仍然用之前的地址來操作該內(nèi)存——這將將導致內(nèi)存越界。以上是一個推論(真實情況有些差異),也就是說 C 訪問傳入的 Go 內(nèi)存可能是不安全的!
當然有 RPC 遠程過程調(diào)用的經(jīng)驗的用戶可能會考慮通過完全傳值的方式處理:借助 C 語言內(nèi)存穩(wěn)定的特性,在 C 語言空間先開辟同樣大小的內(nèi)存,然后將 Go 的內(nèi)存填充到 C 的內(nèi)存空間;返回的內(nèi)存也是如此處理。下面的例子是這種思路的具體實現(xiàn):
package main
/*
#include <stdlib.h>
#include <stdio.h>
void printString(const char* s) {
printf("%s", s);
}
*/
import "C"
import "unsafe"
func printString(s string) {
cs := C.CString(s)
defer C.free(unsafe.Pointer(cs))
C.printString(cs)
}
func main() {
s := "hello"
printString(s)
}
在需要將 Go 的字符串傳入 C 語言時,先通過 C.CString
將 Go 語言字符串對應(yīng)的內(nèi)存數(shù)據(jù)復制到新創(chuàng)建的 C 語言內(nèi)存空間上。上面例子的處理思路雖然是安全的,但是效率極其低下(因為要多次分配內(nèi)存并逐個復制元素),同時也極其繁瑣。
為了簡化并高效處理此種向 C 語言傳入 Go 語言內(nèi)存的問題,cgo 針對該場景定義了專門的規(guī)則:在 CGO 調(diào)用的 C 語言函數(shù)返回前,cgo 保證傳入的 Go 語言內(nèi)存在此期間不會發(fā)生移動,C 語言函數(shù)可以大膽地使用 Go 語言的內(nèi)存!
根據(jù)新的規(guī)則我們可以直接傳入 Go 字符串的內(nèi)存:
package main
/*
#include<stdio.h>
void printString(const char* s, int n) {
int i;
for(i = 0; i < n; i++) {
putchar(s[i]);
}
putchar('\n');
}
*/
import "C"
func printString(s string) {
p := (*reflect.StringHeader)(unsafe.Pointer(&s))
C.printString((*C.char)(unsafe.Pointer(p.Data)), C.int(len(s)))
}
func main() {
s := "hello"
printString(s)
}
現(xiàn)在的處理方式更加直接,且避免了分配額外的內(nèi)存。完美的解決方案!
任何完美的技術(shù)都有被濫用的時候,CGO 的這種看似完美的規(guī)則也是存在隱患的。我們假設(shè)調(diào)用的 C 語言函數(shù)需要長時間運行,那么將會導致被他引用的 Go 語言內(nèi)存在 C 語言返回前不能被移動,從而可能間接地導致這個 Go 內(nèi)存棧對應(yīng)的 goroutine 不能動態(tài)伸縮棧內(nèi)存,也就是可能導致這個 goroutine 被阻塞。因此,在需要長時間運行的 C 語言函數(shù)(特別是在純 CPU 運算之外,還可能因為需要等待其它的資源而需要不確定時間才能完成的函數(shù)),需要謹慎處理傳入的 Go 語言內(nèi)存。
不過需要小心的是在取得 Go 內(nèi)存后需要馬上傳入 C 語言函數(shù),不能保存到臨時變量后再間接傳入 C 語言函數(shù)。因為 CGO 只能保證在 C 函數(shù)調(diào)用之后被傳入的 Go 語言內(nèi)存不會發(fā)生移動,它并不能保證在傳入 C 函數(shù)之前內(nèi)存不發(fā)生變化。
以下代碼是錯誤的:
// 錯誤的代碼
tmp := uintptr(unsafe.Pointer(&x))
pb := (*int16)(unsafe.Pointer(tmp))
*pb = 42
因為 tmp 并不是指針類型,在它獲取到 Go 對象地址之后 x 對象可能會被移動,但是因為不是指針類型,所以不會被 Go 語言運行時更新成新內(nèi)存的地址。在非指針類型的 tmp 保持 Go 對象的地址,和在 C 語言環(huán)境保持 Go 對象的地址的效果是一樣的:如果原始的 Go 對象內(nèi)存發(fā)生了移動,Go 語言運行時并不會同步更新它們。
作為一個 Go 程序員在使用 CGO 時潛意識會認為總是 Go 調(diào)用 C 函數(shù)。其實 CGO 中,C 語言函數(shù)也可以回調(diào) Go 語言實現(xiàn)的函數(shù)。特別是我們可以用 Go 語言寫一個動態(tài)庫,導出 C 語言規(guī)范的接口給其它用戶調(diào)用。當 C 語言函數(shù)調(diào)用 Go 語言函數(shù)的時候,C 語言函數(shù)就成了程序的調(diào)用方,Go 語言函數(shù)返回的 Go 對象內(nèi)存的生命周期也就自然超出了 Go 語言運行時的管理。簡言之,我們不能在 C 語言函數(shù)中直接使用 Go 語言對象的內(nèi)存。
雖然 Go 語言禁止在 C 語言函數(shù)中長期持有 Go 指針對象,但是這種需求是切實存在的。如果需要在 C 語言中訪問 Go 語言內(nèi)存對象,我們可以將 Go 語言內(nèi)存對象在 Go 語言空間映射為一個 int 類型的 id,然后通過此 id 來間接訪問和控制 Go 語言對象。
以下代碼用于將 Go 對象映射為整數(shù)類型的 ObjectId,用完之后需要手工調(diào)用 free 方法釋放該對象 ID:
package main
import "sync"
type ObjectId int32
var refs struct {
sync.Mutex
objs map[ObjectId]interface{}
next ObjectId
}
func init() {
refs.Lock()
defer refs.Unlock()
refs.objs = make(map[ObjectId]interface{})
refs.next = 1000
}
func NewObjectId(obj interface{}) ObjectId {
refs.Lock()
defer refs.Unlock()
id := refs.next
refs.next++
refs.objs[id] = obj
return id
}
func (id ObjectId) IsNil() bool {
return id == 0
}
func (id ObjectId) Get() interface{} {
refs.Lock()
defer refs.Unlock()
return refs.objs[id]
}
func (id *ObjectId) Free() interface{} {
refs.Lock()
defer refs.Unlock()
obj := refs.objs[*id]
delete(refs.objs, *id)
*id = 0
return obj
}
我們通過一個 map 來管理 Go 語言對象和 id 對象的映射關(guān)系。其中 NewObjectId 用于創(chuàng)建一個和對象綁定的 id,而 id 對象的方法可用于解碼出原始的 Go 對象,也可以用于結(jié)束 id 和原始 Go 對象的綁定。
下面一組函數(shù)以 C 接口規(guī)范導出,可以被 C 語言函數(shù)調(diào)用:
package main
/*
extern char* NewGoString(char*);
extern void FreeGoString(char*);
extern void PrintGoString(char*);
static void printString(const char* s) {
char* gs = NewGoString(s);
PrintGoString(gs);
FreeGoString(gs);
}
*/
import "C"
//export NewGoString
func NewGoString(s *C.char) *C.char {
gs := C.GoString(s)
id := NewObjectId(gs)
return (*C.char)(unsafe.Pointer(uintptr(id)))
}
//export FreeGoString
func FreeGoString(p *C.char) {
id := ObjectId(uintptr(unsafe.Pointer(p)))
id.Free()
}
//export PrintGoString
func PrintGoString(s *C.char) {
id := ObjectId(uintptr(unsafe.Pointer(p)))
gs := id.Get().(string)
print(gs)
}
func main() {
C.printString("hello")
}
在 printString 函數(shù)中,我們通過 NewGoString 創(chuàng)建一個對應(yīng)的 Go 字符串對象,返回的其實是一個 id,不能直接使用。我們借助 PrintGoString 函數(shù)將 id 解析為 Go 語言字符串后打印。該字符串在 C 語言函數(shù)中完全跨越了 Go 語言的內(nèi)存管理,在 PrintGoString 調(diào)用前即使發(fā)生了棧伸縮導致的 Go 字符串地址發(fā)生變化也依然可以正常工作,因為該字符串對應(yīng)的 id 是穩(wěn)定的,在 Go 語言空間通過 id 解碼得到的字符串也就是有效的。
在 Go 語言中,Go 是從一個固定的虛擬地址空間分配內(nèi)存。而 C 語言分配的內(nèi)存則不能使用 Go 語言保留的虛擬內(nèi)存空間。在 CGO 環(huán)境,Go 語言運行時默認會檢查導出返回的內(nèi)存是否是由 Go 語言分配的,如果是則會拋出運行時異常。
下面是 CGO 運行時異常的例子:
/*
extern int* getGoPtr();
static void Main() {
int* p = getGoPtr();
*p = 42;
}
*/
import "C"
func main() {
C.Main()
}
//export getGoPtr
func getGoPtr() *C.int {
return new(C.int)
}
其中 getGoPtr 返回的雖然是 C 語言類型的指針,但是內(nèi)存本身是從 Go 語言的 new 函數(shù)分配,也就是由 Go 語言運行時統(tǒng)一管理的內(nèi)存。然后我們在 C 語言的 Main 函數(shù)中調(diào)用了 getGoPtr 函數(shù),此時默認將發(fā)送運行時異常:
$ go run main.go
panic: runtime error: cgo result has Go pointer
goroutine 1 [running]:
main._cgoexpwrap_cfb3840e3af2_getGoPtr.func1(0xc420051dc0)
command-line-arguments/_obj/_cgo_gotypes.go:60 +0x3a
main._cgoexpwrap_cfb3840e3af2_getGoPtr(0xc420016078)
command-line-arguments/_obj/_cgo_gotypes.go:62 +0x67
main._Cfunc_Main()
command-line-arguments/_obj/_cgo_gotypes.go:43 +0x41
main.main()
/Users/chai/go/src/github.com/chai2010 \
/advanced-go-programming-book/examples/ch2-xx \
/return-go-ptr/main.go:17 +0x20
exit status 2
異常說明 cgo 函數(shù)返回的結(jié)果中含有 Go 語言分配的指針。指針的檢查操作發(fā)生在 C 語言版的 getGoPtr 函數(shù)中,它是由 cgo 生成的橋接 C 語言和 Go 語言的函數(shù)。
下面是 cgo 生成的 C 語言版本 getGoPtr 函數(shù)的具體細節(jié)(在 cgo 生成的 _cgo_export.c
文件定義):
int* getGoPtr()
{
__SIZE_TYPE__ _cgo_ctxt = _cgo_wait_runtime_init_done();
struct {
int* r0;
} __attribute__((__packed__)) a;
_cgo_tsan_release();
crosscall2(_cgoexp_95d42b8e6230_getGoPtr, &a, 8, _cgo_ctxt);
_cgo_tsan_acquire();
_cgo_release_context(_cgo_ctxt);
return a.r0;
}
其中 _cgo_tsan_acquire
是從 LLVM 項目移植過來的內(nèi)存指針掃描函數(shù),它會檢查 cgo 函數(shù)返回的結(jié)果是否包含 Go 指針。
需要說明的是,cgo 默認對返回結(jié)果的指針的檢查是有代價的,特別是 cgo 函數(shù)返回的結(jié)果是一個復雜的數(shù)據(jù)結(jié)構(gòu)時將花費更多的時間。如果已經(jīng)確保了 cgo 函數(shù)返回的結(jié)果是安全的話,可以通過設(shè)置環(huán)境變量 GODEBUG=cgocheck=0
來關(guān)閉指針檢查行為。
$ GODEBUG=cgocheck=0 go run main.go
關(guān)閉 cgocheck 功能后再運行上面的代碼就不會出現(xiàn)上面的異常的。但是要注意的是,如果 C 語言使用期間對應(yīng)的內(nèi)存被 Go 運行時釋放了,將會導致更嚴重的崩潰問題。cgocheck 默認的值是 1,對應(yīng)一個簡化版本的檢測,如果需要完整的檢測功能可以將 cgocheck 設(shè)置為 2。
關(guān)于 cgo 運行時指針檢測的功能詳細說明可以參考 Go 語言的官方文檔。
![]() | ![]() |
更多建議: