Go 內存管理之三:CGO
之前在 povilasv.me[1] 上,我們一起探討了 Go 內存管理 和 Go 內存管理之二。在上篇博文中,我們發現使用 cgo 會佔用更多的虛擬內存。現在我們來深入研究一下 cgo。
CGO 揭祕
正如之前所見,cgo 會使虛擬內存膨脹。此外,對於大部分用戶而言,一旦他們導入了 net 包或者其子包(比如 http),就會自動的使用 cgo。
我在標準庫的代碼裏發現很多描述 cgo 調用工作機制的文檔。比如,你在 cgocall.go[2] 文件裏,就能看到非常有用的註釋:
爲了在 Go 代碼中調用 C 函數 f
,cgo 會生成代碼調用 runtime.cgocall(_cgo_Cfunc_f, frame)
,這裏 _cgo_Cfunc_f
是由 cgo 自動生成、gcc 編譯的函數。
爲了不阻塞其他的 Go 例程或垃圾收集器,runtime.cgocall
(如下)先調用 entersyscall
,接着再調用 runtime.asmcgocall(_cgo_Cfunc_f, frame)
。
runtime.asmcgocall
(見 asm$GOARCH.s)切換到 m->go
棧上(被認爲是由操作系統分配的棧,可以在其上安全的運行 gcc 編譯的代碼),並且調用 _cgo_Cfunc_f(frame)
。_
_cgo_Cfunc_f
調用真正的 C 函數 f
,並將 frame
結構裏的參數傳遞給它,將結果記錄到 frame
結構裏,最後返回 runtime.asmcgocall
。
runtime.asmcgocall
重獲控制後,再切回原來的 g
(m->curg
)的棧,然後返回 runtime.cgocall
。
然後, runtime.cgocall
會調用 exitsyscall
,它會阻塞直到 m
可以運行 Go 代碼而不會違反 $GOMAXPROCS
限制,接着將 g
從 m
上解鎖。
以上的描述跳過了由 gcc 編譯的函數 f
再調用 Go 函數的可能性。如果發生這種情況,我們會在執行 f
的過程中沿着兔子洞走下去。
-- cgocall.go 源碼(https://golang.org/src/runtime/cgocall.go )
註釋甚至還深入的講解了 Go 如何實現 cgo 到 Go 的調用。我強烈建議你研究一下這些代碼和註釋。透過現象看本質讓我學到了很多。從這些註釋我們可以看到,是否調用 C 代碼會讓 Go 程序的行爲完全不同。
運行時追蹤
探索 Go 程序行爲的一種很酷的方法是使用 Go 運行時追蹤。要想進一步瞭解 Go 程序追蹤方面的知識,可以參閱 Go 運行時追蹤器 [3] 這篇博文。現在,我們來修改一下代碼加入追蹤功能:
func main() {
trace.Start(os.Stderr)
cs := C.CString("Hello from stdio")
time.Sleep(10 * time.Second)
C.puts(cs)
C.free(unsafe.Pointer(cs))
trace.Stop()
}
編譯這個程序並且將標準錯誤輸出保存到文件裏:
/ex7 2> trace.out
最後,來看看追蹤結果:
go tool trace trace.out
就是這樣。下次如果再遇到表現怪異的命令行程序,我就知道如何去追蹤了🙂。順便說一下,如果要追蹤網頁服務的話,可以使用 httptrace
包,用起來更簡單。想要了解更多的話,可以參閱 HTTP 追蹤 [4] 這篇博文。
我還編寫了一個相似的但沒有任何 C 代碼調用的程序,以便使用 go tool trace
比較它們的追蹤結果。這就是這個 Go 原生程序的代碼:
func main() {
trace.Start(os.Stderr)
str := "Hello from stdio"
time.Sleep(10 * time.Second)
fmt.Println(str)
trace.Stop()
}
cgo 程序和 Go 原生程序的追蹤結果並沒有太大差異。當然,我注意到有一些統計還是有點差別的。比如,cgo 程序沒有包含堆的統計信息。
cgo 程序的追蹤信息統計
Go 原生程序的追蹤信息統計
我試圖使用不同的視圖去觀察,但是並沒有發現更多的顯著差異。我猜測這可能是由於 Go 不會爲已編譯的 C 代碼添加追蹤指令。
因此,我決定使用 strace
來繼續探索它們之間的差異。
使用 strace
探索 cgo
首先明確一下,我們將要探索的兩個程序有着相同的行爲。我們只是簡單的從上述的程序中去除了追蹤語句。
cgo 程序:
func main() {
cs := C.CString("Hello from stdio")
time.Sleep(10 * time.Second)
C.puts(cs)
C.free(unsafe.Pointer(cs))
}
go 原生程序:
package main
import (
"fmt"
"time"
)
func main() {
str := "Hello from stdio"
time.Sleep(10 * time.Second)
fmt.Println(str)
}
編譯,然後使用 strace
運行它們:
sudo strace -f ./program_name
我加了 -f
標誌讓 strace 也追蹤線程。
-f
追蹤當前被追蹤進程由於執行 fork(2),vfork(2)和 clone(2) 系統調用而生成的子進程
cgo 結果
如前所見,爲了完成工作,cgo 程序會加載 libc 和 pthreads 這兩個 C 代碼庫。而且,事實證明,cgo 程序以不同的方式創建線程。創建線程的時候,你可以看到有一個函數調用爲線程棧分配了 8 MB 的內存:
mmap(NULL, 8392704, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_STACK, -1, 0) = 0x7f1990629000
// 我們爲棧分配了 8 MB 內存
mprotect(0x7f199062a000, 8388608, PROT_READ|PROT_WRITE) = 0
// 允許讀寫,但禁止執行位於該內存區域的代碼
設置完棧之後,你會看到一個系統調用 clone
,但是傳遞的參數與一個典型的 Go 原生程序不同:
clone( child_stack=0x7f1990e28fb0, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, parent_tidptr=0x7f1990e299d0, tls=0x7f1990e29700, child_tidptr=0x7f1990e299d0) = 3600
如果你對這些參數的含義感興趣,請參閱下面的描述(摘自 clone
手冊):
CLONE_VM
– 調用進程和子進程運行於同一內存空間。
CLONE_FS
– 調用者和子進程共享相同的文件系統信息。
CLONE_FILES
– 調用進程和子進程共享相同的文件描述符表。
CLONE_SIGHAND
– 調用進程和子進程共享相同的信號處理函數表。 譯註 1[5]
CLONE_THREAD
– 把子進程與調用進程置於同一個線程組中。
CLONE_SYSVSEM
– 子進程和調用進程共享同一個 System V 信號量調整值的列表。
CLONE_SETTLS
– 將 TLS (線程本地存儲)描述符設置成 nettls
。
CLONE_PARENT_SETTID
– 將子線程 ID 存儲在 ptid
在父線程內存中的位置。
CLONE_CHILD_CLEARTID
– 將子線程 ID 存儲在 ctid
在子線程內存中的位置。
–- clone
系統調用手冊
clone
調用之後,線程會首先保留 128 MB 內存,然後再取消保留 57.8 MB 和 8 MB。看一下下面這段 strace
的輸出:
mmap(NULL, 134217728, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0) = 0x7f1988629000
//134217728 / 1024 / 1024 = 128 MiB
munmap(0x7f1988629000, 60649472 )
// 取消從 0x7f1988629000 起始的 57.8 MiB 的內存映射
munmap(0x7f1990000000, 6459392)
// 取消從 0x7f1990000000 起始的 8 MiB 的內存映射
mprotect(0x7f198c000000, 135168, PROT_READ|PROT_WRITE
現在,一切都能說通了。在 cgo 程序中,我們看到分配了大約 373.25 MB 的虛擬內存,這可以從上面的輸出中獲得完全的解釋。甚至,它還解釋了爲什麼我在本文第一部分 [6] 中的 /proc/PID/maps
裏面沒有看到這些內存映射,因爲保留內存的線程有自己的 PID。另外,雖然線程調用了 mmap
,由於並沒有實際的使用那些內存區域,因而它們並不會被算到常駐內存裏,而是被算到了虛擬內存裏。
讓我們來做一些隨手計算:
strace 的輸出中有 5 個 clone
系統調用。各自保留了 8 MB(棧)+ 128 MB,接着又取消保留 57.8 MB 和 8 MB,這樣最終每個線程保留了約 70 MB。但實際上,有一個線程並沒有取消映射任何內存,還有一個沒有取消映射那 8 MB。所以,最終的算式應該像下面這樣:
4 * 70 + 8 + 1 * 128 = ~ 416 MB
此外,不要忘了程序初始化的時候會額外保留一些內存,因而應該再加上一些常量。
顯然,要想弄清楚我們到底是在哪個時刻對內存做的採樣(執行 ps
命令)是非常困難的;比如,我們可能在只有 2 或 3 個線程在運行的時候執行的 ps
,內存已經被 mmap
但還沒有被釋放等。在我看來,這就是我最初寫作 Go 內存管理 [7] 這篇博文想要尋找的解答。
如果你對 mmap
的參數含義感興趣,這裏是它們的定義:
-
MAP_ANONYMOUS – 不映射到任何文件,將內存初始化成 0。
-
MAP_NORESERVE – 不爲這個映射保留交換空間。
-
MAP_PRIVATE – 創建一個私有的寫時複製映射。對於映射了同一個文件的多個進程而言,一個進程對內存的更新對其他進程不可見,並且不會寫回文件。
mmap 系統調用手冊
最後,我們來看看 Go 原生程序是怎樣創建線程的。
Go 原生結果
go 原生程序只會進行 4 次 clone
系統調用,新線程不會分配內存(沒有 mmap
調用),也不爲棧保留 8 MB 的內存空間。go 原生程序創建線程的調用大致如下:
clone( child_stack=0xc420042000, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM) = 3935
注意 Go 和 cgo 調用 clone
時傳遞參數的差異。
此外,在 Go 原生代碼產生的 strace 輸出裏,你可以清楚的看到 Println
語句對應的系統調用:
[pid 3934] write(1, "Hello from stdio\n", 17Hello from stdio
) = 17
譯註 2[8]
然而,在 cgo 版本里,我沒有找到 fputs()
對應的類似系統調用。
Go 原生程序讓我喜愛的一點是,它的 strace 輸出更小、更易於理解。這意味着發生了更少的事情。比如,go 原生程序的 strace 輸出僅有 281 行,而 cgo 版的輸出有 342 行。
結語
你可以從我的探索中得到的有:
-
如果使用的包裏引用了 C 代碼,Go 可能自動神奇的切換到 cgo。比如使用了
net
、net/http
包。 -
Go 有兩個 DNS 解析器的實現:
netgo
版和netcgo
版。 -
通過設置環境變量
export GODEBUG=netdns=1
,你可以瞭解你在使用哪個 DNS 客戶端。 -
你可以在運行時切換 DNS 解析器,只需將環境變量設置成
export GODEBUG=netdns=go
或export GODEBUG=netdns=cgo
。 譯註 3[9] -
你可以通過 Go 構建標籤在編譯時指定使用的 DNS 解析實現:
go build -tags netcgo
或go build -tags netgo
。 -
/proc
文件系統很有用,但是不要被線程誤導! -
/prod/PID/status
和/proc/PID/maps
對於快速瞭解正在發生什麼會很有用。 -
Go 運行時追蹤器能夠幫助你調試程序。
-
當你不知道如何下手時,不要忘了還有
strace -f
。
最後:
-
cgo 不是 Go。
-
大的虛擬內存佔用不是壞事。
-
不同版本的 Go 編譯出來的程序會有不同的表現,Go 1.10 上正確的事情並不一定在 Go 1.12 上同樣正確。
如果這篇博文讓你有所收穫,希望不僅僅是在編譯 Go 程序的時候需要設置 CGO_ENABLED=0
。Go 的作者不是憑空決定了 Go 現在的運行機制。你現在看到的這些行爲在將來也許會改變,就像 Go 1.12 帶來的變化那樣。
這就是今天的內容。如果你想第一時間看到我的博客文章,請訂閱簡報 [10]。如果你願意支持我的寫作,我這裏還有一個願望清單 [11],你可以爲我買一本書或是隨便一個什麼東西😉。
感謝您的閱讀,下次再見!
譯註
-
此處原文中對
CLONE_SIGHAND
的說明實爲CLONE_FILES
的說明,應爲拷貝粘貼錯誤。譯文已糾正。 -
行末的
Hello from stdio
和換行符應爲程序運行時打印到標準輸出所致。 -
此處原文中兩處皆爲:
export GODEBUG=netdns=go
,應爲筆誤。譯文已糾正。
via: https://povilasv.me/go-memory-management-part-3/
作者:Povilas[12] 譯者:Stonelgh[13] 校對:polaris1119[14]
本文由 GCTT[15] 原創編譯,Go 中文網 [16] 榮譽推出,發佈在 Go 語言中文網公衆號,轉載請聯繫我們授權。
參考資料
[1]
povilasv.me: https://povilasv.me/
[2]
cgocall.go: https://golang.org/src/runtime/cgocall.go
[3]
Go 運行時追蹤器: https://blog.gopheracademy.com/advent-2017/go-execution-tracer/
[4]
HTTP 追蹤: https://blog.golang.org/http-tracing
[5]
譯註 1: #note1
[6]
本文第一部分: https://povilasv.me/go-memory-management/
[7]
Go 內存管理: https://povilasv.me/go-memory-management/
[8]
譯註 2: #note2
[9]
譯註 3: #note3
[10]
簡報: https://povilasv.me/newsletter
[11]
願望清單: https://www.amazon.com/hz/wishlist/ls/2NLKE1Z1SND3W?ref=wl_share_
[12]
Povilas: https://povilasv.me/about/
[13]
Stonelgh: https://github.com/stonglgh
[14]
polaris1119: https://github.com/polaris1119
[15]
GCTT: https://github.com/studygolang/GCTT
[16]
Go 中文網: https://studygolang.com/
福利
我爲大家整理了一份從入門到進階的 Go 學習資料禮包,包含學習建議:入門看什麼,進階看什麼。關注公衆號 「polarisxu」,回覆 ebook 獲取;還可以回覆「進羣」,和數萬 Gopher 交流學習。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/wD7JiWICzA0WHniVHu2-9w