這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
- 視頻資訊
- 什麼是 Build Mode?
- Go 的八種 Build Mode
exe (靜態編譯)
- exe (用 libc)
- exe (動態連結
libc 和非 Go 代碼)
pie - Position Independent Executables
c-archive C 的靜態連結庫
c-shared C 的動態連結程式庫
shared Go 的動態連結程式庫
plugin Go 的外掛程式
- 優缺點
- 未來
視頻資訊 #
Go Build Modes
by David Crawshaw, Google
at GopherCon 2017
https://www.youtube.com/watch?v=x-LhC-J2Vbk
什麼是 Build Mode? #
build mode 用於指導編譯器如何建立可執行二進位檔案。越多的執行方式,就意味著可以讓 Go 程式運行於更多的位置。
Go 的八種 Build Mode #
exe (靜態編譯)
exe (動態連結 libc)
exe (動態連結 libc 和非 Go 代碼)
pie 地址無關可執行檔(安全特性)
c-archive C 的靜態連結庫
c-shared C 的動態連結程式庫
shared Go 的動態連結程式庫
plugin Go 的外掛程式
exe (靜態編譯) #
這個是大家最喜歡的,所有的代碼都構建到一個可執行檔了。
1 |
CGO_ENABLED=0 go build hello.go |
這是大家使用 Go 最喜歡的構建方式。所有的依賴都構建到了一個二進位檔案了,沒有任何外部依賴,可執行檔直接調用 syscall 和核心通訊。
這裡使用 CGO_ENABLED=0 來約束不使用任何 CGO 的部分,這樣不會依賴 libc 這類庫。
exe (用 libc) #
這樣的可執行檔大部分都是靜態編譯,只不過使用了 libc 動態連結程式庫,因此像一些 net 包的操作,比如 DNS 查詢、os/user 的使用者名稱查詢等等,這些會使用系統提供的 libc 動態連結程式庫。
其好處是,可以利用系統特定的實現,保證行為和系統一致。
exe (動態連結 libc 和非 Go 代碼) #
當程式編譯的時候,所有 Go 代碼自然都被編譯為 object 檔案,而所有非 Go 的代碼,也可以被被其編譯器(如 C, Fortran 等)編譯為 object 檔案,而這些非 Go 代碼可以被 cgo 調用。
當程式被串連(link)的時候,這些非 Go 代碼可以選擇被編譯進最終的二進位檔案中,也可以選擇動態連結,在運行時載入。
pie - Position Independent Executables #
這是構建運行地址無關的二進位可執行檔的形式,這是一種安全特性,可以在支援 PIE 的作業系統中,讓可執行檔在載入時,每次的地址都是不同的。避免已知地址的跳躍式的攻擊。
這種方式和 exe 基本一樣,將來可能會成為預設。
c-archive C 的靜態連結庫 #
從這裡開始,和前面構建可執行檔不同了。這裡構建的是供 C 程式調用的庫。更準確一些的說,這裡是把 Go 程式構建為 archive (.a) 檔案,這樣 C 類的程式可以靜態連結 .a 檔案,並調用其中代碼。
1234567891011 |
package mainimport "fmt"import "C"func main() {}//export Hellofunc Hello() {fmt.Println("Hello, world.")} |
注意這裡的 //export Hello,這是約定,所有需要匯出給 C 調用的函數,必須通過注釋添加這個構建資訊,否則不會構建產生 C 所需的標頭檔。
然後我們構建這個 hello.go 檔案:
1 |
go build -buildmode=c-archive hello.go |
構建後,會產生兩個檔案,一個是靜態庫檔案 hello.a,另一個則是 C 的標頭檔 hello.h。
12 |
hello.a: current ar archive random libraryhello.h: c program text, ASCII text |
在所產生的 hello.h 的標頭檔中,我們可以看到 Go 的 Hello() 函數的定義:
12345678910 |
#ifdef __cplusplusextern "C" {#endifextern void Hello();#ifdef __cplusplus}#endif |
然後我們可以在 hello.c 中引用標頭檔,並使用 Go 編譯的靜態庫:
123456 |
#include "hello.h"int main(void) { Hello(); return 0;} |
然後,構建 C 程式:
1 |
cc hello.a hello.c -o hello |
最後執行:
12 |
$ ./helloHello, world. |
c-shared C 的動態連結程式庫 #
和前一個例子不同的地方是,這將用 Go 代碼建立一個動態連結程式庫(Unix: .so/Windows .dll),然後用 C 語言程式動態載入運行。
Go 和 C 語言的代碼和上面是一樣的,但是構建過程不同:
1 |
go build -buildmode=c-shared -o hello.so hello.go |
這裡我們使用了 -buildmode=c-shared,以構建 C 所支援的動態連結程式庫。
註:需要注意的是,這裡明確指定了 -o hello.so,這裡我和演講者不同,如果不指定輸出檔案名,那麼預設會使用 hello 作為檔案名稱,導致後續的操作找不到 hello.so 檔案。
這次也產生了兩個檔案,一個是 hello.so,一個是 hello.h:
12 |
hello.h: c program text, ASCII texthello.so: Mach-O 64-bit dynamically linked shared library x86_64 |
然後,編譯對應的 C 程式:
1 |
cc hello.c hello.so -o hello |
如果對比 c-archive 例子和 c-shared 例子中的 hello 二進位可執行檔的大小,就會發現 c-shared 的例子的 hello 要小很多:
12345 |
# c-archive-rwxr-xr-x 1 taowang staff 1.5M 3 Oct 17:51 hello# c-shared-rwxr-xr-x 1 taowang staff 8.2K 3 Oct 19:17 hello |
這是因為前者,將 Go 的代碼靜態編譯進了 C 的程式中;而後者,則是動態連結,C 的可執行檔內不包含我們寫的 Go 的代碼,所有這部分函數都在動態連結程式庫 hello.so 中。
1 |
-rw-r--r-- 1 taowang staff 2.2M 3 Oct 19:17 hello.so |
因此,執行的時候,我們除了需要 hello 這個二進位可執行檔外,我們還需要 hello.so 這個動態連結程式庫。如果預設的 LD_LIBRARY_PATH 包含了目前的目錄,並且 hello.so 就在目前的目錄,那麼可以直接:
12 |
$ ./helloHello, world. |
否則,如果提示找不到 hello.so,如:
1 |
dyld: Library not loaded: hello.so |
那可以手動指定 LD_LIBRARY_PATH 變數,告訴作業系統到哪裡去尋找動態連結程式庫:
1234567 |
# On Linux$ LD_LIBRARY_PATH=. ./helloHello, world.# On macOS$ DYLD_LIBRARY_PATH=. ./helloHello, world. |
為什麼會需要動態連結? #
從開始使用 Go 我們就反反覆複的聽到人說 Go 的靜態連結如何方便,既然如此,那麼我們為什麼需要動態連結?
因為動態連結可以在運行時需要的時候,由程式決定載入,也可以在不需要的時候卸載,這樣可以節約記憶體資源。
123456789101112131415 |
#include <dlfcn.h>#include <stdio.h>int main(void) { void* lib = dlopen("hello", 0); void (*fn)() = dlsym(lib, "Hello"); if (!fn) { fprintf(stderr, "no fn: %s\n", dlerror()); return 1; } // Calls Hello(); fn(); return 0;} |
這裡我們使用 dlopen() 來載入庫,然後用 dlsym() 來載入符號(函數)到一個函數指標,然後我們調用該函數指標 fn()。
shared Go 的動態連結程式庫 #
shared 模式和 c-shared 有些相似,都是構建一個動態連結程式庫,以便在運行時載入。所不同的是 shared 並非構建 C 語言的動態連結程式庫,而是專門為 Go 可執行檔構建動態連結程式庫。
macOS 下目前不支援 shared 模式。
這次還是 hello.go,不過稍有不同。
1234567 |
package mainimport "fmt"func main() {fmt.Println("Hello, World!")} |
這裡就是獨立的一個檔案,一個 main(),執行後列印 Hello, World。我們可以像以前一樣用 exe 模式構建,然後執行。不過這次我們用一種不同的方式構建。
12 |
go install -buildmode=shared stdgo build -linkshared hello.go |
這裡我們首先把 Go 標準庫 std 構建並安裝到 $GOPATH/pkg 下,然後使用 -linkshared 來構建 hello.go。
執行結果和前面一樣,但是如果仔細觀察產生的檔案,就會發現和前面很不同。
12 |
$ ls -l hello-rwxr-xr-x 1 root root 16032 Oct 3 13:27 hello |
可以看到這個 Hello World 程式只有十幾KB大小。對於 C 程式員來說,這沒啥驚訝的,因為就應該這麼大啊。但是對於 Go 程式員來說,這就是很奇怪了,因為一般不都得 7~8MB 嗎?
其原因就是使用了動態連結程式庫,所有標準庫部分,都用動態連結的辦法來調用,構建的二進位可執行檔中只包含了程式部分。C 程式構建的 Hello World 之所以小,也是因為動態連結的原因。
如果我們查閱程式所調用的庫就可以看到具體情況:
1234567 |
$ ldd hello linux-vdso.so.1 (0x00007ffed3d4e000) libstd.so => /usr/local/go/pkg/linux_amd64_dynlink/libstd.so (0x00007f608c409000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f608c06a000) libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f608be66000) libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f608bc49000) /lib64/ld-linux-x86-64.so.2 (0x00007f608e866000) |
如果我們進一步去查看 libstd.so,就會看到一個巨大的動態連結程式庫,這就是 Go 的標準庫:
1 |
-rw-r--r-- 1 root root 37M Oct 3 13:27 /usr/local/go/pkg/linux_amd64_dynlink/libstd.so |
當然,要使用這個模式需要很多準備工作,所有的動態連結程式庫都需要在指定的位置,版本都必須相容等等,所以我們一般不常用這個模式。
plugin Go 的外掛程式 #
外掛程式形式和 c-shared、shared 相似,都是構建一個動態連結程式庫,和 shared 一樣,這是構建一個 Go 專用的動態連結程式庫,而和 shared 不同的是,動態連結程式庫並非在程式啟動時載入,而是由程式內決定何時載入和釋放。
這是個新的東西,所以意味著可能不能用?……,當然如果用的對的話,應該還可以用。
我們建立一個 plugin,myplugin.go:
1234567 |
package mainimport "fmt"func Hello() { fmt.Println("Hello, World!")} |
可以看到,這和最初那個靜態連結庫的性質相似。不過不同的是,這裡既沒有 import "C",也沒有 //export Hello,而且也沒有 func main()。因為這裡不需要,我們是 Go 調用 Go 的代碼,因此很多東西都省了。
調用代碼這麼寫:
123456789101112131415161718 |
package mainimport "plugin"func main() {//載入 myplugin 庫p, err := plugin.Open("myplugin.so")if err != nil {log.Fatal(err)}//取得 Hello 函數fn, err := p.Lookup("Hello")if err != nil {log.Fatal(err)}//調用函數fn.(func())()} |
可以看到,這個邏輯上,和 hello-dyn.c 很相似。plugin.Open() 有點兒像 dlopen();而 p.Lookup() 有點兒像 dlsym()。實際上也是如此,底層實現的時候就是調用的這兩個函數。
注意這裡的 fn.(func())(),p.Lookup() 返回的是一個 interface{},因此這裡需要轉型為具體函數類型。
用下面的命令構建:
12 |
go build -buildmode=plugin myplugin.gogo build runplugin.go |
前者會產生一個 myplugin.so,後者會產生調用者 runplugin。
12 |
-rw-r--r-- 1 root root 3.8M Oct 3 13:58 myplugin.so-rwxr-xr-x 1 root root 3.5M Oct 3 13:58 runplugin |
優缺點 #
exe (靜態編譯)
- Pros:
- 全部整合,不需要任何依賴
- 非常適合超小型的容器環境
- 很容易跨不同 Linux 發行版
exe (動態連結 libc)
- Pros:
- 可以利用系統功能,比如 DNS 查詢。
- 可以通過
libc 直接使用系統配置。
- Cons:
exe (動態連結 libc 和非 Go 代碼)
- Pros:
- 可以直接在 Go 程式中使用非 Go 的代碼
- 方便和老的系統整合
- Cons:
- 構建變得更加複雜
- C 不是 Go
- 更容易出問題。
- 所有 Go 可能出問題地方
- 所有 C 可能出問題的地方
- 所有 Go <-> C 之間通訊可能出問題的地方
pie 地址無關可執行檔(安全特性)
- Pros:
- Cons:
- 二進位會更大一些(bug, will be fixed)
- 大約會有
~1% 的效能損失
c-archive C 的靜態連結庫
- Pros:
- 可以讓 Go 整合到現有的 C 程式中
- 事實上,這就是 Go 在 iOS 上的工作方式
- 非常適用於已存在的非 Go 環境的構建
- Cons:
c-shared C 的動態連結程式庫
- Pros:
- 比較方便 Go 整合進現有的 C 程式中
- 可以在運行時載入
- 這是目前 Go 在 Android 下的工作方式(Java 的
System.load())
- Cons:
- 跨語言調用會比較麻煩
- 想想 Android 的環境,可能出問題的面積更大了:
- Go 可能出問題的地方
- C 可能出問題的地方
- Java 可能出問題的地方
- 所有它們之間通訊可能出問題的地方……?
shared Go 的動態連結程式庫
- Pros:
- 多個可執行檔可以共用動態連結程式庫,可以降低系統總的體積。
- 一般作業系統廠商會比較青睞於這種方式,可以讓整個系統的體積降低。
- 事實上,這就是 Canonical(Ubuntu) 力推實現的方式
- 可以降低系統體積,不過現在儲存空間一般不是問題
- 可以降低記憶體,可以在記憶體中共用動態連結程式庫代碼(如果動態連結程式庫的 loader 足夠聰明的話)
- Cons:
plugin Go 的外掛程式
- Pros:
- 在運行時 Go 程式載入其它 Go 程式
- 對於複雜應用來說,允許不同部分在不同時間構建
- Cons:
- 構建比較複雜,部署也會很複雜
- 如果問演講者是否該用
plugin 模式,答案一般是 No。
未來 #
還有很多地方需要改進。
c-shared:目前不支援 Windows(可能),macOS(部分支援)
shared:目前不支援 macOS
plugin:目前不支援 macOS、Windows
plugin:或許可以將 runtime 從外掛程式中移除以獲得更小的可執行檔