Go運行時,對bug的分析調試過程解析
原文: Debugging an evil Go runtime bug
作者:Hector Martin
翻譯:雁驚寒
摘要:本文講述了作者通過對硬件、內核進行分析來調試程序bug的整個過程。以下是譯文。
前言
我是 Prometheus 和 Grafana 的超級粉絲。作為一名前谷歌SRE(Site Reliability Engineer, 網站可靠性工程師),我學會了如何選擇優秀的監控應用程序。這個組合在過去的一年中一直是我戰無不勝的法寶。我使用它們監控我自己的個人服務器(包括黑盒和白盒監控)、為我的客戶提供專業的技術支持,以及實現其他很多的功能。 使用Prometheus編寫自定義導出程序來監視數據非常地簡單,而且你可以很方便地在其他地方找到一個適合于自己的可用的導出程序。例如,我們使用 sql_exporter 為Encounter事件制作了一個非常漂亮的儀表盤。
Euskal Encounter的事件儀表盤
由于把 node_exporter 部署到任何一臺機器上都非常簡單,并且它能運行一個Prometheus實例來為機器做基本的系統級監控(包括CPU、內存、網絡、磁盤、文件系統的使用情況等),那么我想,為什么不監視一下我的筆記本電腦呢?我有一臺Clevo“游戲”筆記本電腦,它是我主要的工作電腦,大部分時間都是假裝在家里做臺式機,有時也會和我一起參加像混沌通信大會(
Chaos Communication Congress)這樣的大型活動。由于我已經在它和一臺運行Prometheus的服務器之間建立了VPN鏈接,所以,我可以通過執行emerge prometheus-node_exporter來啟動服務,指向Prometheus實例,并自動為其配置警報。這意味著每當我打開太多Chrome選項卡并耗光32GB內存的時候,我的手機就會收到報警。完美!
問題浮現
不過,在設置完的一個小時之后,我的手機確實出現了一個提示:新添加的目標無法訪問。我可以SSH到筆記本電腦,說明電腦運行正常,但node_exporter已經崩潰了。
fatal error: unexpected signal during runtime execution [signal SIGSEGV: segmentation violation code=0x1 addr=0xc41ffc7fff pc=0x41439e] goroutine 2395 [running]: runtime.throw(0xae6fb8, 0x2a) /usr/lib64/go/src/runtime/panic.go:605 +0x95 fp=0xc4203e8be8 sp=0xc4203e8bc8 pc=0x42c815 runtime.sigpanic() /usr/lib64/go/src/runtime/signal_unix.go:351 +0x2b8 fp=0xc4203e8c38 sp=0xc4203e8be8 pc=0x443318 runtime.heapBitsSetType(0xc4204b6fc0, 0x30, 0x30, 0xc420304058) /usr/lib64/go/src/runtime/mbitmap.go:1224 +0x26e fp=0xc4203e8c90 sp=0xc4203e8c38 pc=0x41439e runtime.mallocgc(0x30, 0xc420304058, 0x1, 0x1) /usr/lib64/go/src/runtime/malloc.go:741 +0x546 fp=0xc4203e8d38 sp=0xc4203e8c90 pc=0x411876 runtime.newobject(0xa717e0, 0xc42032f430) /usr/lib64/go/src/runtime/malloc.go:840 +0x38 fp=0xc4203e8d68 sp=0xc4203e8d38 pc=0x411d68 github.com/prometheus/node_exporter/vendor/github.com/prometheus/client_golang/prometheus.NewConstMetric(0xc42018e460, 0x2, 0x3ff0000000000000, 0xc42032f430, 0x1, 0x1, 0x10, 0x9f9dc0, 0x8a0601, 0xc42032f430) /var/tmp/portage/net-analyzer/prometheus-node_exporter-0.15.0/work/prometheus-node_exporter-0.15.0/src/github.com/prometheus/node_exporter/vendor/github.com/prometheus/client_golang/prometheus/value.go:165 +0xd0 fp=0xc4203e8dd0 sp=0xc4203e8d68 pc=0x77a980
像其他的Prometheus組件一樣,node_exporter是用Go編寫的。 Go是一種相對安全的語言,盡管有的時候你可能會搬起石頭砸自己的腳,而且它不像Rust那樣具有強有力的安全保證,但是,要在Go中產生段錯誤也并不是那么容易的。 況且,node_exporter是一個相對來說比較簡單的Go應用程序,只單純的依賴Go。 因此,這是一個非常有趣的崩潰,特別是崩潰在mallocgc里面。一般情況下,這里永遠都不會崩潰。
重啟幾次之后,事情變得更有趣了:
2017/11/07 06:32:49 http: panic serving 172.20.0.1:38504: runtime error: growslice: cap out of range goroutine 41 [running]: net/http.(*conn).serve.func1(0xc4201cdd60) /usr/lib64/go/src/net/http/server.go:1697 +0xd0 panic(0xa24f20, 0xb41190) /usr/lib64/go/src/runtime/panic.go:491 +0x283 fmt.(*buffer).WriteString(...) /usr/lib64/go/src/fmt/print.go:82 fmt.(*fmt).padString(0xc42053a040, 0xc4204e6800, 0xc4204e6850) /usr/lib64/go/src/fmt/format.go:110 +0x110 fmt.(*fmt).fmt_s(0xc42053a040, 0xc4204e6800, 0xc4204e6850) /usr/lib64/go/src/fmt/format.go:328 +0x61 fmt.(*pp).fmtString(0xc42053a000, 0xc4204e6800, 0xc4204e6850, 0xc400000073) /usr/lib64/go/src/fmt/print.go:433 +0x197 fmt.(*pp).printArg(0xc42053a000, 0x9f4700, 0xc42041c290, 0x73) /usr/lib64/go/src/fmt/print.go:664 +0x7b5 fmt.(*pp).doPrintf(0xc42053a000, 0xae7c2d, 0x2c, 0xc420475670, 0x2, 0x2) /usr/lib64/go/src/fmt/print.go:996 +0x15a fmt.Sprintf(0xae7c2d, 0x2c, 0xc420475670, 0x2, 0x2, 0x10, 0x9f4700) /usr/lib64/go/src/fmt/print.go:196 +0x66 fmt.Errorf(0xae7c2d, 0x2c, 0xc420475670, 0x2, 0x2, 0xc420410301, 0xc420410300) /usr/lib64/go/src/fmt/print.go:205 +0x5a
太有趣了。 這次Sprintf出現崩潰了。 為什么?
runtime: pointer 0xc4203e2fb0 to unallocated span idx=0x1f1 span.base()=0xc4203dc000 span.limit=0xc4203e6000 span.state=3 runtime: found in object at *(0xc420382a80+0x80) object=0xc420382a80 k=0x62101c1 s.base()=0xc420382000 s.limit=0xc420383f80 s.spanclass=42 s.elemsize=384 s.state=_MSpanInUse <snip> fatal error: found bad pointer in Go heap (incorrect use of unsafe or cgo?) runtime stack: runtime.throw(0xaee4fe, 0x3e) /usr/lib64/go/src/runtime/panic.go:605 +0x95 fp=0x7f0f19ffab90 sp=0x7f0f19ffab70 pc=0x42c815 runtime.heapBitsForObject(0xc4203e2fb0, 0xc420382a80, 0x80, 0xc41ffd8a33, 0xc400000000, 0x7f0f400ac560, 0xc420031260, 0x11) /usr/lib64/go/src/runtime/mbitmap.go:425 +0x489 fp=0x7f0f19ffabe8 sp=0x7f0f19ffab90 pc=0x4137c9 runtime.scanobject(0xc420382a80, 0xc420031260) /usr/lib64/go/src/runtime/mgcmark.go:1187 +0x25d fp=0x7f0f19ffac90 sp=0x7f0f19ffabe8 pc=0x41ebed runtime.gcDrain(0xc420031260, 0x5) /usr/lib64/go/src/runtime/mgcmark.go:943 +0x1ea fp=0x7f0f19fface0 sp=0x7f0f19ffac90 pc=0x41e42a runtime.gcBgMarkWorker.func2() /usr/lib64/go/src/runtime/mgc.go:1773 +0x80 fp=0x7f0f19ffad20 sp=0x7f0f19fface0 pc=0x4580b0 runtime.systemstack(0xc420436ab8) /usr/lib64/go/src/runtime/asm_amd64.s:344 +0x79 fp=0x7f0f19ffad28 sp=0x7f0f19ffad20 pc=0x45a469 runtime.mstart() /usr/lib64/go/src/runtime/proc.go:1125 fp=0x7f0f19ffad30 sp=0x7f0f19ffad28 pc=0x430fe0
現在,垃圾收集者偶然間又發現了一個問題,是另一個崩潰。
在這一點上,很自然地就能得到兩個結論:要么是硬件有嚴重的問題,要么在在二進制文件中存在一個嚴重的內存破壞缺陷。 我最初認為第一種情況不太可能,因為這臺機器上運行的程序非常雜,沒有出現任何不穩定的與硬件有關的跡象。 由于像node_exporter這樣的Go二進制文件是靜態鏈接的,不依賴于任何其他庫,所以我可以下載正式版的二進制文件來試一下。 然而,當我這樣做的時候,程序還是崩潰了。
unexpected fault address 0x0 unexpected fault address 0x0 fatal error: fault [signal SIGSEGV: segmentation violation code=0x80 addr=0x0 pc=0x76b998] goroutine 13 [running]: runtime.throw(0xabfb11, 0x5) /usr/local/go/src/runtime/panic.go:605 +0x95 fp=0xc420060c40 sp=0xc420060c20 pc=0x42c725 runtime.sigpanic() /usr/local/go/src/runtime/signal_unix.go:374 +0x227 fp=0xc420060c90 sp=0xc420060c40 pc=0x443197 github.com/prometheus/node_exporter/vendor/github.com/prometheus/client_model/go.(*LabelPair).GetName(...) /go/src/github.com/prometheus/node_exporter/vendor/github.com/prometheus/client_model/go/metrics.pb.go:85 github.com/prometheus/node_exporter/vendor/github.com/prometheus/client_golang/prometheus.(*Desc).String(0xc4203ae010, 0xaea9d0, 0xc42045c000) /go/src/github.com/prometheus/node_exporter/vendor/github.com/prometheus/client_golang/prometheus/desc.go:179 +0xc8 fp=0xc420060dc8 sp=0xc420060c90 pc=0x76b998
又是一次完全不同的崩潰。這說明node_exporter的上游或者它的一個依賴項確實存在問題,所以,我在GitHub上提交了一個 issue 。也許開發者以前見過這個,如果他們有什么想法的話,那么引起他們的注意是非常值得的。
走了一趟并不順暢的彎路
毫無疑問,對于上游問題,首先能想到的是這是一個硬件問題。畢竟我只是在一臺特定的機器上碰到這個問題。其他所有的機器都能很順利地運行node_exporter。雖然在這臺主機上沒有其他硬件連接不穩定的證據,但是我也無法解釋這臺機器存在能導致node_exporter崩潰的特殊性。Memtest86+的運行永遠不會破壞其他程序,所以我安裝了一個。
然后,發生了這個:
這是我用客戶的電腦所得到的
哎呀!RAM壞了。更具體點說是有一位(bit)的壞內存。在測試程序完整地運行了一遍之后,最終得到的就只是那一個壞的位,另外在測試7中存在一些誤報(在附近移動塊的時候出來了一個錯誤)。
進一步的測試表明,SMP模式下的Memtest86+測試5可以快速檢測到錯誤,但通常不會在第一遍檢測的時候發現。錯誤總是出現在相同的地址和相同的位上。這說明這個問題出現在一個微弱或泄漏的RAM單元上,特別是隨溫度會變壞的那種。這非常符合邏輯:溫度的升高會增加RAM單元的泄漏,并且很有可能會引起位翻轉。
從這個角度來看,這是274,877,906,944個位中的一個壞點。這實際上是一個非常不錯的的錯誤率了!硬盤和閃存的錯誤率要高得多,只是這些設備在出廠時會標出壞塊,在用戶不知情的情況下透明地換出,并且可以將新發現的弱塊透明地標記為壞塊,并將其重新定位到備用區。內存并不這么奢侈,所以一個壞的位永遠都是壞的。
唉,這不可能成為node_exporter崩潰的原因。那個應用程序使用的RAM很少,所以它碰到壞位的機會是非常低的。這類問題一般表現得并不會很明顯,也許會導致某些圖形中的像素錯誤、在某些文本中出現單個字母的翻轉、也可能指令被破壞導致無法運行,或者當某些非常重要的數據確實落在了壞位上會出現崩潰。盡管如此,它確實會導致長期的可靠性問題,這就是服務器和其他可靠設備必須使用ECC RAM才能糾正這種錯誤的原因。
我沒有在這臺筆記本電腦上配置豪華的ECC RAM。但是我擁有將內存壞塊標記為壞的能力,并告訴操作系統不要使用它。GRUB 2有一個鮮為人知的功能,它允許你改變傳遞給啟動內核的內存映射。僅僅為了一個壞塊而購買新的RAM是不值得的,所以這是一個不錯的選擇。
不過,還有一件事情是我可以做的。由于情況會隨著溫度的升高而變差,那么如果我加熱RAM會發生什么呢?
memtest86+
愜意的100°C
我把熱風槍設置到一個較低的溫度(130°C),并對兩個模塊進行加熱(其他兩個模塊在后蓋下,因為我的筆記本電腦總共有四個SODIMM插槽)。我發現另外還有三個弱點只能在高溫下才能檢測到,它們分布在三個內存條上。
我還發現,即使我交換了模塊的位置,發生錯誤的位置仍然保持大體上的一致:地址的最高位保持不變。這是因為RAM是交錯的:數據遍布在四個內存條上,而不是在每個內存條上連續分配可用地址空間的四分之一。因此,我可以屏蔽一個足夠大的RAM區域,以覆蓋每個錯誤位所有可能的地址。我發現,屏蔽連續的128KB區域應該足以覆蓋每個給定壞點的所有可能的地址排列,但是,為了更好的進行測量,我將它四舍五入到1MB。我用了三個1MB對齊的塊來進行掩蓋(其中一個塊掩蓋了兩個壞點,我總共要掩蓋四個壞點):
- 0x36a700000 – 0x36a7fffff
- 0x460e00000 – 0x460efffff
- 0x4ea000000 – 0x4ea0fffff
這可以使用GRUB的地址/掩碼語法來指定,/etc/default/grub如下所示:
GRUB_BADRAM="0x36a700000,0xfffffffffff00000,0x460e00000,0xfffffffffff00000,0x4ea000000,0xfffffffffff00000"
不用說,node_exporter還是崩潰了,但我知道了這不并是真正的問題所在。
深度挖掘
這種錯誤很煩人,它顯然是因為代碼運行的某塊內存被破壞而引起的。這種錯誤很難調試,因為我們無法預測什么會被破壞(或發生變化),而且我們也無法在發生錯誤的時候捕捉到錯誤的代碼。
首先,我嘗試了node_exporter的其他一些版本,并啟用或禁用了不同的參數,但并沒有什么效果。我還嘗試在strace下運行實例,似乎沒有發生崩潰,這強烈說明了這是在競爭條件下的一個問題。strace通常會攔截所有線程運行的所有系統調用,并在某種程度上讓應用程序的執行串行化。后來,我發現strace也崩潰了,但是運行了很長時間才出現崩潰。由于這似乎與并發有關,所以我試著設置GOMAXPROCS=1,這個參數告訴Go只使用一個OS級別的線程來運行Go代碼。崩潰再也沒有發生,問題再一次指向了并發。
到目前為止,我已經收集了一定數量的崩潰日志,并開始關注其中的一些規律。雖然崩潰的位置以及表面原因有很多種,但是最終的錯誤信息可以分為多個不同的類型,而且每種類型的錯誤不止出現過一次。所以我開始使用谷歌搜索這些錯誤,并偶然間發現了 Go issue #20427 。雖然這個問題似乎與Go無關,但卻引起了類似的段錯誤和隨機性問題。在Go 1.9之后,這個問題被關閉了,但并沒有得到解決。沒有人知道根本原因是什么,而且它再也沒有出現過。
所以,我從issue中抓取了 這段 聲稱能夠重現問題的示例代碼,并在我的機器上運行。你看,它在幾秒鐘內崩潰了。太好了。這比等待node_exporter崩潰所需的時間要短得多。
這并沒有讓我從Go的角度更接近這個問題,但它卻加快了我測試的速度。所以,我們來試試從另一個角度進行分析吧。
把不同的電腦區分開來
這個問題發生在我的筆記本電腦上,但在其他機器上卻都沒有發生。我嘗試著在其他電腦上重現這個問題,但沒有一臺機器發生崩潰。這說明我的筆記本電腦中有一些特別的東西。由于Go是靜態鏈接的二進制文件,所以其余的用戶空間并不重要。這留下了兩個相關的部分:硬件和內核。
我沒有什么方法來測試各臺電腦的硬件,除了我自己的機器,但我可以搗鼓內核。所以,我們來試著走第一步:它會在虛擬機中崩潰嗎?
為了測試這個,我創建了一個最小化的initramfs,這使我能夠快速啟動QEMU虛擬機,而不必安裝發行版或啟動完整的Linux系統。我的initramfs是用Linux的scripts/gen_initramfs_list.sh構建的,包含以下文件:
dir /dev 755 0 0 nod /dev/console 0600 0 0 c 5 1 nod /dev/null 0666 0 0 c 1 3 dir /bin 755 0 0 file /bin/busybox busybox 755 0 0 slink /bin/sh busybox 755 0 0 slink /bin/true busybox 755 0 0 file /init init.sh 755 0 0 file /reproducer reproducer 755 0 0
/init是Linux initramfs的入口,在我這個案例中是一個簡單的shell腳本,用于啟動測試并測量時間:
#!/bin/sh export PATH=/bin start=$(busybox date +%s) echo "Starting test now..." /reproducer ret=$? end=$(busybox date +%s) echo "Test exited with status $ret after $((end-start)) seconds"
/bin/busybox是BusyBox的一個靜態鏈接版本,通常用于這樣的最小化系統,用以提供所有基本的Linux shell實用程序(包括shell本身)。
initramfs可以這樣構建(從Linux內核源代碼樹中),其中,list.txt是上面的文件列表:
scripts/gen_initramfs_list.sh -o initramfs.gz list.txt
QEMU可以直接引導內核和initramfs:
qemu-system-x86_64 -kernel /boot/vmlinuz-4.13.9-gentoo -initrd initramfs.gz -append 'console=ttyS0' -smp 8 -nographic -serial mon:stdio -cpu host -enable-kvm
并沒有任何信息輸出到控制臺上…… 我意識到我沒有為筆記本電腦內核編譯8250串行端口支持。哦,我太蠢了。它根本沒有物理串口,對吧?不管怎么樣,我重新編譯了內核,并附帶串行支持。我再試了一下,它成功啟動并運行了。
它崩潰了嗎?是的。太好了,這意味著這個問題在同一臺機器上的虛擬機上是可以重現的。我在家里的服務器上用同樣的QEMU命令,用自己的內核,但什么也沒有發生。然后,我從筆記本電腦中把內核復制過來,然后啟動,崩潰了。內核是問題的關鍵,硬件不是問題。
搗鼓內核
我意識到自己需要編譯許多的內核來嘗試才能縮小范圍,所以,我決定轉移到一臺最強大的機器上來:一個有點舊的12核24線程Xeon處理器的機器。我將已知的壞內核源復制到那臺機器上,構建并進行測試。
它竟然沒有崩潰!為什么?
在仔細思索了一番之后,我已經能夠確定是原來的壞的內核二進制文件崩潰了。我們要回到分析硬件的問題上去嗎?跟我在哪臺機器上編譯內核有關嗎?所以,我試著在家用服務器上編譯內核,接著,這個崩潰立即觸發了。在兩臺機器上構建相同的內核會導致崩潰,而第三臺機器不會。它們之間有什么不同呢?
我的筆記本電腦和家用服務器都是〜amd64(非穩定版),而我的Xeon服務器是amd64(穩定版)。這意味著它們的GCC是不同的。我的筆記本電腦和家用服務器都是gcc(Gentoo Hardened 6.4.0 p1.0)6.4.0,而我的Xeon是gcc(Gentoo硬件5.4.0-r3 p1.3,pie-0.6.5) 5.4.0。
但是我的家用服務器內核與筆記本電腦內核幾乎是相同的(盡管不完全相同),使用相同的GCC構建,并沒有重現崩潰。所以,現在我們必須得出結論:用來構建內核的編譯器和內核本身(或其配置?)都有問題。
為了進一步縮小范圍,我在家用服務器(linux-4.13.9-gentoo)上編譯了筆記本電腦上的內核樹,并確認它出現了崩潰。然后,我把家用服務器上的.config復制過來并編譯,發現它沒有崩潰。這么做是因為我們想要尋找內核配置之間的差異和編譯器之間的差異:
- linux-4.13.9-gentoo + gcc 5.4.0-r3 p1.3 + laptop .config - 沒有崩潰
- linux-4.13.9-gentoo + gcc 6.4.0 p1.0 + laptop .config - 崩潰
- linux-4.13.9-gentoo + gcc 6.4.0 p1.0 + server .config - 沒有崩潰
兩個.config,一個好,一個壞。需要一點時間來查看它們之間的差異。當然,這兩個配置文件是完全不同的(因為我喜歡定制我的內核配置,讓它只包含特定機器上所需的驅動程序),所以我不得不在重復編譯內核來縮小差異。
我決定從“壞”的.config開始著手,從中刪除一些東西。由于要測試崩潰需要等待一定的時間,所以測試“崩潰”比“不崩潰”更容易。在22個內核的構建過程中,我對 config 文件做了簡化,使其不支持網絡、沒有文件系統、沒有塊設備核心,甚至不支持PCI(但它仍然可以在虛擬機上正常工作?。,F在編譯一下內核不到60秒的時間,內核大小大約是我常用內核的四分之一左右。
然后,我轉移到“好”的.config文件上來,刪除了所有不必要的垃圾,同時確保它不會崩潰(這比之前的測試更加棘手更加慢)。也有一些有問題的分支,我在這些分支上修改了一些東西,接著就開始崩潰了。但是,我誤認為這些分支是“不會崩潰”的。所以,當崩潰發生的時候,我不得不找回以前的內核,并找出引起崩潰的確切的原因。最后,我一共編譯了7個內核。
最后,我把范圍縮小到.config中的幾個不同的選項上來。其中有幾個嫌疑很大,特別是CONFIG_OPTIMIZE_INLINING。經過仔細地測試,我得出結論:這個選項就是罪魁禍首。把它關掉,就會產生崩潰,啟用,就不會崩潰。這個選項在打開的時候允許GCC自己確定哪個inline函數真的需要內聯,而不是強制內聯。這也解釋了:內聯行為可能隨著GCC版本的不同而不同。
/* * Force always-inline if the user requests it so via the .config, * or if gcc is too old. * GCC does not warn about unused static inline functions for * -Wunused-function. This turns out to avoid the need for complex #ifdef * directives. Suppress the warning in clang as well by using "unused" * function attribute, which is redundant but not harmful for gcc. */ #if !defined(CONFIG_ARCH_SUPPORTS_OPTIMIZED_INLINING) || \ !defined(CONFIG_OPTIMIZE_INLINING) || (__GNUC__ < 4) #define inline inline __attribute__((always_inline,unused)) notrace #define __inline__ __inline__ __attribute__((always_inline,unused)) notrace #define __inline __inline __attribute__((always_inline,unused)) notrace #else /* A lot of inline functions can cause havoc with function tracing */ #define inline inline __attribute__((unused)) notrace #define __inline__ __inline__ __attribute__((unused)) notrace #define __inline __inline __attribute__((unused)) notrace #endif
那么接下來做什么呢? 我們知道CONFIG_OPTIMIZE_INLINING這個選項使得測試結果出現不同,但是它可能會改變整個內核中每一個inline的行為。 如何查明問題的真相呢?
我有一個主意。
基于散列的差異化編譯
我們要做的是在選項打開的情況下編譯內核的一部分,在選項關閉的情況下編譯另一部分。 通過測試生成的內核并檢查問題是否重現,可以推導出內核編譯單元的哪個子集的代碼有問題。
我沒有列舉出所有的目標文件,或是進行某種二分法搜索,而是決定采用基于散列的方法。 我為GCC編寫了這個包裝器腳本:
#!/bin/bash args=("$@") doit= while [ $# -gt 0 ]; do case "$1" in -c) doit=1 ;; -o) shift objfile="$1" ;; esac shift done extra= if [ ! -z "$doit" ]; then sha="$(echo -n "$objfile" | sha1sum - | cut -d" " -f1)" echo "${sha:0:8} $objfile" >> objs.txt if [ $((0x${sha:0:8} & (0x80000000 >> $BIT))) = 0 ]; then echo "[n]" "$objfile" 1>&2 else extra=-DCONFIG_OPTIMIZE_INLINING echo "[y]" "$objfile" 1>&2 fi fi exec gcc $extra "${args[@]}"
這個腳本使用SHA-1來取目標文件名的散列值,然后從前32位中檢查散列的給定位(由環境變量$BIT進行標識)。 如果這個位的值是0,則編譯的時候不帶CONFIG_OPTIMIZE_INLINING, 如果是1,則帶上CONFIG_OPTIMIZE_INLINING。 我發現內核大約有685個目標文件,這需要大約10個位來進行唯一標識。 這種基于散列的方法有一個很好的屬性:我可以選擇產生崩潰可能性比較大的結果(即位的值是0),因為要證明給定的內核不會崩潰是很困難的(因為崩潰是概率性出現的, 可能需要相當一段時間才會發生)。
我構建了32個內核,只花了29分鐘的時間。然后,我開始對它們進行測試,每當發生崩潰的時候,我都會將可能的SHA-1散列的正則表達式縮小到那些在這些特定位置上是0的散列。在發生了8次崩潰的時候,我把范圍縮小到4個目標文件。一旦出現了10次崩潰之后,就只剩下唯一的一個了。
$ grep '^[0246][012389ab][0189][014589cd][028a][012389ab][014589cd]' objs_0.txt 6b9cab4f arch/x86/entry/vdso/vclock_gettime.o
vDSO的代碼。當然。
vDSO在搗鬼
內核的vDSO實際上并不算是內核代碼。 vDSO是內核放置在每個進程地址空間中的一個小型共享庫,它允許應用程序在不離開用戶模式的情況下執行特定的系統調用。這大大提高了系統性能,同時仍然允許內核根據需要更改這些系統調用的實現細節。
換句話說,vDSO是用GCC編譯的代碼,與內核一起構建,最終與每個用戶空間的應用程序進行鏈接。它是用戶空間的代碼。這就解釋了為什么內核和它的編譯器都與此有關:這并不是跟內核本身有關,而是與內核提供的共享庫有關! Go使用vDSO來提升性能。Go也正好有一個重建自己的標準庫的戰略,所以,它沒有使用任何標準的Linux glibc的代碼來調用vDSO,而是使用了自己的代碼。
那么改變CONFIG_OPTIMIZE_INLINING的值對vDSO有什么作用呢?我們來看看這段匯編。
設置CONFIG_OPTIMIZE_INLINING = n:
arch/x86/entry/vdso/vclock_gettime.o.no_inline_opt: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <vread_tsc>: 0: 55 push %rbp 1: 48 89 e5 mov %rsp,%rbp 4: 90 nop 5: 90 nop 6: 90 nop 7: 0f 31 rdtsc 9: 48 c1 e2 20 shl $0x20,%rdx d: 48 09 d0 or %rdx,%rax 10: 48 8b 15 00 00 00 00 mov 0x0(%rip),%rdx # 17 <vread_tsc+0x17> 17: 48 39 c2 cmp %rax,%rdx 1a: 77 02 ja 1e <vread_tsc+0x1e> 1c: 5d pop %rbp 1d: c3 retq 1e: 48 89 d0 mov %rdx,%rax 21: 5d pop %rbp 22: c3 retq 23: 0f 1f 00 nopl (%rax) 26: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1) 2d: 00 00 00 0000000000000030 <__vdso_clock_gettime>: 30: 55 push %rbp 31: 48 89 e5 mov %rsp,%rbp 34: 48 81 ec 20 10 00 00 sub $0x1020,%rsp 3b: 48 83 0c 24 00 orq $0x0,(%rsp) 40: 48 81 c4 20 10 00 00 add $0x1020,%rsp 47: 4c 8d 0d 00 00 00 00 lea 0x0(%rip),%r9 # 4e <__vdso_clock_gettime+0x1e> 4e: 83 ff 01 cmp $0x1,%edi 51: 74 66 je b9 <__vdso_clock_gettime+0x89> 53: 0f 8e dc 00 00 00 jle 135 <__vdso_clock_gettime+0x105> 59: 83 ff 05 cmp $0x5,%edi 5c: 74 34 je 92 <__vdso_clock_gettime+0x62> 5e: 83 ff 06 cmp $0x6,%edi 61: 0f 85 c2 00 00 00 jne 129 <__vdso_clock_gettime+0xf9> [...]
設置CONFIG_OPTIMIZE_INLINING=y:
arch/x86/entry/vdso/vclock_gettime.o.inline_opt: file format elf64-x86-64 Disassembly of section .text: 0000000000000000 <__vdso_clock_gettime>: 0: 55 push %rbp 1: 4c 8d 0d 00 00 00 00 lea 0x0(%rip),%r9 # 8 <__vdso_clock_gettime+0x8> 8: 83 ff 01 cmp $0x1,%edi b: 48 89 e5 mov %rsp,%rbp e: 74 66 je 76 <__vdso_clock_gettime+0x76> 10: 0f 8e dc 00 00 00 jle f2 <__vdso_clock_gettime+0xf2> 16: 83 ff 05 cmp $0x5,%edi 19: 74 34 je 4f <__vdso_clock_gettime+0x4f> 1b: 83 ff 06 cmp $0x6,%edi 1e: 0f 85 c2 00 00 00 jne e6 <__vdso_clock_gettime+0xe6> [...]
有趣的是,CONFIG_OPTIMIZE_INLINING=y這個本應該讓GCC內聯變少的標志,實際上卻讓內聯變得更多:vread_tsc在該版本中內聯,而不在CONFIG_OPTIMIZE_INLINING=n版本中。但是vread_tsc根本沒有標記為內聯,所以GCC完全有權限這么做。
但誰在乎函數是否內聯了呢?真正的問題在哪里呢?那么,仔細觀察一下非內聯版本吧……
30: 55 push %rbp 31: 48 89 e5 mov %rsp,%rbp 34: 48 81 ec 20 10 00 00 sub $0x1020,%rsp 3b: 48 83 0c 24 00 orq $0x0,(%rsp) 40: 48 81 c4 20 10 00 00 add $0x1020,%rsp
為什么GCC會分配超過4KB的棧呢?這不是棧分配,這是棧探測,或者更具體地說是GCC-fstack-check 特性 的結果。
Gentoo Linux在默認的配置文件中啟用了-fstack-check。這是為了規避 Stack Clash 漏洞。-fstack-check是GCC的一個很老的功能,它有一個副作用,會引發一些非常愚蠢的行為,每個非葉子函數(也就是一個函數調用的函數)只會探測棧指針前4KB的空間。換句話說,用-fstack-check編譯的代碼可能至少需要4 KB的??臻g,除非它是一個葉子函數。
Go喜歡小巧的棧。
TEXT runtime·walltime(SB),NOSPLIT,$16 // Be careful. We're calling a function with gcc calling convention here. // We're guaranteed 128 bytes on entry, and we've taken 16, and the // call uses another 8. // That leaves 104 for the gettime code to use. Hope that's enough!
實際上,104個字節并不是對每個人都夠用,對我的內核來說也一樣。
需要指出的是,vDSO的規范沒有提到最大的棧使用保證,所以,Go做了一個無效的假設。
結論
這完美地詮釋了問題出現的原因。棧探測器是一個orq,它是跟0做邏輯或運算。這是一個無操作,但有效地探測了目標地址(如果它是未映射的,就會出現段錯誤)。但是我們沒有在vDSO代碼中看到段錯誤,那么Go為什么會出現呢?實際上,跟0做邏輯或運算并不是真的無操作。由于orq不是一個原子指令,而實際上是CPU讀取內存地址,然后再寫回來。這時候就出現了競爭條件。如果其他線程在其他的CPU上并行運行,那么orq就可能會消除同時發生的內存寫操作。由于寫入超出了棧的邊界,這可能會侵入其他線程的棧或隨機數據。這也是為什么GOMAXPROCS=1能夠解決這個問題的原因,因為這可以防止兩個線程同時運行Go代碼。
那么怎么修復呢?我把這留給了Go的開發人員。他們最終的解決方案是在調用vDSO函數之前 轉到更大的棧
上。這會引入了一個小小的速度延遲(納秒級),但這是可以接受的。在用修復過的Go工具鏈構建node_exporter之后,崩潰消失了。