在 Go 里用 CGO?這 7 個問題你要關注!
大家好,我是煎魚。
今天給大家分享的是 Go 諺語中的 Cgo is not Go[1],原文章同名,略有修改,原文作者是 @Dave Cheney。以下的 “我” 均指代原作者。
借用 JWZ 的一句話:有些人在面對一個問題時,認為 "我知道,我會使用 cgo(來解決這個問題)"。
類似的引言
在使用 cgo 后,他們就會遇到兩個新問題。
Cgo 是什么
Cgo 是一項了不起的技術,它允許 Go 程序與 C 語言庫相互操作,這是一個非常有用的功能。
沒有它,Go 就不會有今天的地位。cgo 是在 Android 和 iOS 上運行 Go 程序的關鍵。
注:甚至許多內部用到其他底層語言的同學,會使用它來做膠水。
被過度使用
我個人認為 cgo 在 Go 項目中被過度使用了,當面臨在 Go 中重新實現一大段 C 語言代碼時,程序員會選擇使用 cgo 來包裝庫,認為這是個更容易解決的問題。但我認為這是一種錯誤的選擇行為。
顯然,在某些情況下,cgo 是不可避免的,最明顯的是你必須與圖形驅動或窗口系統進行互操作,而后者只能以二進制 blob 的形式提供。在這些場景下,cgo 的使用證明了它的權衡是合理的,比許多人準備承認的要少得多。
以下是一份不完整的權衡清單,當你把 Go 項目建立在 cgo 庫上時,你可能沒有意識到這些權衡。
你需要對此進行思考。
構建時間變長
當你在 Go 包中導入 "C" 時,go build 需要做更多的工作來構建你的代碼。
構建你的包不再是簡單地將范圍內的所有 .go 文件的列表傳遞給 go 工具編譯的一次調用,而是包含以下工作項:
- 需要調用 cgo 工具來生成 C 到 Go 和 Go 到 C 的相關代碼。
- 系統中的 C 編譯器會為軟件包中的每個 C 文件進行調用處理。
- 各個編譯單元被合并到一個 .o 文件中。
- 生成的 .o 文件會通過系統的鏈接器,對其引用的共享對象進行修正。
所有這些工作在你每次編譯或測試你的軟件包時都會發生,如果你在該軟件包中積極工作的話,這種情況是經常發生的。
Go 工具會在可能的情況下將這些工作并行化(包括對所有的 C 代碼進行全面重建),軟件包的編譯時間將會增加,并會隨之增大而增大。
你還需要在各大平臺上調試你的 C 語言代碼,以避免由于兼容性導致的編譯失敗。
復雜的構建
Go 的目標之一是產生一種語言,它的構建過程是自我描述的;你的程序的源代碼包含了足夠的信息,可以讓一個工具來構建這個項目。這并不是說使用 Makefile 來自動構建工作流程是不好的,但是在 cgo 被引入項目之前,除了 go 工具之外,你可能不需要任何東西來構建和測試。
在引入了 cgo 之后,你需要設置所有的環境變量,跟蹤可能安裝在奇怪地方的共享對象和頭文件。
另外需要注意,Go 支持許多的平臺,而 cgo 并不是。所以你必須花一些時間來為你的 Windows 用戶想出一個解決方案。
現在你的用戶必須安裝 C 編譯器,而不僅僅是 Go 編譯器。他們還必須安裝你的項目所依賴的 C 語言庫,你也要承擔這個技術支持的成本。
交叉匯編被拋在窗外
Go 對交叉編譯的支持是同類中最好的。從 Go 1.5 開始,你可以通過 Go 項目網站上的官方安裝程序支持從任何平臺交叉編譯到任何其他平臺。
在默認情況下,交叉編譯時 cgo 被禁用。通常情況下,如果你的項目是純粹的 Go,這不是一個問題。
當你混入對 C 庫的依賴時,你要么放棄交叉編譯你的因那個也,要么你必須投入時間為所有目標尋找和維護交叉編譯的 C 工具鏈,才能實現交叉編譯。
Go 支持的平臺數量在不斷增加。Go 1.5 增加了對 64 位 ARM 和 PowerPC 的支持。Go 1.6 增加了對 64 位 MIPS 的支持,而 IBM 的 s390 架構被吹捧為 Go 1.7。RISC-V 正在開發中。
如果你的產品依賴于 C 語言庫,你不僅有上述交叉編譯的所有問題,你還必須確保你所依賴的 C 語言代碼在 Go 支持的新平臺上可靠地工作 -- 而且你必須在 C/Go 混合語言為你提供的有限調試能力的情況下做到這一點。
你失去了對所有工具的訪問權
Go 有很好的工具;我們有 race detector、用于分析代碼的 pprof、覆蓋率、模糊測試和源代碼分析工具。但這些工具都不能在 cgo 中起到作用(也就是沒法排查)。
相反,像 valgrind 這樣優秀的工具并不了解 Go 的調用約定或堆棧布局。在這一點上,Ian Lance Taylor 的工作是整合 clang 的內存凈化器來調試 C 端的懸空指針,這對 Go 1.6 中的 cgo 用戶有好處。
將 Go 代碼和 C 代碼結合起來的結果是兩個世界的交叉點,而不是結合點;C 的內存安全和 Go 程序的調試性。但失去了許多核心工具的使用空間。
性能將始終是一個問題
C 代碼和 Go 代碼生活在兩個不同的世界里,cgo 穿越了它們之間的邊界,這種轉換不是免費的。而且取決于它在你的代碼中存在的位置,其成本可能是無關緊要的,也可能是巨大的。
?C 對 Go 的調用慣例或可增長的堆棧一無所知,所以對 C 代碼的調用必須記錄 goroutine 堆棧的所有細節,切換到 C 堆棧,并運行 C 代碼,而 C 代碼對它是如何被調用的,或負責程序的更大的 Go 運行時一無所知。
公平地說,Go 對 C 的世界也一無所知。這就是為什么隨著時間的推移,兩者之間的數據傳遞規則變得越來越繁瑣,因為編譯器越來越善于發現不再被認為是有效的堆棧數據,而垃圾回收器也越來越善于對堆進行同樣的處理。
如果在 C 語言世界中出現故障,Go 代碼必須恢復足夠的狀態,至少要打印出堆棧跟蹤并干凈地退出程序,而不是把核心文件的信息都暴露出來。
管理這種跨調用堆棧的過渡,尤其是涉及到信號、線程和回調的情況下,是不容易的(Ian Lance Taylor 在 Go 1.6 中也做了大量的工作來改善信號處理與 C 的互操作性)。
歸根結底,C 語言和 Go 語言之間的轉換是不容易的,互相對對方都一戶無知,會有明顯的性能開銷。
C 語言發號施令,而不是你的代碼
你用哪種語言編寫綁定或包裝 C 代碼并不重要;Python、使用 JNI 的 Java、使用 libFFI 的一些語言,或者通過 cgo 的 Go;這是 C 的世界,你只是生活在其中。
Go 代碼和 C 代碼必須就如何共享地址空間、信號處理程序和線程 TLS 槽等資源達成一致 -- 我說的一致,實際上是指 Go 必須圍繞 C 代碼的假設開展工作。C 代碼可以假設它總是在一個線程上運行,或者根本沒有準備好在多線程環境下工作。
你不是在寫一個使用 C 庫的邏輯的 Go 程序,是在寫一個必須與互不可控的 C 代碼共存的 Go 程序,這個 C 代碼很難被取代,在談判中占上風,而且不關心你的問題。
部署變得更加復雜
任何對普通觀眾的 Go 演講都會包含至少一張帶有這些文字的幻燈片:Single, static binary(單一的、靜態的二進制)。
這是 Go 的一張王牌,使其成為遠離虛擬機和運行時管理的典型代表。使用 cgo,你就放棄了這一點,放棄了 Go 的優勢區域。
根據你的環境,你可能會把你的 Go 項目編譯成 deb 或 rpm,并且假設你的其他依賴項也被打包了,把它們作為安裝依賴項加入,把問題推給操作系統的軟件包管理器。但這對以前像 go build && scp 那樣直接的構建和部署過程來說,是有幾個重大的變化。
完全靜態地編譯 Go 程序是可能的,但這絕不是簡單的,這表明在項目中加入 cgo 的影響會波及整個構建和部署的生命周期。
明智的選擇
說白了,我并不是說你不應該使用 cgo。但是在你做這個設計前,請仔細考慮你將會放棄的 Go 的許多品質。
需要考慮清楚得失,再思考是否值得你這么去做。
參考資料
[1]Cgo is not Go: https://dave.cheney.net/2016/01/18/cgo-is-not-go???