傳遞回調函數和指針到 Cgo

amzking · · 206 次點擊 · · 開始瀏覽    
`Cgo`允許 Go 程序調用 C 庫或其他暴露了 C 接口的庫。正是如此,這也成為 Go 程序員工具箱的重要組成部分。 使用`Cgo`可能會比較棘手,特別是在 Go 和 C 代碼中傳遞指針和回調函數時。 這篇文章討論了一個端到端當例子,包含了如下幾方面: * `Cgo`的基本使用,包括鏈接一個傳統的 C 庫到 Go 二進制文件中。 * 從 Go 語言中傳遞 struct 到 C 語言中。 * 傳遞 Go 函數到 C 程序中,并安排 C 程序在隨后調用它們。 * 安全的傳遞任意的 Go 數據到 C 代碼中,這些 C 代碼后續會回傳這些數據到它所調用的 Go 回調中。 本文并不是一個`Cgo`的使用教程-在閱讀前,需要你對它對簡單使用案例有所熟悉。 在本文最后列了一些有用的`Cgo`使用教程和相關的文章。這個案例的全部源代碼詳見[Github](https://github.com/eliben/code-for-blog/tree/master/2019/cgo-callback)。 ## 問題所在-一個C庫調用多個Go回調程序 如下是一個虛構的C庫的頭文件,該庫處理(輸入)數據,并基于事件調用回調函數。 ```c typedef void (*StartCallbackFn)(void* user_data, int i); typedef void (*EndCallbackFn)(void* user_data, int a, int b); typedef struct { StartCallbackFn start; EndCallbackFn end; } Callbacks; // Processes the file and invokes callbacks from cbs on events found in the // file, each with its own relevant data. user_data is passed through to the // callbacks. void traverse(char* filename, Callbacks cbs, void* user_data); ``` 回調標簽是由幾個重要的模式組成,所展示的這些模式在現實中也同樣普遍: * 每一個回調擁有自己的類型簽名,這里為了簡便,我們使用`int`類型的參數,這個參數可以是其他任何類型。 * 當只有較小數量的回調被調用時,它們可能作為獨立的參數被傳遞到 traverse 中;然而,回調的數量非常大時(比如說,超過三個),后幾乎總是有一個匯集它們的結構體被傳遞。 允許用戶將某些回調參數設置為 null 很常見,以向底層庫傳達:對于某些特定事件并沒有意義,也不應為此調用任何用戶代碼。 * 每個回調都獲得一個不透明指針 user_data,該指針從調用者傳遞到 traverse (最終傳遞到回調函數)。它用于區分互不相同的遍歷,并傳遞用戶特定的狀態。 典型的,traverse 會透傳 user_data,而不嘗試訪問他; 由于它是`void *`,因此它對于庫是完全模糊的, 并且用戶代碼會將其強制轉換為回調中的某些具體類型。 我們對 traverse 的實現僅是一個簡單的模擬: ```c void traverse(char* filename, Callbacks cbs, void* user_data) { // 模擬某些遍歷,調用 start 回調,之后調用 end 回調 // callback, if they are defined. if (cbs.start != NULL) { cbs.start(user_data, 100); } if (cbs.end != NULL) { cbs.end(user_data, 2, 3); } } ``` 我們的任務是包裝這個庫,在 Go 代碼中進行使用。我們想要在遍歷中調用 Go 回調,不用再寫任何多余的 C 代碼。 ## Go 接口 讓我們從構思在 Go 代碼中我們接口的樣式開始,如下是一個方式: ```go type Visitor interface { Start(int) End(int, int) } func GoTraverse(filename string, v Visitor) { // ... 實現 } ```` 本文后續部分顯示了使用此方法的完整實現。但是,它有一些缺點: * 當我們需要提供大量的回調時,如果我們僅對幾個回調感興趣,編寫 Visitor 的實現可能會很乏味。 可以通過提供一個結構體來實現帶有某些默認操作(例如,無操作)的完整接口來減輕這種情況,然后用戶結構可以匿名繼承此默認結構,而不必實現每個方法。 盡管如此,帶有大量方法的接口通常不是一個好的 Go 實踐。 * 一個更嚴重的限制是,很難向 C 遍歷傳達我們對某些回調不感興趣的信息。 根據定義,實現 Visitor 的對象將具有所有方法的實現,因此沒有簡單的方法來判斷我們是否對調用其中的某些方法不感興趣。 這可能會對性能產生嚴重影響。 一個可替換的方法是模仿我們在 C 語言中擁有的方式;也就是說,創建一個整合函數對象的結構體: ```go type GoStartCallback func(int) type GoEndCallback func(int, int) type GoCallbacks struct { startCb GoStartCallback endCb GoEndCallback } func GoTraverse(filename string, cbs *GoCallbacks) { // ... 實現 } ``` 這立即解決了兩個缺點:函數對象的默認值為`nil`,GoTraverse 可以將其解釋為“對此事件不感興趣”,其中可以將相應的 C 回調設置為`NULL`。 由于 Go 函數對象可以是閉包或綁定方法,因此在不同的回調之間保留狀態沒有困難。 后附的代碼示例在單獨的目錄中提供了此替代實現,但是在其余文章中,我們將繼續使用 Go 接口的更慣用的方法。 對于實現而言,選擇哪種方法并不重要。 ## Cgo 包裝函數的實現 `Cgo`指針傳遞規則不允許將 Go 函數值直接傳遞給 C,因此要注冊回調,我們需要在 C 中創建包裝器函數。 而且,我們也不能直接傳遞 Go 程序分配的指針到 C 程序中,因為 Go 的并發垃圾回收器會移動數據。 `Cgo`的[Wiki](https://github.com/golang/go/wiki/cgo#function-variables)提供了使用間接尋址的解決方法。 在這里,我將使用 go-pointer 程序包,該程序包以稍微更方便,更通用的方式實現了相同目的。 考慮到這些,讓我們之間進行實現。該代碼初步看起來可能會比較晦澀,但這很快就會展現出他的意義。如下是 GoTraverse 的代碼。 ```go import gopointer "github.com/mattn/go-pointer" func GoTraverse(filename string, v Visitor) { cCallbacks := C.Callbacks{} cCallbacks.start = C.StartCallbackFn(C.startCgo) cCallbacks.end = C.EndCallbackFn(C.endCgo) var cfilename *C.char = C.CString(filename) defer C.free(unsafe.Pointer(cfilename)) p := gopointer.Save(v) defer gopointer.Unref(p) C.traverse(cfilename, cCallbacks, p) } ``` 我們先在 Go 代碼中創建 C 的回調結構,然后封裝。因為我們不能直接將 Go 函數賦值給 C 函數指針,我們將在獨立的 Go 文件[注1]中定義這些包裝函數。 ```c /* extern void goStart(void*, int); extern void goEnd(void*, int, int); void startCgo(void* user_data, int i) { goStart(user_data, i); } void endCgo(void* user_data, int a, int b) { goEnd(user_data, a, b); } */ import "C" ``` 這些是非常輕量的、調用 go 函數的包裝器——我們不得不為每一類的回調寫這樣一個 C 函數。我們很快就會看到 Go 函數 goStart 和 goEnd。 在填充這個 C 回調結構體后,GoTraverse 會將文件名從 Go 字符串轉換為 C 字符串(`Wiki`中有詳細信息)。 之后,它創建一個代表 Go 訪問者的值,我們可以使用 go-pointer 包將其傳遞給 C。最后,它調用 traverse。 完成這個實現,goStart 和 goEnd 代碼如下: ```go //export goStart func goStart(user_data unsafe.Pointer, i C.int) { v := gopointer.Restore(user_data).(Visitor) v.Start(int(i)) } //export goEnd func goEnd(user_data unsafe.Pointer, a C.int, b C.int) { v := gopointer.Restore(user_data).(Visitor) v.End(int(a), int(b)) } ``` 導出指令意味著這些功能對于 C 代碼是可見的。 它們的簽名應具有 C 類型或可轉換為 C 類型的類型。 它們的行為類似: 1. 從 user_data 解壓縮訪問者對象 2. 在訪問者上調用適當的方法 ## 詳細的調用流程 讓我們研究一下“開始”事件的回調調用流程,以更好地了解各個部分是如何連接在一起的。 GoTraverse 將 startCgo 賦值給 Callbacks 結構體中的 start 指針,Callbacks 結構體將被傳遞給 traverse。因此,traverse 遇到 start 事件時,它將調用 startCgo。 回調的參數包括:傳遞給 traverse 的 user_data 指針以及事件特定的參數(該例中為一個 int 類型的參數)。 startCgo 是 goStart 的填充程序,并使用相同的參數調用它。 goStart 解壓縮由 GoTraverse 打包到 user_data 中的 Visitor 實現,并從那里調用 Start 方法,并向其傳遞事件特定的參數。 到這一點為止,所有代碼都由 Go 庫包裝 traverse 提供;從這里開始,我們進入由`API`用戶編寫的自定義代碼。 ## 通過C代碼傳遞Go指針 此實現的另一個關鍵細節是我們用于將 Visitor 封裝在`void * user_data`內在 C 回調來回傳遞的的技巧。 [Cgo文檔](https://golang.org/cmd/cgo/#hdr-Passing_pointers)指出: > 如果 Go 代碼指向的 Go 內存不包含任何 Go 指針,則 Go 代碼可以將 Go 指針傳遞給 C。 但是,我們當然不能保證任意的 Go 對象不包含任何指針。除了明顯使用指針外,函數值,切片,字符串,接口和許多其他對象還包含隱式指針。 限制源于 Go 垃圾收集器的性質,該垃圾收集器與其他代碼同時運行,并允許移動數據,從 C 角度來看,會使指針無效。 所以,我們能做些什么?如上所述,解決方案是間接的,Cgo`Wiki`提供了一個簡單的示例。 我們沒有直接將指針傳遞給 C ,而是將其保留在 Go 板塊中,并找到了一種間接引用它的方法; 例如,我們可以使用一些數字索引。這保證了所有指針對于 Go 的垃圾回收仍然可見,但是我們可以在 C 板塊中保留一些唯一的標識符,以便以后我們訪問它們。 通過在 unsafe.Pointer(映射到 Cgo 對 C 的調用中直接`void *`)和`interface{}`之間創建一個映射,go-pointer 包便可以做到這一點,從本質上講,我們可以存儲任意的 Go 數據并提供唯一的 ID(unsafe.Pointer)以供后續引用。 為什么不像`Wiki`示例中那樣使用 unsafe.Pointer 代替`int`?因為不明確的數據通常在 C 語言中用`void *`表示,所以不安全。指針是自然映射到它的東西。如果使用`int`,我們將不得不去考慮在其他幾個地方進行轉換。 ## 如果沒有 user_data 呢? 看到我們如何使用 user_data, 使其穿越 C 代碼回到我們的回調函數,以傳輸特定于用戶 Visitor 的實現,人們可能會想-如果沒有可用的 user_data 怎么辦? 事實證明,在大多數情況下,都存在諸如 user_data 之類的東西,因為沒有它,原始的 C `API`就有缺陷。再次考慮遍歷示例,但是這項沒有 user_data: ```c typedef void (*StartCallbackFn)(int i); typedef void (*EndCallbackFn)(int a, int b); typedef struct { StartCallbackFn start; EndCallbackFn end; } Callbacks; void traverse(char* filename, Callbacks cbs); ``` 假設我們提供一個回調作為開始: ```c void myStart(int i) { // ... } ``` 在 myStart 中,我們有些困惑了。我們不知道調用哪個遍歷-可能有許多不同的遍歷,不同的文件和數據結構滿足不同的需求。我們也不知道在哪里記錄事件的結果。這里唯一的辦法是使用全局數據。這是一個不好的`API`! 有了這樣的`API`,我們在 Go 板塊的情況就不會差很多。我們還可以依靠全局數據來查找與此特定遍歷有關的信息,并且我們可以使用相同的 Go 指針技巧在此全局數據中存儲任意 Go 對象。但是,這種情況不太可能出現,因為 C `API`不太可能忽略此關鍵細節。 ## 附屬資源鏈接 關于使用`Cgo`的信息還有很多,其中有些是過時的(在明確定義傳遞指針的規則之前)。如下是我在準備這篇文章時發現的特別有用的鏈接集合: * [官方的 Cgo 文檔](https://golang.org/cmd/cgo/)是權威指南。 * [Cgo 的 Wiki 頁面](https://github.com/golang/go/wiki/cgo)是相當有用的。 * [Go 語言并發垃圾回收的一些細節](https://blog.golang.org/go15gc)。 * Yasuhiro Matsumoto的[從C調用Go](https://dev.to/mattn/call-go-function-from-c-function-1n3)的文章。 * 指針傳遞規則的[詳細細節](https://github.com/golang/proposal/blob/master/design/12416-cgo-pointers.md)。 [注1]由于 Cgo 生成和編譯 C 代碼的特殊性,它們位于單獨的文件中-有關[Wiki](https://github.com/golang/go/wiki/cgo#export-and-definition-in-preamble)的更多詳細信息。我沒有對這些函數使用靜態內聯技巧的原因是我們必須獲取它們的地址。

via: https://eli.thegreenplace.net/2019/passing-callbacks-and-pointers-to-cgo/

作者:Eli Bendersky  譯者:amzking  校對:DingdingZhou

本文由 GCTT 原創編譯,Go語言中文網 榮譽推出

入群交流(和以上內容無關):Go中文網 QQ 交流群:729884609 或加微信入微信群:274768166 備注:入群;關注公眾號:Go語言中文網

206 次點擊  ?  1 贊  
加入收藏 微博
被以下專欄收入,發現更多相似內容
暫無回復
添加一條新回復 (您需要 登錄 后才能回復 沒有賬號 ?)
  • 請盡量讓自己的回復能夠對別人有幫助
  • 支持 Markdown 格式, **粗體**、~~刪除線~~、`單行代碼`
  • 支持 @ 本站用戶;支持表情(輸入 : 提示),見 Emoji cheat sheet
  • 圖片支持拖拽、截圖粘貼等方式上傳