Go編譯的幾個細節(jié),連專家也要停下來想想
在Go開發(fā)中,編譯相關(guān)的問題看似簡單,但實則蘊含許多細節(jié)。有時,即使是Go專家也需要停下來,花時間思考答案或親自驗證。本文將通過幾個具體問題,和大家一起探討Go編譯過程中的一些你可能之前未曾關(guān)注的細節(jié)。
注:本文示例使用的環(huán)境為Go 1.23.0、Linux Kernel 3.10.0和CentOS 7.9。
1. Go編譯默認采用靜態(tài)鏈接還是動態(tài)鏈接?
我們來看第一個問題:Go編譯默認采用靜態(tài)鏈接還是動態(tài)鏈接呢?
很多人脫口而出:動態(tài)鏈接[3],因為CGO_ENABLED默認值為1,即開啟Cgo。也有些人會說:“其實Go編譯器默認是靜態(tài)鏈接的,只有在使用C語言庫時才會動態(tài)鏈接”。那么到底哪個是正確的呢?
我們來看一個具體的示例。但在這之前,我們要承認一個事實,那就是CGO_ENABLED默認值為1,你可以通過下面命令來驗證這一點:
$go env|grep CGO_ENABLED
CGO_ENABLED='1'
驗證Go默認究竟是哪種鏈接,我們寫一個hello, world的Go程序即可:
// go-compilation/main.go
package main
import "fmt"
func main() {
fmt.Println("hello, world")
}
構(gòu)建該程序:
$go build -o helloworld-default main.go
之后,我們查看一下生成的可執(zhí)行文件helloworld-default的文件屬性:
$file helloworld-default
helloworld-default: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped
$ldd helloworld-default
不是動態(tài)可執(zhí)行文件
我們看到,雖然CGO_ENABLED=1,但默認情況下,Go構(gòu)建出的helloworld程序是靜態(tài)鏈接的(statically linked)。
那么默認情況下,Go編譯器是否都會采用靜態(tài)鏈接的方式來構(gòu)建Go程序呢?我們給上面的main.go添加一行代碼:
// go-compilation/main-with-os-user.go
package main
import (
"fmt"
_ "os/user"
)
func main() {
fmt.Println("hello, world")
}
和之前的hello, world不同的是,這段代碼多了一行包的空導入,導入的是os/user這個包。
編譯這段代碼,我們得到helloworld-with-os-user可執(zhí)行文件。
$go build -o helloworld-with-os-user main-with-os-user.go
使用file和ldd檢視文件helloworld-with-os-user:
$file helloworld-with-os-user
helloworld-with-os-user: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), not stripped
$ldd helloworld-with-os-user
linux-vdso.so.1 => (0x00007ffcb8fd4000)
libpthread.so.0 => /lib64/libpthread.so.0 (0x00007fb5d6fce000)
libc.so.6 => /lib64/libc.so.6 (0x00007fb5d6c00000)
/lib64/ld-linux-x86-64.so.2 (0x00007fb5d71ea000)
我們看到:一行新代碼居然讓helloworld從靜態(tài)鏈接變?yōu)榱藙討B(tài)鏈接,同時這也是如何編譯出一個hello world版的動態(tài)鏈接Go程序的答案。
通過nm命令我們還可以查看Go程序依賴了哪些C庫的符號:
$nm -a helloworld-with-os-user |grep " U "
U abort
U __errno_location
U fprintf
U fputc
U free
U fwrite
U malloc
U mmap
U munmap
U nanosleep
U pthread_attr_destroy
U pthread_attr_getstack
U pthread_attr_getstacksize
U pthread_attr_init
U pthread_cond_broadcast
U pthread_cond_wait
U pthread_create
U pthread_detach
U pthread_getattr_np
U pthread_key_create
U pthread_mutex_lock
U pthread_mutex_unlock
U pthread_self
U pthread_setspecific
U pthread_sigmask
U setenv
U sigaction
U sigaddset
U sigemptyset
U sigfillset
U sigismember
U stderr
U strerror
U unsetenv
U vfprintf
由此,我們可以得到一個結(jié)論,在默認情況下(CGO_ENABLED=1),Go會盡力使用靜態(tài)鏈接的方式,但在某些情況下,會采用動態(tài)鏈接。那么究竟在哪些情況下會默認生成動態(tài)鏈接的程序呢?我們繼續(xù)往下看。
2. 在何種情況下默認會生成動態(tài)鏈接的Go程序?
在以下幾種情況下,Go編譯器會默認(CGO_ENABLED=1)生成動態(tài)鏈接的可執(zhí)行文件,我們逐一來看一下。
2.1 一些使用C實現(xiàn)的標準庫包
根據(jù)上述示例,我們可以看到,在某些情況下,即使只依賴標準庫,Go 仍會在CGO_ENABLED=1的情況下采用動態(tài)鏈接。這是因為代碼依賴的標準庫包使用了C版本的實現(xiàn)。雖然這種情況并不常見,但os/user包[4]和net包[5]是兩個典型的例子。
os/user包的示例在前面我們已經(jīng)見識過了。user包允許開發(fā)者通過名稱或ID查找用戶賬戶。對于大多數(shù)Unix系統(tǒng)(包括linux),該包內(nèi)部有兩種版本的實現(xiàn),用于解析用戶和組ID到名稱,并列出附加組ID。一種是用純Go編寫,解析/etc/passwd和/etc/group文件。另一種是基于cgo的,依賴于標準C庫(libc)中的例程,如getpwuid_r、getgrnam_r和getgrouplist。當cgo可用(CGO_ENABLED=1),并且特定平臺的libc實現(xiàn)了所需的例程時,將使用基于cgo的(libc支持的)代碼,即采用動態(tài)鏈接方式。
同樣,net包在名稱解析(Name Resolution,即域名或主機名對應(yīng)IP查找)上針對大多數(shù)Unix系統(tǒng)也有兩個版本的實現(xiàn):一個是純Go版本,另一個是基于C的版本。C版本會在cgo可用且特定平臺實現(xiàn)了相關(guān)C函數(shù)(比如getaddrinfo和getnameinfo等)時使用。
下面是一個簡單的使用net包并采用動態(tài)鏈接的示例:
// go-compilation/main-with-net.go
package main
import (
"fmt"
_ "net"
)
func main() {
fmt.Println("hello, world")
}
編譯后,我們查看一下文件屬性:
$go build -o helloworld-with-net main-with-net.go
$file helloworld-with-net
helloworld-with-net: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked (uses shared libs), not stripped
$ldd helloworld-with-net
linux-vdso.so.1 => (0x00007ffd75dfd000)
libresolv.so.2 => /lib64/libresolv.so.2 (0x00007fdda2cf9000)
libpthread.so.0 => /lib64/libpthread.so.0 (0x00007fdda2add000)
libc.so.6 => /lib64/libc.so.6 (0x00007fdda270f000)
/lib64/ld-linux-x86-64.so.2 (0x00007fdda2f13000)
我們看到C版本實現(xiàn)依賴了libresolv.so這個用于名稱解析的C庫。
由此可得,當Go在默認cgo開啟時,一旦依賴了標準庫中擁有C版本實現(xiàn)的包,比如os/user、net等,Go編譯器會采用動態(tài)鏈接的方式編譯Go可執(zhí)行程序。
2.2 顯式使用cgo調(diào)用外部C程序
如果使用cgo與外部C代碼交互,那么生成的可執(zhí)行文件必然會包含動態(tài)鏈接。下面我們來看一個調(diào)用cgo的簡單示例。
首先,建立一個簡單的C lib:
// go-compilation/my-c-lib
$tree my-c-lib
my-c-lib
├── Makefile
├── mylib.c
└── mylib.h
// go-compilation/my-c-lib/Makefile
.PHONY: all static
all:
gcc -c -fPIC -o mylib.o mylib.c
gcc -shared -o libmylib.so mylib.o
static:
gcc -c -fPIC -o mylib.o mylib.c
ar rcs libmylib.a mylib.o
// go-compilation/my-c-lib/mylib.h
#ifndef MYLIB_H
#define MYLIB_H
void hello();
int add(int a, int b);
#endif // MYLIB_H
// go-compilation/my-c-lib/mylib.c
#include <stdio.h>
void hello() {
printf("Hello from C!\n");
}
int add(int a, int b) {
return a + b;
}
執(zhí)行make all構(gòu)建出動態(tài)鏈接庫libmylib.so!接下來,我們編寫一個Go程序通過cgo調(diào)用libmylib.so中:
// go-compilation/main-with-call-myclib.go
package main
/*
#cgo CFLAGS: -I ./my-c-lib
#cgo LDFLAGS: -L ./my-c-lib -lmylib
#include "mylib.h"
*/
import "C"
import "fmt"
func main() {
// 調(diào)用 C 函數(shù)
C.hello()
// 調(diào)用 C 中的加法函數(shù)
result := C.add(3, 4)
fmt.Printf("Result of addition: %d\n", result)
}
編譯該源碼:
$go build -o helloworld-with-call-myclib main-with-call-myclib.go
通過ldd可以看到,可執(zhí)行文件helloworld-with-call-myclib是動態(tài)鏈接的,并依賴libmylib.so:
$ldd helloworld-with-call-myclib
linux-vdso.so.1 => (0x00007ffcc39d8000)
libmylib.so => not found
libpthread.so.0 => /lib64/libpthread.so.0 (0x00007f7166df5000)
libc.so.6 => /lib64/libc.so.6 (0x00007f7166a27000)
/lib64/ld-linux-x86-64.so.2 (0x00007f7167011000)
設(shè)置LD_LIBRARY_PATH(為了讓程序找到libmylib.so)并運行可執(zhí)行文件helloworld-with-call-myclib:
$ LD_LIBRARY_PATH=./my-c-lib:$LD_LIBRARY_PATH ./helloworld-with-call-myclib
Hello from C!
Result of addition: 7
2.3 使用了依賴cgo的第三方包
在日常開發(fā)中,我們經(jīng)常依賴一些第三方包,有些時候這些第三方包依賴cgo,比如mattn/go-sqlite3[6]。下面就是一個依賴go-sqlite3包的示例:
// go-compilation/go-sqlite3/main.go
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/mattn/go-sqlite3"
)
func main() {
// 打開數(shù)據(jù)庫(如果不存在,則創(chuàng)建)
db, err := sql.Open("sqlite3", "./test.db")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// 創(chuàng)建表
sqlStmt := `CREATE TABLE IF NOT EXISTS user (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT);`
_, err = db.Exec(sqlStmt)
if err != nil {
log.Fatalf("%q: %s\n", err, sqlStmt)
}
// 插入數(shù)據(jù)
_, err = db.Exec(`INSERT INTO user (name) VALUES (?)`, "Alice")
if err != nil {
log.Fatal(err)
}
// 查詢數(shù)據(jù)
rows, err := db.Query(`SELECT id, name FROM user;`)
if err != nil {
log.Fatal(err)
}
defer rows.Close()
for rows.Next() {
var id int
var name string
err = rows.Scan(&id, &name)
if err != nil {
log.Fatal(err)
}
fmt.Printf("%d: %s\n", id, name)
}
// 檢查查詢中的錯誤
if err = rows.Err(); err != nil {
log.Fatal(err)
}
}
編譯和運行該源碼:
$go build demo
$ldd demo
linux-vdso.so.1 => (0x00007ffe23d8e000)
libdl.so.2 => /lib64/libdl.so.2 (0x00007faf0ddef000)
libpthread.so.0 => /lib64/libpthread.so.0 (0x00007faf0dbd3000)
libc.so.6 => /lib64/libc.so.6 (0x00007faf0d805000)
/lib64/ld-linux-x86-64.so.2 (0x00007faf0dff3000)
$./demo
1: Alice
到這里,有些讀者可能會問一個問題:如果需要在上述依賴場景中生成靜態(tài)鏈接的Go程序,該怎么做呢?接下來,我們就來看看這個問題的解決細節(jié)。
3. 如何在上述情況下實現(xiàn)靜態(tài)鏈接?
到這里是不是有些燒腦了啊!我們針對上一節(jié)的三種情況,分別對應(yīng)來看一下靜態(tài)編譯的方案。
3.1 僅依賴標準包
在前面我們說過,之所以在使用os/user、net包時會在默認情況下采用動態(tài)鏈接,是因為Go使用了這兩個包對應(yīng)功能的C版實現(xiàn),如果要做靜態(tài)編譯,讓Go編譯器選擇它們的純Go版實現(xiàn)即可。那我們僅需要關(guān)閉CGO即可,以依賴標準庫os/user為例:
$CGO_ENABLED=0 go build -o helloworld-with-os-user-static main-with-os-user.go
$file helloworld-with-os-user-static
helloworld-with-os-user-static: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped
$ldd helloworld-with-os-user-static
不是動態(tài)可執(zhí)行文件
3.2 使用cgo調(diào)用外部c程序(靜態(tài)鏈接)
對于依賴cgo調(diào)用外部c的程序,我們要使用靜態(tài)鏈接就必須要求外部c庫提供靜態(tài)庫,因此,我們需要my-c-lib提供一份libmylib.a,這通過下面命令可以實現(xiàn)(或執(zhí)行make static):
$gcc -c -fPIC -o mylib.o mylib.c
$ar rcs libmylib.a mylib.o
有了libmylib.a后,我們還要讓Go程序靜態(tài)鏈接該.a文件,于是我們需要修改一下Go源碼中cgo鏈接的flag,加上靜態(tài)鏈接的選項:
// go-compilation/main-with-call-myclib-static.go
... ...
#cgo LDFLAGS: -static -L my-c-lib -lmylib
... ...
編譯鏈接并查看一下文件屬性:
$go build -o helloworld-with-call-myclib-static main-with-call-myclib-static.go
$file helloworld-with-call-myclib-static
helloworld-with-call-myclib-static: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 2.6.32, BuildID[sha1]=b3da3ed817d0d04230460069b048cab5f5bfc3b9, not stripped
我們得到了預期的結(jié)果!
3.3 依賴使用cgo的外部go包(靜態(tài)鏈接)
最麻煩的是這類情況,要想實現(xiàn)靜態(tài)鏈接,我們需要找出外部go依賴的所有c庫的.a文件(靜態(tài)共享庫)。以我們的go-sqlite3示例為例,go-sqlite3是sqlite庫的go binding,它依賴sqlite庫,同時所有第三方c庫都依賴libc,我們還要準備一份libc的.a文件,下面我們就先安裝這些:
$yum install -y gcc glibc-static sqlite-devel
... ...
已安裝:
sqlite-devel.x86_64 0:3.7.17-8.el7_7.1
更新完畢:
glibc-static.x86_64 0:2.17-326.el7_9.3
接下來,我們就來以靜態(tài)鏈接的方式在go-compilation/go-sqlite3-static下編譯一下:
$go build -tags 'sqlite_omit_load_extension' -ldflags '-linkmode external -extldflags "-static"' demo
$file ./demo
./demo: ELF 64-bit LSB executable, x86-64, version 1 (GNU/Linux), statically linked, for GNU/Linux 2.6.32, BuildID[sha1]=c779f5c3eaa945d916de059b56d94c23974ce61c, not stripped
這里命令行中的-tags 'sqlite_omit_load_extension'用于禁用SQLite3的動態(tài)加載功能,確保更好的靜態(tài)鏈接兼容性。而-ldflags '-linkmode external -extldflags "-static"'的含義是使用外部鏈接器(比如gcc linker),并強制靜態(tài)鏈接所有庫。
我們再看完略燒腦的幾個細節(jié)后,再來看一個略輕松的話題。
4. Go編譯出的可執(zhí)行文件過大,能優(yōu)化嗎?
Go編譯出的二進制文件一般較大,一個簡單的“Hello World”程序通常在2MB左右:
$ls -lh helloworld-default
-rwxr-xr-x 1 root root 2.1M 11月 3 10:39 helloworld-default
這一方面是因為Go將整個runtime都編譯到可執(zhí)行文件中了,另一方面也是因為Go靜態(tài)編譯所致。那么在默認情況下,Go二進制文件的大小還有優(yōu)化空間么?方法不多,有兩種可以嘗試:
- 去除符號表和調(diào)試信息
在編譯時使用-ldflags="-s -w"標志可以去除符號表和調(diào)試符號,其中-s用于去掉符號表和調(diào)試信息,-w用于去掉DWARF調(diào)試信息,這樣能顯著減小文件體積。以helloworld為例,可執(zhí)行文件的size減少了近四成:
$go build -ldflags="-s -w" -o helloworld-default-nosym main.go
$ls -l
-rwxr-xr-x 1 root root 2124504 11月 3 10:39 helloworld-default
-rwxr-xr-x 1 root root 1384600 11月 3 13:34 helloworld-default-nosym
- 使用tinygo
TinyGo[7]是一個Go語言的編譯器,它專為資源受限的環(huán)境而設(shè)計,例如微控制器、WebAssembly和其他嵌入式設(shè)備。TinyGo的目標是提供一個輕量級的、能在小型設(shè)備上運行的Go運行時,同時盡可能支持Go語言的特性。tinygo的一大優(yōu)點就是生成的二進制文件通常比標準Go編譯器生成的文件小得多:
$tinygo build -o helloworld-tinygo main.go
$ls -l
總用量 2728
-rwxr-xr-x 1 root root 2128909 11月 5 05:43 helloworld-default*
-rwxr-xr-x 1 root root 647600 11月 5 05:45 helloworld-tinygo*
我們看到:tinygo生成的可執(zhí)行文件的size僅是原來的30%。
注:雖然TinyGo在特定場景(如IoT和嵌入式開發(fā))中非常有用,但在常規(guī)服務(wù)器環(huán)境中,由于生態(tài)系統(tǒng)兼容性、性能、調(diào)試支持等方面的限制,可能并不是最佳選擇。對于需要高并發(fā)、復雜功能和良好調(diào)試支持的應(yīng)用,標準Go仍然是更合適的選擇。
注:這里使用的tinygo為0.34.0版本。
5. 未使用的符號是否會被編譯到Go二進制文件中?
到這里,相信讀者心中也都會縈繞一些問題:到底哪些符號被編譯到最終的Go二進制文件中了呢?未使用的符號是否會被編譯到Go二進制文件中嗎?在這一小節(jié)中,我們就來探索一下。
出于對Go的了解,我們已經(jīng)知道無論是GOPATH時代,還是Go module時代,Go的編譯單元始終是包(package),一個包(無論包中包含多少個Go源文件)都會作為一個編譯單元被編譯為一個目標文件(.a),然后Go鏈接器會將多個目標文件鏈接在一起生成可執(zhí)行文件,因此如果一個包被依賴,那么它就會進入到Go二進制文件中,它內(nèi)部的符號也會進入到Go二進制文件中。
那么問題來了!是否被依賴包中的所有符號都會被放到最終的可執(zhí)行文件中呢?我們以最簡單的helloworld-default為例,它依賴fmt包,并調(diào)用了fmt包的Println函數(shù),我們看看Println這個符號是否會出現(xiàn)在最終的可執(zhí)行文件中:
$nm -a helloworld-default | grep "Println"
000000000048eba0 T fmt.(*pp).doPrintln
居然沒有!我們初步懷疑是inline優(yōu)化在作祟。接下來,關(guān)閉優(yōu)化再來試試:
$go build -o helloworld-default-noinline -gcflags='-l -N' main.go
$nm -a helloworld-default-noinline | grep "Println"
000000000048ec00 T fmt.(*pp).doPrintln
0000000000489ee0 T fmt.Println
看來的確如此!不過當使用"fmt."去過濾helloworld-default-noinline的所有符號時,我們發(fā)現(xiàn)fmt包的一些常見的符號并未包含在其中,比如Printf、Fprintf、Scanf等。
這是因為Go編譯器的一個重要特性:死碼消除(dead code elimination),即編譯器會將未使用的代碼和數(shù)據(jù)從最終的二進制文件中剔除。
我們再來繼續(xù)探討一個衍生問題:如果Go源碼使用空導入方式導入了一個包,那么這個包是否會被編譯到Go二進制文件中呢?其實道理是一樣的,如果用到了里面的符號,就會存在,否則不會。
以空導入os/user為例,即便在CGO_ENABLED=0的情況下,因為沒有使用os/user中的任何符號,在最終的二進制文件中也不會包含user包:
$CGO_ENABLED=0 go build -o helloworld-with-os-user-noinline -gcflags='-l -N' main-with-os-user.go
[root@iZ2ze18rmx2avqb5xgb4omZ helloworld]# nm -a helloworld-with-os-user-noinline |grep user
0000000000551ac0 B runtime.userArenaState
但是如果是帶有init函數(shù)的包,且init函數(shù)中調(diào)用了同包其他符號的情況呢?我們以expvar包為例看一下:
// go-compilation/main-with-expvar.go
package main
import (
_ "expvar"
"fmt"
)
func main() {
fmt.Println("hello, world")
}
編譯并查看一下其中的符號:
$go build -o helloworld-with-expvar-noinline -gcflags='-l -N' main-with-expvar.go
$nm -a helloworld-with-expvar-noinline|grep expvar
0000000000556480 T expvar.appendJSONQuote
00000000005562e0 T expvar.cmdline
00000000005561c0 T expvar.expvarHandler
00000000005568e0 T expvar.(*Func).String
0000000000555ee0 T expvar.Func.String
00000000005563a0 T expvar.init.0
00000000006e0560 D expvar..inittask
0000000000704550 d expvar..interfaceSwitch.0
... ...
除此之外,如果一個包即便沒有init函數(shù),但有需要初始化的全局變量,比如crypto包的hashes:
// $GOROOT/src/crypto/crypto.go
var hashes = make([]func() hash.Hash, maxHash)
crypto包的相關(guān)如何也會進入最終的可執(zhí)行文件中,大家自己動手不妨試試。下面是我得到的一些輸出:
$go build -o helloworld-with-crypto-noinline -gcflags='-l -N' main-with-crypto.go
$nm -a helloworld-with-crypto-noinline|grep crypto
00000000005517b0 B crypto.hashes
000000000048ee60 T crypto.init
0000000000547280 D crypto..inittask
有人會問:os/user包也有一些全局變量啊,為什么這些符號沒有被包含在可執(zhí)行文件中呢?比如:
// $GOROOT/src/os/user/user.go
var (
userImplemented = true
groupImplemented = true
groupListImplemented = true
)
這就要涉及Go包初始化的邏輯了。我們看到crypto包包含在可執(zhí)行文件中的符號中有crypto.init和crypto..inittask這兩個符號,顯然這不是crypto包代碼中的符號,而是Go編譯器為crypto包自動生成的init函數(shù)和inittask結(jié)構(gòu)。
Go編譯器會為每個包生成一個init函數(shù),即使包中沒有顯式定義init函數(shù),同時每個包都會有一個inittask結(jié)構(gòu)[8],用于運行時的包初始化系統(tǒng)。當然這么說也不足夠精確,如果一個包沒有init函數(shù)、需要初始化的全局變量或其他需要運行時初始化的內(nèi)容,則編譯器不會為其生成init函數(shù)和inittask。比如上面的os/user包。
os/user包確實有上述全局變量的定義,但是這些變量是在編譯期就可以確定值的常量布爾值,而且未被包外引用或在包內(nèi)用于影響控制流。Go編譯器足夠智能,能夠判斷出這些初始化是"無副作用的",不需要在運行時進行初始化。只有真正需要運行時初始化的包才會生成init和inittask。這也解釋了為什么空導入os/user包時沒有相關(guān)的init和inittask符號,而crypto、expvar包有的init.0和inittask符號。
6. 如何快速判斷Go項目是否依賴cgo?
在使用開源Go項目時,我們經(jīng)常會遇到項目文檔中沒有明確說明是否依賴Cgo的情況。這種情況下,如果我們需要在特定環(huán)境(比如CGO_ENABLED=0)下使用該項目,就需要事先判斷項目是否依賴Cgo,有些時候還要快速地給出判斷。
那究竟是否可以做到這種快速判斷呢?我們先來看看一些常見的作法。
第一類作法是源碼層面的靜態(tài)分析。最直接的方式是檢查源碼中是否存在import "C"語句,這種引入方式是CGO使用的顯著標志。
// 在項目根目錄中執(zhí)行
$grep -rn 'import "C"' .
這個命令會遞歸搜索當前目錄下所有文件,顯示包含import "C"的行號和文件路徑,幫助快速定位CGO的使用位置。
此外,CGO項目通常包含特殊的編譯指令,這些指令以注釋形式出現(xiàn)在源碼中,比如前面見識過的#cgo CFLAGS、#cgo LDFLAGS等,通過對這些編譯指令的檢測,同樣可以來判斷項目是否依賴CGO。
不過第一類作法并不能查找出Go項目的依賴包是否依賴cgo。而找出直接依賴或間接依賴是否依賴cgo,我們需要工具幫忙,比如使用Go工具鏈提供的命令分析項目依賴:
$go list -deps -f '{{.ImportPath}}: {{.CgoFiles}}' ./... | grep -v '\[\]'
其中ImportPath是依賴包的導入路徑,而CgoFiles則是依賴中包含import "C"的Go源文件。我們以go-sqlite3那個依賴cgo的示例來驗證一下:
// cd go-compilation/go-sqlite3
$go list -deps -f '{{.ImportPath}}: {{.CgoFiles}}' ./... | grep -v '\[\]'
runtime/cgo: [cgo.go]
github.com/mattn/go-sqlite3: [backup.go callback.go error.go sqlite3.go sqlite3_context.go sqlite3_load_extension.go sqlite3_opt_serialize.go sqlite3_opt_userauth_omit.go sqlite3_other.go sqlite3_type.go]
用空導入os/user的示例再來看一下:
$go list -deps -f '{{.ImportPath}}: {{.CgoFiles}}' main-with-os-user.go | grep -v '\[\]'
runtime/cgo: [cgo.go]
os/user: [cgo_lookup_cgo.go getgrouplist_unix.go]
我們知道os/user有純go和C版本兩個實現(xiàn),因此上述判斷只能說“對了一半”,當我關(guān)閉CGO_ENABLED時,Go編譯器不會使用基于cgo的C版實現(xiàn)。
那是否在禁用cgo的前提下對源碼進行一次編譯便能驗證項目是否對cgo有依賴呢?這樣做顯然談不上是一種“快速”的方法,那是否有效呢?我們來對上面的go-sqlite3項目做一個測試,我們在關(guān)閉CGO_ENABLED時,編譯一下該示例:
// cd go-compilation/go-sqlite3
$ CGO_ENABLED=0 go build demo
我們看到,Go編譯器并未報錯!似乎該項目不需要cgo! 但真的是這樣嗎?我們運行一下編譯后的demo可執(zhí)行文件:
$ ./demo
2024/11/03 22:10:36 "Binary was compiled with 'CGO_ENABLED=0', go-sqlite3 requires cgo to work. This is a stub": CREATE TABLE IF NOT EXISTS user (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT);
我們看到成功編譯出來的程序居然出現(xiàn)運行時錯誤,提示需要cgo!
到這里,沒有一種方法可以快速、精確的給出項目是否依賴cgo的判斷。也許判斷Go項目是否依賴CGO并沒有捷徑,需要從源碼分析、依賴檢查和構(gòu)建測試等多個維度進行。
7. 小結(jié)
在本文中,我們深入探討了Go語言編譯過程中的幾個重要細節(jié),尤其是在靜態(tài)鏈接和動態(tài)鏈接的選擇上。通過具體示例,我們了解到:
- 默認鏈接方式:盡管CGO_ENABLED默認值為1,Go編譯器在大多數(shù)情況下會采用靜態(tài)鏈接,只有在依賴特定的C庫或標準庫包時,才會切換到動態(tài)鏈接。
- 動態(tài)鏈接的條件:我們討論了幾種情況下Go會默認生成動態(tài)鏈接的可執(zhí)行文件,包括依賴使用C實現(xiàn)的標準庫包、顯式使用cgo調(diào)用外部C程序,以及使用依賴cgo的第三方包。
- 實現(xiàn)靜態(tài)鏈接:對于需要動態(tài)鏈接的場景,我們也提供了將其轉(zhuǎn)為靜態(tài)鏈接的解決方案,包括關(guān)閉CGO、使用靜態(tài)庫,以及處理依賴cgo的外部包的靜態(tài)鏈接問題。
- 二進制文件優(yōu)化:我們還介紹了如何通過去除符號表和使用TinyGo等方法來優(yōu)化生成的Go二進制文件的大小,以滿足不同場景下的需求。
- 符號編譯與死碼消除:最后,我們探討了未使用的符號是否會被編譯到最終的二進制文件中,并解釋了Go編譯器的死碼消除機制。
通過這些細節(jié)探討,我希望能夠幫助大家更好地理解Go編譯的復雜性,并在實際開發(fā)中做出更明智的選擇,亦能在面對Go編譯相關(guān)問題時,提供有效的解決方案。
本文涉及的源碼可以在這里[9]下載。
參考資料
[1] 本文永久鏈接: https://tonybai.com/2024/mm/dd/some-details-about-go-compilation
[2] Go 1.23.0: https://tonybai.com/2024/08/19/some-changes-in-go-1-23/
[3] 動態(tài)鏈接: https://tonybai.com/2011/07/07/also-talk-about-shared-library-2/
[4] os/user包: https://pkg.go.dev/os/user
[5] net包: https://pkg.go.dev/net
[6] mattn/go-sqlite3: https://github.com/mattn/go-sqlite3
[7] TinyGo: https://github.com/tinygo-org/tinygo/
[8] 每個包都會有一個inittask結(jié)構(gòu): https://go.dev/src/cmd/compile/internal/pkginit/init.go
[9] 這里: https://github.com/bigwhite/experiments/tree/master/go-compilation