cgo不是Go
借用 JWZ 的一句話
有些人,當他們面臨一個問題時,認為“我知道,我會使用 cgo ”。那么現在,他們有了兩個問題。
最近有人在 Gopher 的 Slack Channel 上使用 cgo,對此我感到十分擔心,尤其是竟然有個組織內部打算用一個項目來展示 Go,那真是一個壞主意。對此,我曾說過很多次了,因此也許你們討厭了我的游說,所以我想到了把它寫下來並且去做。
cgo 是一個令人驚異的技術,它允許 Go 程序與 C 的類庫交互操作。那是一個極其有用的特征,今天它達到了一個 Go 所無法企及的地位。cgo 是讓 Go 程序在 Android 和 iOS 上運行的關鍵。
然而,這只是我的個人意見,我不為任何人說話,我認為 cgo 在 Go 項目中被過度使用了。我相信當面臨需要重載一大段用 Go 編寫的 C 代碼時,程序員會更願意選擇用 cgo 去打包庫而非 Go,因為他們認為那樣更容易解決問題。我認為那是虛假經濟。
顯而易見的,cgo 也存在一些不可避免的問題,最明顯的一個問題是作為一個二進制 blog,你不得不與顯卡驅動或者窗口系統進行交互 。但是使用 cgo 所存在的問題,經過權衡后,大部分人認為還是比較少的。
當你在 Go 項目上建立一個 cgo 庫的時候,你可能沒有意識到你的這種權衡其實是不完整的。
構建時間變長
當你在包中引用 import "C",go build 就會做很多額外的工作來構建你的代碼,構建就不會僅僅是向 go tool compile 傳遞一堆 .go 文件了,取而代之的是:
-
cgo 工具就會被調用,在 C 轉換 Go、Go 轉換C的之間生成各種文件。
-
你系統的 C 編譯器會被調用來處理你包中所有的C文件。
-
所有獨立的編譯單元會被組合到一個 .o 文件。
-
生成的 .o 文件會在系統的連接器中對它的引用進行一次檢查修復。
當你針對這個包編程的時候,所有上面的工作在你編譯或者測試的過程中都會進行。Go 工具在可能的情況下,會並行的處理這些工作,但是這個包的編譯時間會隨着所有 C 代碼的構建而增加。
你可以將這部分 C 代碼重構出這個包來解決,但是如果你不用 cgo,自然也就不會遇到這個問題。
對了,你還必須調試 C 代碼在不同平台的兼容性問題。
復雜的構造
Go的目標之一是生產一種語言,它的構造過程就是它自我描述的過程;你程序的資源包含了足夠的信息來使用一個工具去創建這個項目。這不是說用 Makefile 來自動化構建工程是不好的,但是在cgo被引進項目之前,你創建並且測試不需要任何東西,除了go工具。后來cgo被引入,設置所有的環境變量、跟蹤那些可能被安裝在奇怪地方的共享對象和頭文件,現在這些你都需要做。
記住,Go支持那些不裝載開箱即用的平台,所以你不得不花一些時間去提出一個Windows用戶的解決方案。
還有,現在你的用戶還要安裝C編譯器,而不是只安裝Go編譯器。他們同時還需要安裝你項目所依賴的C庫,所以你同時還要承擔這些支持的成本。
交叉編譯不支持
Go對於交叉編譯的支持是最好的。根據Go1.5交叉編譯,你可以從任何支持的平台到其他任何平台,通過Go項目網站上的官方許可安裝程序。
在默認情況下cgo是不允許交叉編譯的。 通常這不構成問題,如果你的項目是純Go的。 當你混合了C庫的依賴, 你或者不得不放棄交叉編譯你的產品,或者不得不花時間在尋找並且維護C的交叉編譯工具鏈來達成你的目標。
或許你所做的產品僅通過TCP與客戶端通信,並且你計划讓它在SaaS(軟件服務化 Software as a Service)的模型上運行,那么你根本不關心交叉編譯就是合理的。然而,如果你在做一個其他人會用的產品,可能會整合到他們的產品中去,或許那是一個監控解決方案,或許那是一個你SaaS服務的客戶端,那么你就把他們很容易交叉編譯的特性給鎖死了。
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有一些偉大的工具;有競態分析、性能分析、覆蓋率、模糊測試和其他源代碼分析工具。這些中沒有任何一個能夠跨越cgo血液或是cgo大腦的屏障。
相反地,像Valgrind這樣的優秀工具不理解Go的調用約定和堆棧布局。在這一點上,Ian Lance Taylor的在Go 1.6中將C的內存清理和空指針調試相結合的工作對於cgo的用戶來說將會非常有益。
梳理Go的代碼和C的代碼結果到了兩個世界的交匯處,不是結盟;C的內存安全,Go程序的可調試性。
性能永遠是一個問題
C代碼和Go代碼生存在兩個不同的宇宙中,cgo橫貫了他們兩個之間的分界線。這個過渡並不是自由的,它取決於它在你代碼中的位置,花費可能是無足輕重的,也可能是巨額的。
C 並不了解 Go 的調用協定或堆棧,所以 Go 語言調用到 C 代碼時必須首先記錄 Go 函數入口堆棧的所有細節,然后切換到 C 的堆棧,運行 C 代碼,這部分 C 代碼並不知道它如何被調用,更不知道外部的 Go 語言運行時環境。
公平地說,Go 也不知道任何關於 C 的情況。這就是為什么隨着編譯器和垃圾收集器在定位無用棧幀和堆方面的發展,Go 和 C 兩者之間傳遞數據的規則變得越來越復雜。
如果在 C 的運行過程中出現一個錯誤,Go 至少得能做到打印錯誤堆棧信息,然后退出程序,而不是把核心文件都暴露出來。
管理這種雙方互相調用的堆棧,再加上在信號、線程和回調,真的很是不容易的。Ian Lance Taylor 在1.6版本的 Go 語言中做了大量的工作來改進與 C 語言信號處理方面的互操作性。
這里要說的是 C 與 Go 語言之間的的互相調用是比較繁瑣的,而且永遠是會有性能開銷的。
C 是主導,而不是你的代碼
你用那種語言捆綁或包裝C代碼都沒關系;Python、有JNI的Java、一些使用了libFFI的語言,或者是有cgo的Go;這是C的世界,你只是在其上生存。
Go代碼和C代碼必須在資源共享方式上取得一致,如地址空間、信號處理和線程調度——而我所說的一致,是Go要圍繞着C的假設。 C代碼可以假設它一直在一個線程上運行,或者是無顧慮地在沒有任何准備工作的情況下直接運行在有許多線程的環境中。
你不是在寫一個Go程序使用C庫中的一些邏輯,取而代之的是你在寫一個Go程序,它必須與一段容易沖突的、很難被取代的、很難協商的、不顧慮你的問題的C代碼共存。
部署變得更復雜
對於普通讀者來說,任何關於Go的描述都要包含至少以下詞匯中的一點:
簡單,靜態二進制
這是Go的法寶,它引領Go成為了遠離虛擬機和運行管理的典型代表。使用cgo,你就要放棄這些。
根據你的環境,把你的Go項目打成一個deb或rpm包,並且假設你其他的依賴也包裝好了,把它們加進安裝依賴,然后把問題從操作系統的包管理中拋出,這也許是可行的。但是這幾個構建和部署程序的重要改變就像 go build && scp一樣倉促直白。
編譯一個完全靜態的Go程序是可行的,但是如果你的項目中包含了cgo那將是絕不簡單的,其后果將影響一整個構建和部署的生命周期。
請明智地選擇
要明確,我不是說你不應該使用cgo。但是在你做這筆交易之前,請仔細考慮你同時要放棄的Go的優點。