Go編譯的幾個(gè)細(xì)節(jié),連專(zhuān)家也要停下來(lái)想想
在Go開(kāi)發(fā)中,編譯相關(guān)的問(wèn)題看似簡(jiǎn)單,但實(shí)則蘊(yùn)含許多細(xì)節(jié)。有時(shí),即使是Go專(zhuān)家也需要停下來(lái),花時(shí)間思考答案或親自驗(yàn)證。本文將通過(guò)幾個(gè)具體問(wèn)題,和大家一起探討Go編譯過(guò)程中的一些你可能之前未曾關(guān)注的細(xì)節(jié)。
注:本文示例使用的環(huán)境為Go 1.23.0、Linux Kernel 3.10.0和CentOS 7.9。
1. Go編譯默認(rèn)采用靜態(tài)鏈接還是動(dòng)態(tài)鏈接?
我們來(lái)看第一個(gè)問(wèn)題:Go編譯默認(rèn)采用靜態(tài)鏈接還是動(dòng)態(tài)鏈接呢?
很多人脫口而出:動(dòng)態(tài)鏈接[3],因?yàn)镃GO_ENABLED默認(rèn)值為1,即開(kāi)啟Cgo。也有些人會(huì)說(shuō):“其實(shí)Go編譯器默認(rèn)是靜態(tài)鏈接的,只有在使用C語(yǔ)言庫(kù)時(shí)才會(huì)動(dòng)態(tài)鏈接”。那么到底哪個(gè)是正確的呢?
我們來(lái)看一個(gè)具體的示例。但在這之前,我們要承認(rèn)一個(gè)事實(shí),那就是CGO_ENABLED默認(rèn)值為1,你可以通過(guò)下面命令來(lái)驗(yàn)證這一點(diǎn):
$go env|grep CGO_ENABLED
CGO_ENABLED='1'
驗(yàn)證Go默認(rèn)究竟是哪種鏈接,我們寫(xiě)一個(gè)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
不是動(dòng)態(tài)可執(zhí)行文件
我們看到,雖然CGO_ENABLED=1,但默認(rèn)情況下,Go構(gòu)建出的helloworld程序是靜態(tài)鏈接的(statically linked)。
那么默認(rèn)情況下,Go編譯器是否都會(huì)采用靜態(tài)鏈接的方式來(lá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不同的是,這段代碼多了一行包的空導(dǎo)入,導(dǎo)入的是os/user這個(gè)包。
編譯這段代碼,我們得到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)榱藙?dòng)態(tài)鏈接,同時(shí)這也是如何編譯出一個(gè)hello world版的動(dòng)態(tài)鏈接Go程序的答案。
通過(guò)nm命令我們還可以查看Go程序依賴(lài)了哪些C庫(kù)的符號(hào):
$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
由此,我們可以得到一個(gè)結(jié)論,在默認(rèn)情況下(CGO_ENABLED=1),Go會(huì)盡力使用靜態(tài)鏈接的方式,但在某些情況下,會(huì)采用動(dòng)態(tài)鏈接。那么究竟在哪些情況下會(huì)默認(rèn)生成動(dòng)態(tài)鏈接的程序呢?我們繼續(xù)往下看。
2. 在何種情況下默認(rèn)會(huì)生成動(dòng)態(tài)鏈接的Go程序?
在以下幾種情況下,Go編譯器會(huì)默認(rèn)(CGO_ENABLED=1)生成動(dòng)態(tài)鏈接的可執(zhí)行文件,我們逐一來(lái)看一下。
2.1 一些使用C實(shí)現(xiàn)的標(biāo)準(zhǔn)庫(kù)包
根據(jù)上述示例,我們可以看到,在某些情況下,即使只依賴(lài)標(biāo)準(zhǔn)庫(kù),Go 仍會(huì)在CGO_ENABLED=1的情況下采用動(dòng)態(tài)鏈接。這是因?yàn)榇a依賴(lài)的標(biāo)準(zhǔn)庫(kù)包使用了C版本的實(shí)現(xiàn)。雖然這種情況并不常見(jiàn),但os/user包[4]和net包[5]是兩個(gè)典型的例子。
os/user包的示例在前面我們已經(jīng)見(jiàn)識(shí)過(guò)了。user包允許開(kāi)發(fā)者通過(guò)名稱(chēng)或ID查找用戶(hù)賬戶(hù)。對(duì)于大多數(shù)Unix系統(tǒng)(包括linux),該包內(nèi)部有兩種版本的實(shí)現(xiàn),用于解析用戶(hù)和組ID到名稱(chēng),并列出附加組ID。一種是用純Go編寫(xiě),解析/etc/passwd和/etc/group文件。另一種是基于cgo的,依賴(lài)于標(biāo)準(zhǔn)C庫(kù)(libc)中的例程,如getpwuid_r、getgrnam_r和getgrouplist。當(dāng)cgo可用(CGO_ENABLED=1),并且特定平臺(tái)的libc實(shí)現(xiàn)了所需的例程時(shí),將使用基于cgo的(libc支持的)代碼,即采用動(dòng)態(tài)鏈接方式。
同樣,net包在名稱(chēng)解析(Name Resolution,即域名或主機(jī)名對(duì)應(yīng)IP查找)上針對(duì)大多數(shù)Unix系統(tǒng)也有兩個(gè)版本的實(shí)現(xiàn):一個(gè)是純Go版本,另一個(gè)是基于C的版本。C版本會(huì)在cgo可用且特定平臺(tái)實(shí)現(xiàn)了相關(guān)C函數(shù)(比如getaddrinfo和getnameinfo等)時(shí)使用。
下面是一個(gè)簡(jiǎn)單的使用net包并采用動(dòng)態(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版本實(shí)現(xiàn)依賴(lài)了libresolv.so這個(gè)用于名稱(chēng)解析的C庫(kù)。
由此可得,當(dāng)Go在默認(rèn)cgo開(kāi)啟時(shí),一旦依賴(lài)了標(biāo)準(zhǔn)庫(kù)中擁有C版本實(shí)現(xiàn)的包,比如os/user、net等,Go編譯器會(huì)采用動(dòng)態(tài)鏈接的方式編譯Go可執(zhí)行程序。
2.2 顯式使用cgo調(diào)用外部C程序
如果使用cgo與外部C代碼交互,那么生成的可執(zhí)行文件必然會(huì)包含動(dòng)態(tài)鏈接。下面我們來(lái)看一個(gè)調(diào)用cgo的簡(jiǎn)單示例。
首先,建立一個(gè)簡(jiǎn)單的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)建出動(dòng)態(tài)鏈接庫(kù)libmylib.so!接下來(lái),我們編寫(xiě)一個(gè)Go程序通過(guò)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
通過(guò)ldd可以看到,可執(zhí)行文件helloworld-with-call-myclib是動(dòng)態(tài)鏈接的,并依賴(là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)并運(yùn)行可執(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 使用了依賴(lài)cgo的第三方包
在日常開(kāi)發(fā)中,我們經(jīng)常依賴(lài)一些第三方包,有些時(shí)候這些第三方包依賴(lài)cgo,比如mattn/go-sqlite3[6]。下面就是一個(gè)依賴(lài)go-sqlite3包的示例:
// go-compilation/go-sqlite3/main.go
package main
import (
"database/sql"
"fmt"
"log"
_ "github.com/mattn/go-sqlite3"
)
func main() {
// 打開(kāi)數(shù)據(jù)庫(kù)(如果不存在,則創(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)
}
// 查詢(xún)數(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)
}
// 檢查查詢(xún)中的錯(cuò)誤
if err = rows.Err(); err != nil {
log.Fatal(err)
}
}
編譯和運(yùn)行該源碼:
$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
到這里,有些讀者可能會(huì)問(wèn)一個(gè)問(wèn)題:如果需要在上述依賴(lài)場(chǎng)景中生成靜態(tài)鏈接的Go程序,該怎么做呢?接下來(lái),我們就來(lái)看看這個(gè)問(wèn)題的解決細(xì)節(jié)。
3. 如何在上述情況下實(shí)現(xiàn)靜態(tài)鏈接?
到這里是不是有些燒腦了啊!我們針對(duì)上一節(jié)的三種情況,分別對(duì)應(yīng)來(lái)看一下靜態(tài)編譯的方案。
3.1 僅依賴(lài)標(biāo)準(zhǔn)包
在前面我們說(shuō)過(guò),之所以在使用os/user、net包時(shí)會(huì)在默認(rèn)情況下采用動(dòng)態(tài)鏈接,是因?yàn)镚o使用了這兩個(gè)包對(duì)應(yīng)功能的C版實(shí)現(xiàn),如果要做靜態(tài)編譯,讓Go編譯器選擇它們的純Go版實(shí)現(xiàn)即可。那我們僅需要關(guān)閉CGO即可,以依賴(lài)標(biāo)準(zhǔn)庫(kù)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
不是動(dòng)態(tài)可執(zhí)行文件
3.2 使用cgo調(diào)用外部c程序(靜態(tài)鏈接)
對(duì)于依賴(lài)cgo調(diào)用外部c的程序,我們要使用靜態(tài)鏈接就必須要求外部c庫(kù)提供靜態(tài)庫(kù),因此,我們需要my-c-lib提供一份libmylib.a,這通過(guò)下面命令可以實(shí)現(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)鏈接的選項(xiàng):
// 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
我們得到了預(yù)期的結(jié)果!
3.3 依賴(lài)使用cgo的外部go包(靜態(tài)鏈接)
最麻煩的是這類(lèi)情況,要想實(shí)現(xiàn)靜態(tài)鏈接,我們需要找出外部go依賴(lài)的所有c庫(kù)的.a文件(靜態(tài)共享庫(kù))。以我們的go-sqlite3示例為例,go-sqlite3是sqlite庫(kù)的go binding,它依賴(lài)sqlite庫(kù),同時(shí)所有第三方c庫(kù)都依賴(lài)libc,我們還要準(zhǔn)備一份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
接下來(lái),我們就來(lái)以靜態(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的動(dòng)態(tài)加載功能,確保更好的靜態(tài)鏈接兼容性。而-ldflags '-linkmode external -extldflags "-static"'的含義是使用外部鏈接器(比如gcc linker),并強(qiáng)制靜態(tài)鏈接所有庫(kù)。
我們?cè)倏赐曷詿X的幾個(gè)細(xì)節(jié)后,再來(lái)看一個(gè)略輕松的話題。
4. Go編譯出的可執(zhí)行文件過(guò)大,能優(yōu)化嗎?
Go編譯出的二進(jìn)制文件一般較大,一個(gè)簡(jiǎn)單的“Hello World”程序通常在2MB左右:
$ls -lh helloworld-default
-rwxr-xr-x 1 root root 2.1M 11月 3 10:39 helloworld-default
這一方面是因?yàn)镚o將整個(gè)runtime都編譯到可執(zhí)行文件中了,另一方面也是因?yàn)镚o靜態(tài)編譯所致。那么在默認(rèn)情況下,Go二進(jìn)制文件的大小還有優(yōu)化空間么?方法不多,有兩種可以嘗試:
- 去除符號(hào)表和調(diào)試信息
在編譯時(shí)使用-ldflags="-s -w"標(biāo)志可以去除符號(hào)表和調(diào)試符號(hào),其中-s用于去掉符號(hào)表和調(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]是一個(gè)Go語(yǔ)言的編譯器,它專(zhuān)為資源受限的環(huán)境而設(shè)計(jì),例如微控制器、WebAssembly和其他嵌入式設(shè)備。TinyGo的目標(biāo)是提供一個(gè)輕量級(jí)的、能在小型設(shè)備上運(yùn)行的Go運(yùn)行時(shí),同時(shí)盡可能支持Go語(yǔ)言的特性。tinygo的一大優(yōu)點(diǎn)就是生成的二進(jìn)制文件通常比標(biāo)準(zhǔn)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僅是原來(lái)的30%。
注:雖然TinyGo在特定場(chǎng)景(如IoT和嵌入式開(kāi)發(fā))中非常有用,但在常規(guī)服務(wù)器環(huán)境中,由于生態(tài)系統(tǒng)兼容性、性能、調(diào)試支持等方面的限制,可能并不是最佳選擇。對(duì)于需要高并發(fā)、復(fù)雜功能和良好調(diào)試支持的應(yīng)用,標(biāo)準(zhǔn)Go仍然是更合適的選擇。
注:這里使用的tinygo為0.34.0版本。
5. 未使用的符號(hào)是否會(huì)被編譯到Go二進(jìn)制文件中?
到這里,相信讀者心中也都會(huì)縈繞一些問(wèn)題:到底哪些符號(hào)被編譯到最終的Go二進(jìn)制文件中了呢?未使用的符號(hào)是否會(huì)被編譯到Go二進(jìn)制文件中嗎?在這一小節(jié)中,我們就來(lái)探索一下。
出于對(duì)Go的了解,我們已經(jīng)知道無(wú)論是GOPATH時(shí)代,還是Go module時(shí)代,Go的編譯單元始終是包(package),一個(gè)包(無(wú)論包中包含多少個(gè)Go源文件)都會(huì)作為一個(gè)編譯單元被編譯為一個(gè)目標(biāo)文件(.a),然后Go鏈接器會(huì)將多個(gè)目標(biāo)文件鏈接在一起生成可執(zhí)行文件,因此如果一個(gè)包被依賴(lài),那么它就會(huì)進(jìn)入到Go二進(jìn)制文件中,它內(nèi)部的符號(hào)也會(huì)進(jìn)入到Go二進(jìn)制文件中。
那么問(wèn)題來(lái)了!是否被依賴(lài)包中的所有符號(hào)都會(huì)被放到最終的可執(zhí)行文件中呢?我們以最簡(jiǎn)單的helloworld-default為例,它依賴(lài)fmt包,并調(diào)用了fmt包的Println函數(shù),我們看看Println這個(gè)符號(hào)是否會(huì)出現(xiàn)在最終的可執(zhí)行文件中:
$nm -a helloworld-default | grep "Println"
000000000048eba0 T fmt.(*pp).doPrintln
居然沒(méi)有!我們初步懷疑是inline優(yōu)化在作祟。接下來(lái),關(guān)閉優(yōu)化再來(lái)試試:
$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
看來(lái)的確如此!不過(guò)當(dāng)使用"fmt."去過(guò)濾helloworld-default-noinline的所有符號(hào)時(shí),我們發(fā)現(xiàn)fmt包的一些常見(jiàn)的符號(hào)并未包含在其中,比如Printf、Fprintf、Scanf等。
這是因?yàn)镚o編譯器的一個(gè)重要特性:死碼消除(dead code elimination),即編譯器會(huì)將未使用的代碼和數(shù)據(jù)從最終的二進(jìn)制文件中剔除。
我們?cè)賮?lái)繼續(xù)探討一個(gè)衍生問(wèn)題:如果Go源碼使用空導(dǎo)入方式導(dǎo)入了一個(gè)包,那么這個(gè)包是否會(huì)被編譯到Go二進(jìn)制文件中呢?其實(shí)道理是一樣的,如果用到了里面的符號(hào),就會(huì)存在,否則不會(huì)。
以空導(dǎo)入os/user為例,即便在CGO_ENABLED=0的情況下,因?yàn)闆](méi)有使用os/user中的任何符號(hào),在最終的二進(jìn)制文件中也不會(huì)包含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)用了同包其他符號(hào)的情況呢?我們以expvar包為例看一下:
// go-compilation/main-with-expvar.go
package main
import (
_ "expvar"
"fmt"
)
func main() {
fmt.Println("hello, world")
}
編譯并查看一下其中的符號(hào):
$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
... ...
除此之外,如果一個(gè)包即便沒(méi)有init函數(shù),但有需要初始化的全局變量,比如crypto包的hashes:
// $GOROOT/src/crypto/crypto.go
var hashes = make([]func() hash.Hash, maxHash)
crypto包的相關(guān)如何也會(huì)進(jìn)入最終的可執(zhí)行文件中,大家自己動(dòng)手不妨試試。下面是我得到的一些輸出:
$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
有人會(huì)問(wèn):os/user包也有一些全局變量啊,為什么這些符號(hào)沒(méi)有被包含在可執(zhí)行文件中呢?比如:
// $GOROOT/src/os/user/user.go
var (
userImplemented = true
groupImplemented = true
groupListImplemented = true
)
這就要涉及Go包初始化的邏輯了。我們看到crypto包包含在可執(zhí)行文件中的符號(hào)中有crypto.init和crypto..inittask這兩個(gè)符號(hào),顯然這不是crypto包代碼中的符號(hào),而是Go編譯器為crypto包自動(dòng)生成的init函數(shù)和inittask結(jié)構(gòu)。
Go編譯器會(huì)為每個(gè)包生成一個(gè)init函數(shù),即使包中沒(méi)有顯式定義init函數(shù),同時(shí)每個(gè)包都會(huì)有一個(gè)inittask結(jié)構(gòu)[8],用于運(yùn)行時(shí)的包初始化系統(tǒng)。當(dāng)然這么說(shuō)也不足夠精確,如果一個(gè)包沒(méi)有init函數(shù)、需要初始化的全局變量或其他需要運(yùn)行時(shí)初始化的內(nèi)容,則編譯器不會(huì)為其生成init函數(shù)和inittask。比如上面的os/user包。
os/user包確實(shí)有上述全局變量的定義,但是這些變量是在編譯期就可以確定值的常量布爾值,而且未被包外引用或在包內(nèi)用于影響控制流。Go編譯器足夠智能,能夠判斷出這些初始化是"無(wú)副作用的",不需要在運(yùn)行時(shí)進(jìn)行初始化。只有真正需要運(yùn)行時(shí)初始化的包才會(huì)生成init和inittask。這也解釋了為什么空導(dǎo)入os/user包時(shí)沒(méi)有相關(guān)的init和inittask符號(hào),而crypto、expvar包有的init.0和inittask符號(hào)。
6. 如何快速判斷Go項(xiàng)目是否依賴(lài)cgo?
在使用開(kāi)源Go項(xiàng)目時(shí),我們經(jīng)常會(huì)遇到項(xiàng)目文檔中沒(méi)有明確說(shuō)明是否依賴(lài)Cgo的情況。這種情況下,如果我們需要在特定環(huán)境(比如CGO_ENABLED=0)下使用該項(xiàng)目,就需要事先判斷項(xiàng)目是否依賴(lài)Cgo,有些時(shí)候還要快速地給出判斷。
那究竟是否可以做到這種快速判斷呢?我們先來(lái)看看一些常見(jiàn)的作法。
第一類(lèi)作法是源碼層面的靜態(tài)分析。最直接的方式是檢查源碼中是否存在import "C"語(yǔ)句,這種引入方式是CGO使用的顯著標(biāo)志。
// 在項(xiàng)目根目錄中執(zhí)行
$grep -rn 'import "C"' .
這個(gè)命令會(huì)遞歸搜索當(dāng)前目錄下所有文件,顯示包含import "C"的行號(hào)和文件路徑,幫助快速定位CGO的使用位置。
此外,CGO項(xiàng)目通常包含特殊的編譯指令,這些指令以注釋形式出現(xiàn)在源碼中,比如前面見(jiàn)識(shí)過(guò)的#cgo CFLAGS、#cgo LDFLAGS等,通過(guò)對(duì)這些編譯指令的檢測(cè),同樣可以來(lái)判斷項(xiàng)目是否依賴(lài)CGO。
不過(guò)第一類(lèi)作法并不能查找出Go項(xiàng)目的依賴(lài)包是否依賴(lài)cgo。而找出直接依賴(lài)或間接依賴(lài)是否依賴(lài)cgo,我們需要工具幫忙,比如使用Go工具鏈提供的命令分析項(xiàng)目依賴(lài):
$go list -deps -f '{{.ImportPath}}: {{.CgoFiles}}' ./... | grep -v '\[\]'
其中ImportPath是依賴(lài)包的導(dǎo)入路徑,而CgoFiles則是依賴(lài)中包含import "C"的Go源文件。我們以go-sqlite3那個(gè)依賴(lài)cgo的示例來(lái)驗(yàn)證一下:
// 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]
用空導(dǎo)入os/user的示例再來(lái)看一下:
$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版本兩個(gè)實(shí)現(xiàn),因此上述判斷只能說(shuō)“對(duì)了一半”,當(dāng)我關(guān)閉CGO_ENABLED時(shí),Go編譯器不會(huì)使用基于cgo的C版實(shí)現(xiàn)。
那是否在禁用cgo的前提下對(duì)源碼進(jìn)行一次編譯便能驗(yàn)證項(xiàng)目是否對(duì)cgo有依賴(lài)呢?這樣做顯然談不上是一種“快速”的方法,那是否有效呢?我們來(lái)對(duì)上面的go-sqlite3項(xiàng)目做一個(gè)測(cè)試,我們?cè)陉P(guān)閉CGO_ENABLED時(shí),編譯一下該示例:
// cd go-compilation/go-sqlite3
$ CGO_ENABLED=0 go build demo
我們看到,Go編譯器并未報(bào)錯(cuò)!似乎該項(xiàng)目不需要cgo! 但真的是這樣嗎?我們運(yùn)行一下編譯后的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);
我們看到成功編譯出來(lái)的程序居然出現(xiàn)運(yùn)行時(shí)錯(cuò)誤,提示需要cgo!
到這里,沒(méi)有一種方法可以快速、精確的給出項(xiàng)目是否依賴(lài)cgo的判斷。也許判斷Go項(xiàng)目是否依賴(lài)CGO并沒(méi)有捷徑,需要從源碼分析、依賴(lài)檢查和構(gòu)建測(cè)試等多個(gè)維度進(jìn)行。
7. 小結(jié)
在本文中,我們深入探討了Go語(yǔ)言編譯過(guò)程中的幾個(gè)重要細(xì)節(jié),尤其是在靜態(tài)鏈接和動(dòng)態(tài)鏈接的選擇上。通過(guò)具體示例,我們了解到:
- 默認(rèn)鏈接方式:盡管CGO_ENABLED默認(rèn)值為1,Go編譯器在大多數(shù)情況下會(huì)采用靜態(tài)鏈接,只有在依賴(lài)特定的C庫(kù)或標(biāo)準(zhǔn)庫(kù)包時(shí),才會(huì)切換到動(dòng)態(tài)鏈接。
- 動(dòng)態(tài)鏈接的條件:我們討論了幾種情況下Go會(huì)默認(rèn)生成動(dòng)態(tài)鏈接的可執(zhí)行文件,包括依賴(lài)使用C實(shí)現(xiàn)的標(biāo)準(zhǔn)庫(kù)包、顯式使用cgo調(diào)用外部C程序,以及使用依賴(lài)cgo的第三方包。
- 實(shí)現(xiàn)靜態(tài)鏈接:對(duì)于需要?jiǎng)討B(tài)鏈接的場(chǎng)景,我們也提供了將其轉(zhuǎn)為靜態(tài)鏈接的解決方案,包括關(guān)閉CGO、使用靜態(tài)庫(kù),以及處理依賴(lài)cgo的外部包的靜態(tài)鏈接問(wèn)題。
- 二進(jìn)制文件優(yōu)化:我們還介紹了如何通過(guò)去除符號(hào)表和使用TinyGo等方法來(lái)優(yōu)化生成的Go二進(jìn)制文件的大小,以滿(mǎn)足不同場(chǎng)景下的需求。
- 符號(hào)編譯與死碼消除:最后,我們探討了未使用的符號(hào)是否會(huì)被編譯到最終的二進(jìn)制文件中,并解釋了Go編譯器的死碼消除機(jī)制。
通過(guò)這些細(xì)節(jié)探討,我希望能夠幫助大家更好地理解Go編譯的復(fù)雜性,并在實(shí)際開(kāi)發(fā)中做出更明智的選擇,亦能在面對(duì)Go編譯相關(guān)問(wèn)題時(shí),提供有效的解決方案。
本文涉及的源碼可以在這里[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] 動(dòng)態(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] 每個(gè)包都會(huì)有一個(gè)inittask結(jié)構(gòu): https://go.dev/src/cmd/compile/internal/pkginit/init.go
[9] 這里: https://github.com/bigwhite/experiments/tree/master/go-compilation