Go 編譯的幾個細節,連專家也要停下來想想

在 Go 開發中,編譯相關的問題看似簡單,但實則蘊含許多細節。有時,即使是 Go 專家也需要停下來,花時間思考答案或親自驗證。本文將通過幾個具體問題,和大家一起探討 Go 編譯過程中的一些你可能之前未曾關注的細節。

注:本文示例使用的環境爲 Go 1.23.0、Linux Kernel 3.10.0 和 CentOS 7.9。

  1. Go 編譯默認採用靜態鏈接還是動態鏈接?

我們來看第一個問題:Go 編譯默認採用靜態鏈接還是動態鏈接呢?

很多人脫口而出:動態鏈接 [3],因爲 CGO_ENABLED 默認值爲 1,即開啓 Cgo。也有些人會說:“其實 Go 編譯器默認是靜態鏈接的,只有在使用 C 語言庫時纔會動態鏈接”。那麼到底哪個是正確的呢?

我們來看一個具體的示例。但在這之前,我們要承認一個事實,那就是 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")
}

構建該程序:

$go build -o helloworld-default main.go

之後,我們查看一下生成的可執行文件 helloworld-default 的文件屬性:

$file helloworld-default
helloworld-default: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped
$ldd helloworld-default
   不是動態可執行文件

我們看到,雖然 CGO_ENABLED=1,但默認情況下,Go 構建出的 helloworld 程序是靜態鏈接的 (statically linked)。

那麼默認情況下,Go 編譯器是否都會採用靜態鏈接的方式來構建 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 可執行文件。

$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 從靜態鏈接變爲了動態鏈接,同時這也是如何編譯出一個 hello world 版的動態鏈接 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

由此,我們可以得到一個結論,在默認情況下 (CGO_ENABLED=1),Go 會盡力使用靜態鏈接的方式,但在某些情況下,會採用動態鏈接。那麼究竟在哪些情況下會默認生成動態鏈接的程序呢?我們繼續往下看。

  1. 在何種情況下默認會生成動態鏈接的 Go 程序?

在以下幾種情況下,Go 編譯器會默認 (CGO_ENABLED=1) 生成動態鏈接的可執行文件,我們逐一來看一下。

2.1 一些使用 C 實現的標準庫包

根據上述示例,我們可以看到,在某些情況下,即使只依賴標準庫,Go 仍會在 CGO_ENABLED=1 的情況下采用動態鏈接。這是因爲代碼依賴的標準庫包使用了 C 版本的實現。雖然這種情況並不常見,但 os/user 包 [4] 和 net 包 [5] 是兩個典型的例子。

os/user 包的示例在前面我們已經見識過了。user 包允許開發者通過名稱或 ID 查找用戶賬戶。對於大多數 Unix 系統 (包括 linux),該包內部有兩種版本的實現,用於解析用戶和組 ID 到名稱,並列出附加組 ID。一種是用純 Go 編寫,解析 / etc/passwd 和 / etc/group 文件。另一種是基於 cgo 的,依賴於標準 C 庫(libc)中的例程,如 getpwuid_r、getgrnam_r 和 getgrouplist。當 cgo 可用 (CGO_ENABLED=1),並且特定平臺的 libc 實現了所需的例程時,將使用基於 cgo 的(libc 支持的)代碼,即採用動態鏈接方式。

同樣,net 包在名稱解析 (Name Resolution,即域名或主機名對應 IP 查找) 上針對大多數 Unix 系統也有兩個版本的實現:一個是純 Go 版本,另一個是基於 C 的版本。C 版本會在 cgo 可用且特定平臺實現了相關 C 函數 (比如 getaddrinfo 和 getnameinfo 等) 時使用。

下面是一個簡單的使用 net 包並採用動態鏈接的示例:

// 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 版本實現依賴了 libresolv.so 這個用於名稱解析的 C 庫。

由此可得,當 Go 在默認 cgo 開啓時,一旦依賴了標準庫中擁有 C 版本實現的包,比如 os/user、net 等,Go 編譯器會採用動態鏈接的方式編譯 Go 可執行程序。

2.2 顯式使用 cgo 調用外部 C 程序

如果使用 cgo 與外部 C 代碼交互,那麼生成的可執行文件必然會包含動態鏈接。下面我們來看一個調用 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;
}

執行 make all 構建出動態鏈接庫 libmylib.so!接下來,我們編寫一個 Go 程序通過 cgo 調用 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() {
 // 調用 C 函數
 C.hello()

 // 調用 C 中的加法函數
 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 可以看到,可執行文件 helloworld-with-call-myclib 是動態鏈接的,並依賴 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)

設置 LD_LIBRARY_PATH(爲了讓程序找到 libmylib.so) 並運行可執行文件 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 的第三方包

在日常開發中,我們經常依賴一些第三方包,有些時候這些第三方包依賴 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() {
 // 打開數據庫(如果不存在,則創建)
 db, err := sql.Open("sqlite3""./test.db")
 if err != nil {
  log.Fatal(err)
 }
 defer db.Close()

 // 創建表
 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)
 }

 // 插入數據
 _, err = db.Exec(`INSERT INTO user (name) VALUES (?)`"Alice")
 if err != nil {
  log.Fatal(err)
 }

 // 查詢數據
 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

到這裏,有些讀者可能會問一個問題:如果需要在上述依賴場景中生成靜態鏈接的 Go 程序,該怎麼做呢?接下來,我們就來看看這個問題的解決細節。

  1. 如何在上述情況下實現靜態鏈接?

到這裏是不是有些燒腦了啊!我們針對上一節的三種情況,分別對應來看一下靜態編譯的方案。

3.1 僅依賴標準包

在前面我們說過,之所以在使用 os/user、net 包時會在默認情況下采用動態鏈接,是因爲 Go 使用了這兩個包對應功能的 C 版實現,如果要做靜態編譯,讓 Go 編譯器選擇它們的純 Go 版實現即可。那我們僅需要關閉 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
    不是動態可執行文件

3.2 使用 cgo 調用外部 c 程序(靜態鏈接)

對於依賴 cgo 調用外部 c 的程序,我們要使用靜態鏈接就必須要求外部 c 庫提供靜態庫,因此,我們需要 my-c-lib 提供一份 libmylib.a,這通過下面命令可以實現 (或執行 make static):

$gcc -c -fPIC -o mylib.o mylib.c
$ar rcs libmylib.a mylib.o

有了 libmylib.a 後,我們還要讓 Go 程序靜態鏈接該. a 文件,於是我們需要修改一下 Go 源碼中 cgo 鏈接的 flag,加上靜態鏈接的選項:

// 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

我們得到了預期的結果!

3.3 依賴使用 cgo 的外部 go 包(靜態鏈接)

最麻煩的是這類情況,要想實現靜態鏈接,我們需要找出外部 go 依賴的所有 c 庫的. a 文件 (靜態共享庫)。以我們的 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

接下來,我們就來以靜態鏈接的方式在 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 的動態加載功能,確保更好的靜態鏈接兼容性。而 - ldflags '-linkmode external -extldflags"-static"'的含義是使用外部鏈接器 (比如 gcc linker),並強制靜態鏈接所有庫。

我們再看完略燒腦的幾個細節後,再來看一個略輕鬆的話題。

  1. Go 編譯出的可執行文件過大,能優化嗎?

Go 編譯出的二進制文件一般較大,一個簡單的 “Hello World” 程序通常在 2MB 左右:

$ls -lh helloworld-default
-rwxr-xr-x 1 root root 2.1M 11月  3 10:39 helloworld-default

這一方面是因爲 Go 將整個 runtime 都編譯到可執行文件中了,另一方面也是因爲 Go 靜態編譯所致。那麼在默認情況下,Go 二進制文件的大小還有優化空間麼?方法不多,有兩種可以嘗試:

在編譯時使用 - ldflags="-s -w" 標誌可以去除符號表和調試符號,其中 - s 用於去掉符號表和調試信息,-w 用於去掉 DWARF 調試信息,這樣能顯著減小文件體積。以 helloworld 爲例,可執行文件的 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[7] 是一個 Go 語言的編譯器,它專爲資源受限的環境而設計,例如微控制器、WebAssembly 和其他嵌入式設備。TinyGo 的目標是提供一個輕量級的、能在小型設備上運行的 Go 運行時,同時儘可能支持 Go 語言的特性。tinygo 的一大優點就是生成的二進制文件通常比標準 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 生成的可執行文件的 size 僅是原來的 30%。

注:雖然 TinyGo 在特定場景(如 IoT 和嵌入式開發)中非常有用,但在常規服務器環境中,由於生態系統兼容性、性能、調試支持等方面的限制,可能並不是最佳選擇。對於需要高併發、複雜功能和良好調試支持的應用,標準 Go 仍然是更合適的選擇。

注:這裏使用的 tinygo 爲 0.34.0 版本。

  1. 未使用的符號是否會被編譯到 Go 二進制文件中?

到這裏,相信讀者心中也都會縈繞一些問題:到底哪些符號被編譯到最終的 Go 二進制文件中了呢?未使用的符號是否會被編譯到 Go 二進制文件中嗎?在這一小節中,我們就來探索一下。

出於對 Go 的瞭解,我們已經知道無論是 GOPATH 時代,還是 Go module 時代,Go 的編譯單元始終是包 (package),一個包(無論包中包含多少個 Go 源文件)都會作爲一個編譯單元被編譯爲一個目標文件 (.a),然後 Go 鏈接器會將多個目標文件鏈接在一起生成可執行文件,因此如果一個包被依賴,那麼它就會進入到 Go 二進制文件中,它內部的符號也會進入到 Go 二進制文件中。

那麼問題來了!是否被依賴包中的所有符號都會被放到最終的可執行文件中呢?我們以最簡單的 helloworld-default 爲例,它依賴 fmt 包,並調用了 fmt 包的 Println 函數,我們看看 Println 這個符號是否會出現在最終的可執行文件中:

$nm -a helloworld-default | grep "Println"
000000000048eba0 T fmt.(*pp).doPrintln

居然沒有!我們初步懷疑是 inline 優化在作祟。接下來,關閉優化再來試試:

$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 的所有符號時,我們發現 fmt 包的一些常見的符號並未包含在其中,比如 Printf、Fprintf、Scanf 等。

這是因爲 Go 編譯器的一個重要特性:死碼消除 (dead code elimination),即編譯器會將未使用的代碼和數據從最終的二進制文件中剔除。

我們再來繼續探討一個衍生問題:如果 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 函數的包,且 init 函數中調用了同包其他符號的情況呢?我們以 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 函數,但有需要初始化的全局變量,比如 crypto 包的 hashes:

// $GOROOT/src/crypto/crypto.go
var hashes = make([]func() hash.Hash, maxHash)

crypto 包的相關如何也會進入最終的可執行文件中,大家自己動手不妨試試。下面是我得到的一些輸出:

$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 包也有一些全局變量啊,爲什麼這些符號沒有被包含在可執行文件中呢?比如:

// $GOROOT/src/os/user/user.go
var (
    userImplemented      = true
    groupImplemented     = true
    groupListImplemented = true
)

這就要涉及 Go 包初始化的邏輯了。我們看到 crypto 包包含在可執行文件中的符號中有 crypto.init 和 crypto..inittask 這兩個符號,顯然這不是 crypto 包代碼中的符號,而是 Go 編譯器爲 crypto 包自動生成的 init 函數和 inittask 結構。

Go 編譯器會爲每個包生成一個 init 函數,即使包中沒有顯式定義 init 函數,同時每個包都會有一個 inittask 結構 [8],用於運行時的包初始化系統。當然這麼說也不足夠精確,如果一個包沒有 init 函數、需要初始化的全局變量或其他需要運行時初始化的內容,則編譯器不會爲其生成 init 函數和 inittask。比如上面的 os/user 包。

os/user 包確實有上述全局變量的定義,但是這些變量是在編譯期就可以確定值的常量布爾值,而且未被包外引用或在包內用於影響控制流。Go 編譯器足夠智能,能夠判斷出這些初始化是 "無副作用的",不需要在運行時進行初始化。只有真正需要運行時初始化的包纔會生成 init 和 inittask。這也解釋了爲什麼空導入 os/user 包時沒有相關的 init 和 inittask 符號,而 crypto、expvar 包有的 init.0 和 inittask 符號。

  1. 如何快速判斷 Go 項目是否依賴 cgo?

在使用開源 Go 項目時,我們經常會遇到項目文檔中沒有明確說明是否依賴 Cgo 的情況。這種情況下,如果我們需要在特定環境(比如 CGO_ENABLED=0)下使用該項目,就需要事先判斷項目是否依賴 Cgo,有些時候還要快速地給出判斷。

那究竟是否可以做到這種快速判斷呢?我們先來看看一些常見的作法。

第一類作法是源碼層面的靜態分析。最直接的方式是檢查源碼中是否存在 import "C" 語句,這種引入方式是 CGO 使用的顯著標志。

// 在項目根目錄中執行
$grep -rn 'import "C"' .

這個命令會遞歸搜索當前目錄下所有文件,顯示包含 import "C" 的行號和文件路徑,幫助快速定位 CGO 的使用位置。

此外,CGO 項目通常包含特殊的編譯指令,這些指令以註釋形式出現在源碼中,比如前面見識過的 #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 版本兩個實現,因此上述判斷只能說 “對了一半”,當我關閉 CGO_ENABLED 時,Go 編譯器不會使用基於 cgo 的 C 版實現。

那是否在禁用 cgo 的前提下對源碼進行一次編譯便能驗證項目是否對 cgo 有依賴呢?這樣做顯然談不上是一種 “快速” 的方法,那是否有效呢?我們來對上面的 go-sqlite3 項目做一個測試,我們在關閉 CGO_ENABLED 時,編譯一下該示例:

// cd go-compilation/go-sqlite3
$ CGO_ENABLED=0 go build demo

我們看到,Go 編譯器並未報錯!似乎該項目不需要 cgo! 但真的是這樣嗎?我們運行一下編譯後的 demo 可執行文件:

$ ./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);

我們看到成功編譯出來的程序居然出現運行時錯誤,提示需要 cgo!

到這裏,沒有一種方法可以快速、精確的給出項目是否依賴 cgo 的判斷。也許判斷 Go 項目是否依賴 CGO 並沒有捷徑,需要從源碼分析、依賴檢查和構建測試等多個維度進行。

  1. 小結

在本文中,我們深入探討了 Go 語言編譯過程中的幾個重要細節,尤其是在靜態鏈接和動態鏈接的選擇上。通過具體示例,我們瞭解到:

通過這些細節探討,我希望能夠幫助大家更好地理解 Go 編譯的複雜性,並在實際開發中做出更明智的選擇,亦能在面對 Go 編譯相關問題時,提供有效的解決方案。

本文涉及的源碼可以在這裏 [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] 

動態鏈接: 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 結構: https://go.dev/src/cmd/compile/internal/pkginit/init.go

[9] 

這裏: https://github.com/bigwhite/experiments/tree/master/go-compilation

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/wKqcAAU7aq-i34ZhW7NH9A