Go 語(yǔ)言 CGO 內(nèi)存模型

2023-03-22 15:00 更新

原文鏈接:https://chai2010.cn/advanced-go-programming-book/ch2-cgo/ch2-07-memory.html


2.7 CGO 內(nèi)存模型

CGO 是架接 Go 語(yǔ)言和 C 語(yǔ)言的橋梁,它使二者在二進(jìn)制接口層面實(shí)現(xiàn)了互通,但是我們要注意因兩種語(yǔ)言的內(nèi)存模型的差異而可能引起的問題。如果在 CGO 處理的跨語(yǔ)言函數(shù)調(diào)用時(shí)涉及到了指針的傳遞,則可能會(huì)出現(xiàn) Go 語(yǔ)言和 C 語(yǔ)言共享某一段內(nèi)存的場(chǎng)景。我們知道 C 語(yǔ)言的內(nèi)存在分配之后就是穩(wěn)定的,但是 Go 語(yǔ)言因?yàn)楹瘮?shù)棧的動(dòng)態(tài)伸縮可能導(dǎo)致棧中內(nèi)存地址的移動(dòng) (這是 Go 和 C 內(nèi)存模型的最大差異)。如果 C 語(yǔ)言持有的是移動(dòng)之前的 Go 指針,那么以舊指針訪問 Go 對(duì)象時(shí)會(huì)導(dǎo)致程序崩潰。

2.7.1 Go 訪問 C 內(nèi)存

C 語(yǔ)言空間的內(nèi)存是穩(wěn)定的,只要不是被人為提前釋放,那么在 Go 語(yǔ)言空間可以放心大膽地使用。在 Go 語(yǔ)言訪問 C 語(yǔ)言內(nèi)存是最簡(jiǎn)單的情形,我們?cè)谥暗睦又幸呀?jīng)見過多次。

因?yàn)?Go 語(yǔ)言實(shí)現(xiàn)的限制,我們無(wú)法在 Go 語(yǔ)言中創(chuàng)建大于 2GB 內(nèi)存的切片(具體請(qǐng)參考 makeslice 實(shí)現(xiàn)代碼)。不過借助 cgo 技術(shù),我們可以在 C 語(yǔ)言環(huán)境創(chuàng)建大于 2GB 的內(nèi)存,然后轉(zhuǎn)為 Go 語(yǔ)言的切片使用:

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 來(lái)創(chuàng)建大于 4G 內(nèi)存大小的切片,從而繞過了 Go 語(yǔ)言實(shí)現(xiàn)的限制(需要代碼驗(yàn)證)。而 freeByteSlice 輔助函數(shù)則用于釋放從 C 語(yǔ)言函數(shù)創(chuàng)建的切片。

因?yàn)?C 語(yǔ)言內(nèi)存空間是穩(wěn)定的,基于 C 語(yǔ)言內(nèi)存構(gòu)造的切片也是絕對(duì)穩(wěn)定的,不會(huì)因?yàn)?Go 語(yǔ)言棧的變化而被移動(dòng)。

2.7.2 C 臨時(shí)訪問傳入的 Go 內(nèi)存

cgo 之所以存在的一大因素是為了方便在 Go 語(yǔ)言中接納吸收過去幾十年來(lái)使用 C/C++ 語(yǔ)言軟件構(gòu)建的大量的軟件資源。C/C++ 很多庫(kù)都是需要通過指針直接處理傳入的內(nèi)存數(shù)據(jù)的,因此 cgo 中也有很多需要將 Go 內(nèi)存?zhèn)魅?C 語(yǔ)言函數(shù)的應(yīng)用場(chǎng)景。

假設(shè)一個(gè)極端場(chǎng)景:我們將一塊位于某 goroutine 的棧上的 Go 語(yǔ)言內(nèi)存?zhèn)魅肓?C 語(yǔ)言函數(shù)后,在此 C 語(yǔ)言函數(shù)執(zhí)行期間,此 goroutinue 的棧因?yàn)榭臻g不足的原因發(fā)生了擴(kuò)展,也就是導(dǎo)致了原來(lái)的 Go 語(yǔ)言內(nèi)存被移動(dòng)到了新的位置。但是此時(shí)此刻 C 語(yǔ)言函數(shù)并不知道該 Go 語(yǔ)言內(nèi)存已經(jīng)移動(dòng)了位置,仍然用之前的地址來(lái)操作該內(nèi)存——這將將導(dǎo)致內(nèi)存越界。以上是一個(gè)推論(真實(shí)情況有些差異),也就是說(shuō) C 訪問傳入的 Go 內(nèi)存可能是不安全的!

當(dāng)然有 RPC 遠(yuǎn)程過程調(diào)用的經(jīng)驗(yàn)的用戶可能會(huì)考慮通過完全傳值的方式處理:借助 C 語(yǔ)言內(nèi)存穩(wěn)定的特性,在 C 語(yǔ)言空間先開辟同樣大小的內(nèi)存,然后將 Go 的內(nèi)存填充到 C 的內(nèi)存空間;返回的內(nèi)存也是如此處理。下面的例子是這種思路的具體實(shí)現(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 語(yǔ)言時(shí),先通過 C.CString 將 Go 語(yǔ)言字符串對(duì)應(yīng)的內(nèi)存數(shù)據(jù)復(fù)制到新創(chuàng)建的 C 語(yǔ)言內(nèi)存空間上。上面例子的處理思路雖然是安全的,但是效率極其低下(因?yàn)橐啻畏峙鋬?nèi)存并逐個(gè)復(fù)制元素),同時(shí)也極其繁瑣。

為了簡(jiǎn)化并高效處理此種向 C 語(yǔ)言傳入 Go 語(yǔ)言內(nèi)存的問題,cgo 針對(duì)該場(chǎng)景定義了專門的規(guī)則:在 CGO 調(diào)用的 C 語(yǔ)言函數(shù)返回前,cgo 保證傳入的 Go 語(yǔ)言內(nèi)存在此期間不會(huì)發(fā)生移動(dòng),C 語(yǔ)言函數(shù)可以大膽地使用 Go 語(yǔ)言的內(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ù)都有被濫用的時(shí)候,CGO 的這種看似完美的規(guī)則也是存在隱患的。我們假設(shè)調(diào)用的 C 語(yǔ)言函數(shù)需要長(zhǎng)時(shí)間運(yùn)行,那么將會(huì)導(dǎo)致被他引用的 Go 語(yǔ)言內(nèi)存在 C 語(yǔ)言返回前不能被移動(dòng),從而可能間接地導(dǎo)致這個(gè) Go 內(nèi)存棧對(duì)應(yīng)的 goroutine 不能動(dòng)態(tài)伸縮棧內(nèi)存,也就是可能導(dǎo)致這個(gè) goroutine 被阻塞。因此,在需要長(zhǎng)時(shí)間運(yùn)行的 C 語(yǔ)言函數(shù)(特別是在純 CPU 運(yùn)算之外,還可能因?yàn)樾枰却渌馁Y源而需要不確定時(shí)間才能完成的函數(shù)),需要謹(jǐn)慎處理傳入的 Go 語(yǔ)言內(nèi)存。

不過需要小心的是在取得 Go 內(nèi)存后需要馬上傳入 C 語(yǔ)言函數(shù),不能保存到臨時(shí)變量后再間接傳入 C 語(yǔ)言函數(shù)。因?yàn)?CGO 只能保證在 C 函數(shù)調(diào)用之后被傳入的 Go 語(yǔ)言內(nèi)存不會(huì)發(fā)生移動(dòng),它并不能保證在傳入 C 函數(shù)之前內(nèi)存不發(fā)生變化。

以下代碼是錯(cuò)誤的:

// 錯(cuò)誤的代碼
tmp := uintptr(unsafe.Pointer(&x))
pb := (*int16)(unsafe.Pointer(tmp))
*pb = 42

因?yàn)?tmp 并不是指針類型,在它獲取到 Go 對(duì)象地址之后 x 對(duì)象可能會(huì)被移動(dòng),但是因?yàn)椴皇侵羔橆愋?,所以不?huì)被 Go 語(yǔ)言運(yùn)行時(shí)更新成新內(nèi)存的地址。在非指針類型的 tmp 保持 Go 對(duì)象的地址,和在 C 語(yǔ)言環(huán)境保持 Go 對(duì)象的地址的效果是一樣的:如果原始的 Go 對(duì)象內(nèi)存發(fā)生了移動(dòng),Go 語(yǔ)言運(yùn)行時(shí)并不會(huì)同步更新它們。

2.7.3 C 長(zhǎng)期持有 Go 指針對(duì)象

作為一個(gè) Go 程序員在使用 CGO 時(shí)潛意識(shí)會(huì)認(rèn)為總是 Go 調(diào)用 C 函數(shù)。其實(shí) CGO 中,C 語(yǔ)言函數(shù)也可以回調(diào) Go 語(yǔ)言實(shí)現(xiàn)的函數(shù)。特別是我們可以用 Go 語(yǔ)言寫一個(gè)動(dòng)態(tài)庫(kù),導(dǎo)出 C 語(yǔ)言規(guī)范的接口給其它用戶調(diào)用。當(dāng) C 語(yǔ)言函數(shù)調(diào)用 Go 語(yǔ)言函數(shù)的時(shí)候,C 語(yǔ)言函數(shù)就成了程序的調(diào)用方,Go 語(yǔ)言函數(shù)返回的 Go 對(duì)象內(nèi)存的生命周期也就自然超出了 Go 語(yǔ)言運(yùn)行時(shí)的管理。簡(jiǎn)言之,我們不能在 C 語(yǔ)言函數(shù)中直接使用 Go 語(yǔ)言對(duì)象的內(nèi)存。

雖然 Go 語(yǔ)言禁止在 C 語(yǔ)言函數(shù)中長(zhǎng)期持有 Go 指針對(duì)象,但是這種需求是切實(shí)存在的。如果需要在 C 語(yǔ)言中訪問 Go 語(yǔ)言內(nèi)存對(duì)象,我們可以將 Go 語(yǔ)言內(nèi)存對(duì)象在 Go 語(yǔ)言空間映射為一個(gè) int 類型的 id,然后通過此 id 來(lái)間接訪問和控制 Go 語(yǔ)言對(duì)象。

以下代碼用于將 Go 對(duì)象映射為整數(shù)類型的 ObjectId,用完之后需要手工調(diào)用 free 方法釋放該對(duì)象 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
}

我們通過一個(gè) map 來(lái)管理 Go 語(yǔ)言對(duì)象和 id 對(duì)象的映射關(guān)系。其中 NewObjectId 用于創(chuàng)建一個(gè)和對(duì)象綁定的 id,而 id 對(duì)象的方法可用于解碼出原始的 Go 對(duì)象,也可以用于結(jié)束 id 和原始 Go 對(duì)象的綁定。

下面一組函數(shù)以 C 接口規(guī)范導(dǎo)出,可以被 C 語(yǔ)言函數(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)建一個(gè)對(duì)應(yīng)的 Go 字符串對(duì)象,返回的其實(shí)是一個(gè) id,不能直接使用。我們借助 PrintGoString 函數(shù)將 id 解析為 Go 語(yǔ)言字符串后打印。該字符串在 C 語(yǔ)言函數(shù)中完全跨越了 Go 語(yǔ)言的內(nèi)存管理,在 PrintGoString 調(diào)用前即使發(fā)生了棧伸縮導(dǎo)致的 Go 字符串地址發(fā)生變化也依然可以正常工作,因?yàn)樵撟址畬?duì)應(yīng)的 id 是穩(wěn)定的,在 Go 語(yǔ)言空間通過 id 解碼得到的字符串也就是有效的。

2.7.4 導(dǎo)出 C 函數(shù)不能返回 Go 內(nèi)存

在 Go 語(yǔ)言中,Go 是從一個(gè)固定的虛擬地址空間分配內(nèi)存。而 C 語(yǔ)言分配的內(nèi)存則不能使用 Go 語(yǔ)言保留的虛擬內(nèi)存空間。在 CGO 環(huán)境,Go 語(yǔ)言運(yùn)行時(shí)默認(rèn)會(huì)檢查導(dǎo)出返回的內(nèi)存是否是由 Go 語(yǔ)言分配的,如果是則會(huì)拋出運(yùn)行時(shí)異常。

下面是 CGO 運(yùn)行時(shí)異常的例子:

/*
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 語(yǔ)言類型的指針,但是內(nèi)存本身是從 Go 語(yǔ)言的 new 函數(shù)分配,也就是由 Go 語(yǔ)言運(yùn)行時(shí)統(tǒng)一管理的內(nèi)存。然后我們?cè)?C 語(yǔ)言的 Main 函數(shù)中調(diào)用了 getGoPtr 函數(shù),此時(shí)默認(rèn)將發(fā)送運(yùn)行時(shí)異常:

$ 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

異常說(shuō)明 cgo 函數(shù)返回的結(jié)果中含有 Go 語(yǔ)言分配的指針。指針的檢查操作發(fā)生在 C 語(yǔ)言版的 getGoPtr 函數(shù)中,它是由 cgo 生成的橋接 C 語(yǔ)言和 Go 語(yǔ)言的函數(shù)。

下面是 cgo 生成的 C 語(yǔ)言版本 getGoPtr 函數(shù)的具體細(xì)節(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 項(xiàng)目移植過來(lái)的內(nèi)存指針掃描函數(shù),它會(huì)檢查 cgo 函數(shù)返回的結(jié)果是否包含 Go 指針。

需要說(shuō)明的是,cgo 默認(rèn)對(duì)返回結(jié)果的指針的檢查是有代價(jià)的,特別是 cgo 函數(shù)返回的結(jié)果是一個(gè)復(fù)雜的數(shù)據(jù)結(jié)構(gòu)時(shí)將花費(fèi)更多的時(shí)間。如果已經(jīng)確保了 cgo 函數(shù)返回的結(jié)果是安全的話,可以通過設(shè)置環(huán)境變量 GODEBUG=cgocheck=0 來(lái)關(guān)閉指針檢查行為。

$ GODEBUG=cgocheck=0 go run main.go

關(guān)閉 cgocheck 功能后再運(yùn)行上面的代碼就不會(huì)出現(xiàn)上面的異常的。但是要注意的是,如果 C 語(yǔ)言使用期間對(duì)應(yīng)的內(nèi)存被 Go 運(yùn)行時(shí)釋放了,將會(huì)導(dǎo)致更嚴(yán)重的崩潰問題。cgocheck 默認(rèn)的值是 1,對(duì)應(yīng)一個(gè)簡(jiǎn)化版本的檢測(cè),如果需要完整的檢測(cè)功能可以將 cgocheck 設(shè)置為 2。

關(guān)于 cgo 運(yùn)行時(shí)指針檢測(cè)的功能詳細(xì)說(shuō)明可以參考 Go 語(yǔ)言的官方文檔。



以上內(nèi)容是否對(duì)您有幫助:
在線筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)