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 重獲控制後,再切回原來的 gm->curg)的棧,然後返回 runtime.cgocall

然後, runtime.cgocall 會調用 exitsyscall,它會阻塞直到 m 可以運行 Go 代碼而不會違反 $GOMAXPROCS 限制,接着將 gm 上解鎖。

以上的描述跳過了由 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 的參數含義感興趣,這裏是它們的定義:

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 行。

結語

你可以從我的探索中得到的有:

最後:

如果這篇博文讓你有所收穫,希望不僅僅是在編譯 Go 程序的時候需要設置 CGO_ENABLED=0。Go 的作者不是憑空決定了 Go 現在的運行機制。你現在看到的這些行爲在將來也許會改變,就像 Go 1.12 帶來的變化那樣。

這就是今天的內容。如果你想第一時間看到我的博客文章,請訂閱簡報 [10]。如果你願意支持我的寫作,我這裏還有一個願望清單 [11],你可以爲我買一本書或是隨便一個什麼東西😉。

感謝您的閱讀,下次再見!

譯註

  1. 此處原文中對 CLONE_SIGHAND 的說明實爲 CLONE_FILES 的說明,應爲拷貝粘貼錯誤。譯文已糾正。

  2. 行末的 Hello from stdio 和換行符應爲程序運行時打印到標準輸出所致。

  3. 此處原文中兩處皆爲: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