Go運(yùn)行時(shí),對(duì)bug的分析調(diào)試過(guò)程解析
原文: Debugging an evil Go runtime bug
作者:Hector Martin
翻譯:雁驚寒
摘要:本文講述了作者通過(guò)對(duì)硬件、內(nèi)核進(jìn)行分析來(lái)調(diào)試程序bug的整個(gè)過(guò)程。以下是譯文。
前言
我是 Prometheus 和 Grafana 的超級(jí)粉絲。作為一名前谷歌SRE(Site Reliability Engineer, 網(wǎng)站可靠性工程師),我學(xué)會(huì)了如何選擇優(yōu)秀的監(jiān)控應(yīng)用程序。這個(gè)組合在過(guò)去的一年中一直是我戰(zhàn)無(wú)不勝的法寶。我使用它們監(jiān)控我自己的個(gè)人服務(wù)器(包括黑盒和白盒監(jiān)控)、為我的客戶提供專業(yè)的技術(shù)支持,以及實(shí)現(xiàn)其他很多的功能。 使用Prometheus編寫自定義導(dǎo)出程序來(lái)監(jiān)視數(shù)據(jù)非常地簡(jiǎn)單,而且你可以很方便地在其他地方找到一個(gè)適合于自己的可用的導(dǎo)出程序。例如,我們使用 sql_exporter 為Encounter事件制作了一個(gè)非常漂亮的儀表盤。
Euskal Encounter的事件儀表盤
由于把 node_exporter 部署到任何一臺(tái)機(jī)器上都非常簡(jiǎn)單,并且它能運(yùn)行一個(gè)Prometheus實(shí)例來(lái)為機(jī)器做基本的系統(tǒng)級(jí)監(jiān)控(包括CPU、內(nèi)存、網(wǎng)絡(luò)、磁盤、文件系統(tǒng)的使用情況等),那么我想,為什么不監(jiān)視一下我的筆記本電腦呢?我有一臺(tái)Clevo“游戲”筆記本電腦,它是我主要的工作電腦,大部分時(shí)間都是假裝在家里做臺(tái)式機(jī),有時(shí)也會(huì)和我一起參加像混沌通信大會(huì)(
Chaos Communication Congress)這樣的大型活動(dòng)。由于我已經(jīng)在它和一臺(tái)運(yùn)行Prometheus的服務(wù)器之間建立了VPN鏈接,所以,我可以通過(guò)執(zhí)行emerge prometheus-node_exporter來(lái)啟動(dòng)服務(wù),指向Prometheus實(shí)例,并自動(dòng)為其配置警報(bào)。這意味著每當(dāng)我打開太多Chrome選項(xiàng)卡并耗光32GB內(nèi)存的時(shí)候,我的手機(jī)就會(huì)收到報(bào)警。完美!
問題浮現(xiàn)
不過(guò),在設(shè)置完的一個(gè)小時(shí)之后,我的手機(jī)確實(shí)出現(xiàn)了一個(gè)提示:新添加的目標(biāo)無(wú)法訪問。我可以SSH到筆記本電腦,說(shuō)明電腦運(yùn)行正常,但node_exporter已經(jīng)崩潰了。
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是一種相對(duì)安全的語(yǔ)言,盡管有的時(shí)候你可能會(huì)搬起石頭砸自己的腳,而且它不像Rust那樣具有強(qiáng)有力的安全保證,但是,要在Go中產(chǎn)生段錯(cuò)誤也并不是那么容易的。 況且,node_exporter是一個(gè)相對(duì)來(lái)說(shuō)比較簡(jiǎn)單的Go應(yīng)用程序,只單純的依賴Go。 因此,這是一個(gè)非常有趣的崩潰,特別是崩潰在mallocgc里面。一般情況下,這里永遠(yuǎn)都不會(huì)崩潰。
重啟幾次之后,事情變得更有趣了:
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出現(xiàn)崩潰了。 為什么?
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
現(xiàn)在,垃圾收集者偶然間又發(fā)現(xiàn)了一個(gè)問題,是另一個(gè)崩潰。
在這一點(diǎn)上,很自然地就能得到兩個(gè)結(jié)論:要么是硬件有嚴(yán)重的問題,要么在在二進(jìn)制文件中存在一個(gè)嚴(yán)重的內(nèi)存破壞缺陷。 我最初認(rèn)為第一種情況不太可能,因?yàn)檫@臺(tái)機(jī)器上運(yùn)行的程序非常雜,沒有出現(xiàn)任何不穩(wěn)定的與硬件有關(guān)的跡象。 由于像node_exporter這樣的Go二進(jìn)制文件是靜態(tài)鏈接的,不依賴于任何其他庫(kù),所以我可以下載正式版的二進(jìn)制文件來(lái)試一下。 然而,當(dāng)我這樣做的時(shí)候,程序還是崩潰了。
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
又是一次完全不同的崩潰。這說(shuō)明node_exporter的上游或者它的一個(gè)依賴項(xiàng)確實(shí)存在問題,所以,我在GitHub上提交了一個(gè) issue 。也許開發(fā)者以前見過(guò)這個(gè),如果他們有什么想法的話,那么引起他們的注意是非常值得的。
走了一趟并不順暢的彎路
毫無(wú)疑問,對(duì)于上游問題,首先能想到的是這是一個(gè)硬件問題。畢竟我只是在一臺(tái)特定的機(jī)器上碰到這個(gè)問題。其他所有的機(jī)器都能很順利地運(yùn)行node_exporter。雖然在這臺(tái)主機(jī)上沒有其他硬件連接不穩(wěn)定的證據(jù),但是我也無(wú)法解釋這臺(tái)機(jī)器存在能導(dǎo)致node_exporter崩潰的特殊性。Memtest86+的運(yùn)行永遠(yuǎn)不會(huì)破壞其他程序,所以我安裝了一個(gè)。
然后,發(fā)生了這個(gè):
這是我用客戶的電腦所得到的
哎呀!RAM壞了。更具體點(diǎn)說(shuō)是有一位(bit)的壞內(nèi)存。在測(cè)試程序完整地運(yùn)行了一遍之后,最終得到的就只是那一個(gè)壞的位,另外在測(cè)試7中存在一些誤報(bào)(在附近移動(dòng)塊的時(shí)候出來(lái)了一個(gè)錯(cuò)誤)。
進(jìn)一步的測(cè)試表明,SMP模式下的Memtest86+測(cè)試5可以快速檢測(cè)到錯(cuò)誤,但通常不會(huì)在第一遍檢測(cè)的時(shí)候發(fā)現(xiàn)。錯(cuò)誤總是出現(xiàn)在相同的地址和相同的位上。這說(shuō)明這個(gè)問題出現(xiàn)在一個(gè)微弱或泄漏的RAM單元上,特別是隨溫度會(huì)變壞的那種。這非常符合邏輯:溫度的升高會(huì)增加RAM單元的泄漏,并且很有可能會(huì)引起位翻轉(zhuǎn)。
從這個(gè)角度來(lái)看,這是274,877,906,944個(gè)位中的一個(gè)壞點(diǎn)。這實(shí)際上是一個(gè)非常不錯(cuò)的的錯(cuò)誤率了!硬盤和閃存的錯(cuò)誤率要高得多,只是這些設(shè)備在出廠時(shí)會(huì)標(biāo)出壞塊,在用戶不知情的情況下透明地?fù)Q出,并且可以將新發(fā)現(xiàn)的弱塊透明地標(biāo)記為壞塊,并將其重新定位到備用區(qū)。內(nèi)存并不這么奢侈,所以一個(gè)壞的位永遠(yuǎn)都是壞的。
唉,這不可能成為node_exporter崩潰的原因。那個(gè)應(yīng)用程序使用的RAM很少,所以它碰到壞位的機(jī)會(huì)是非常低的。這類問題一般表現(xiàn)得并不會(huì)很明顯,也許會(huì)導(dǎo)致某些圖形中的像素錯(cuò)誤、在某些文本中出現(xiàn)單個(gè)字母的翻轉(zhuǎn)、也可能指令被破壞導(dǎo)致無(wú)法運(yùn)行,或者當(dāng)某些非常重要的數(shù)據(jù)確實(shí)落在了壞位上會(huì)出現(xiàn)崩潰。盡管如此,它確實(shí)會(huì)導(dǎo)致長(zhǎng)期的可靠性問題,這就是服務(wù)器和其他可靠設(shè)備必須使用ECC RAM才能糾正這種錯(cuò)誤的原因。
我沒有在這臺(tái)筆記本電腦上配置豪華的ECC RAM。但是我擁有將內(nèi)存壞塊標(biāo)記為壞的能力,并告訴操作系統(tǒng)不要使用它。GRUB 2有一個(gè)鮮為人知的功能,它允許你改變傳遞給啟動(dòng)內(nèi)核的內(nèi)存映射。僅僅為了一個(gè)壞塊而購(gòu)買新的RAM是不值得的,所以這是一個(gè)不錯(cuò)的選擇。
不過(guò),還有一件事情是我可以做的。由于情況會(huì)隨著溫度的升高而變差,那么如果我加熱RAM會(huì)發(fā)生什么呢?
memtest86+
愜意的100°C
我把熱風(fēng)槍設(shè)置到一個(gè)較低的溫度(130°C),并對(duì)兩個(gè)模塊進(jìn)行加熱(其他兩個(gè)模塊在后蓋下,因?yàn)槲业墓P記本電腦總共有四個(gè)SODIMM插槽)。我發(fā)現(xiàn)另外還有三個(gè)弱點(diǎn)只能在高溫下才能檢測(cè)到,它們分布在三個(gè)內(nèi)存條上。
我還發(fā)現(xiàn),即使我交換了模塊的位置,發(fā)生錯(cuò)誤的位置仍然保持大體上的一致:地址的最高位保持不變。這是因?yàn)镽AM是交錯(cuò)的:數(shù)據(jù)遍布在四個(gè)內(nèi)存條上,而不是在每個(gè)內(nèi)存條上連續(xù)分配可用地址空間的四分之一。因此,我可以屏蔽一個(gè)足夠大的RAM區(qū)域,以覆蓋每個(gè)錯(cuò)誤位所有可能的地址。我發(fā)現(xiàn),屏蔽連續(xù)的128KB區(qū)域應(yīng)該足以覆蓋每個(gè)給定壞點(diǎn)的所有可能的地址排列,但是,為了更好的進(jìn)行測(cè)量,我將它四舍五入到1MB。我用了三個(gè)1MB對(duì)齊的塊來(lái)進(jìn)行掩蓋(其中一個(gè)塊掩蓋了兩個(gè)壞點(diǎn),我總共要掩蓋四個(gè)壞點(diǎn)):
- 0x36a700000 – 0x36a7fffff
- 0x460e00000 – 0x460efffff
- 0x4ea000000 – 0x4ea0fffff
這可以使用GRUB的地址/掩碼語(yǔ)法來(lái)指定,/etc/default/grub如下所示:
GRUB_BADRAM="0x36a700000,0xfffffffffff00000,0x460e00000,0xfffffffffff00000,0x4ea000000,0xfffffffffff00000"
不用說(shuō),node_exporter還是崩潰了,但我知道了這不并是真正的問題所在。
深度挖掘
這種錯(cuò)誤很煩人,它顯然是因?yàn)榇a運(yùn)行的某塊內(nèi)存被破壞而引起的。這種錯(cuò)誤很難調(diào)試,因?yàn)槲覀儫o(wú)法預(yù)測(cè)什么會(huì)被破壞(或發(fā)生變化),而且我們也無(wú)法在發(fā)生錯(cuò)誤的時(shí)候捕捉到錯(cuò)誤的代碼。
首先,我嘗試了node_exporter的其他一些版本,并啟用或禁用了不同的參數(shù),但并沒有什么效果。我還嘗試在strace下運(yùn)行實(shí)例,似乎沒有發(fā)生崩潰,這強(qiáng)烈說(shuō)明了這是在競(jìng)爭(zhēng)條件下的一個(gè)問題。strace通常會(huì)攔截所有線程運(yùn)行的所有系統(tǒng)調(diào)用,并在某種程度上讓應(yīng)用程序的執(zhí)行串行化。后來(lái),我發(fā)現(xiàn)strace也崩潰了,但是運(yùn)行了很長(zhǎng)時(shí)間才出現(xiàn)崩潰。由于這似乎與并發(fā)有關(guān),所以我試著設(shè)置GOMAXPROCS=1,這個(gè)參數(shù)告訴Go只使用一個(gè)OS級(jí)別的線程來(lái)運(yùn)行Go代碼。崩潰再也沒有發(fā)生,問題再一次指向了并發(fā)。
到目前為止,我已經(jīng)收集了一定數(shù)量的崩潰日志,并開始關(guān)注其中的一些規(guī)律。雖然崩潰的位置以及表面原因有很多種,但是最終的錯(cuò)誤信息可以分為多個(gè)不同的類型,而且每種類型的錯(cuò)誤不止出現(xiàn)過(guò)一次。所以我開始使用谷歌搜索這些錯(cuò)誤,并偶然間發(fā)現(xiàn)了 Go issue #20427 。雖然這個(gè)問題似乎與Go無(wú)關(guān),但卻引起了類似的段錯(cuò)誤和隨機(jī)性問題。在Go 1.9之后,這個(gè)問題被關(guān)閉了,但并沒有得到解決。沒有人知道根本原因是什么,而且它再也沒有出現(xiàn)過(guò)。
所以,我從issue中抓取了 這段 聲稱能夠重現(xiàn)問題的示例代碼,并在我的機(jī)器上運(yùn)行。你看,它在幾秒鐘內(nèi)崩潰了。太好了。這比等待node_exporter崩潰所需的時(shí)間要短得多。
這并沒有讓我從Go的角度更接近這個(gè)問題,但它卻加快了我測(cè)試的速度。所以,我們來(lái)試試從另一個(gè)角度進(jìn)行分析吧。
把不同的電腦區(qū)分開來(lái)
這個(gè)問題發(fā)生在我的筆記本電腦上,但在其他機(jī)器上卻都沒有發(fā)生。我嘗試著在其他電腦上重現(xiàn)這個(gè)問題,但沒有一臺(tái)機(jī)器發(fā)生崩潰。這說(shuō)明我的筆記本電腦中有一些特別的東西。由于Go是靜態(tài)鏈接的二進(jìn)制文件,所以其余的用戶空間并不重要。這留下了兩個(gè)相關(guān)的部分:硬件和內(nèi)核。
我沒有什么方法來(lái)測(cè)試各臺(tái)電腦的硬件,除了我自己的機(jī)器,但我可以搗鼓內(nèi)核。所以,我們來(lái)試著走第一步:它會(huì)在虛擬機(jī)中崩潰嗎?
為了測(cè)試這個(gè),我創(chuàng)建了一個(gè)最小化的initramfs,這使我能夠快速啟動(dòng)QEMU虛擬機(jī),而不必安裝發(fā)行版或啟動(dòng)完整的Linux系統(tǒng)。我的initramfs是用Linux的scripts/gen_initramfs_list.sh構(gòu)建的,包含以下文件:
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的入口,在我這個(gè)案例中是一個(gè)簡(jiǎn)單的shell腳本,用于啟動(dòng)測(cè)試并測(cè)量時(shí)間:
#!/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的一個(gè)靜態(tài)鏈接版本,通常用于這樣的最小化系統(tǒng),用以提供所有基本的Linux shell實(shí)用程序(包括shell本身)。
initramfs可以這樣構(gòu)建(從Linux內(nèi)核源代碼樹中),其中,list.txt是上面的文件列表:
scripts/gen_initramfs_list.sh -o initramfs.gz list.txt
QEMU可以直接引導(dǎo)內(nèi)核和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
并沒有任何信息輸出到控制臺(tái)上…… 我意識(shí)到我沒有為筆記本電腦內(nèi)核編譯8250串行端口支持。哦,我太蠢了。它根本沒有物理串口,對(duì)吧?不管怎么樣,我重新編譯了內(nèi)核,并附帶串行支持。我再試了一下,它成功啟動(dòng)并運(yùn)行了。
它崩潰了嗎?是的。太好了,這意味著這個(gè)問題在同一臺(tái)機(jī)器上的虛擬機(jī)上是可以重現(xiàn)的。我在家里的服務(wù)器上用同樣的QEMU命令,用自己的內(nèi)核,但什么也沒有發(fā)生。然后,我從筆記本電腦中把內(nèi)核復(fù)制過(guò)來(lái),然后啟動(dòng),崩潰了。內(nèi)核是問題的關(guān)鍵,硬件不是問題。
搗鼓內(nèi)核
我意識(shí)到自己需要編譯許多的內(nèi)核來(lái)嘗試才能縮小范圍,所以,我決定轉(zhuǎn)移到一臺(tái)最強(qiáng)大的機(jī)器上來(lái):一個(gè)有點(diǎn)舊的12核24線程Xeon處理器的機(jī)器。我將已知的壞內(nèi)核源復(fù)制到那臺(tái)機(jī)器上,構(gòu)建并進(jìn)行測(cè)試。
它竟然沒有崩潰!為什么?
在仔細(xì)思索了一番之后,我已經(jīng)能夠確定是原來(lái)的壞的內(nèi)核二進(jìn)制文件崩潰了。我們要回到分析硬件的問題上去嗎?跟我在哪臺(tái)機(jī)器上編譯內(nèi)核有關(guān)嗎?所以,我試著在家用服務(wù)器上編譯內(nèi)核,接著,這個(gè)崩潰立即觸發(fā)了。在兩臺(tái)機(jī)器上構(gòu)建相同的內(nèi)核會(huì)導(dǎo)致崩潰,而第三臺(tái)機(jī)器不會(huì)。它們之間有什么不同呢?
我的筆記本電腦和家用服務(wù)器都是〜amd64(非穩(wěn)定版),而我的Xeon服務(wù)器是amd64(穩(wěn)定版)。這意味著它們的GCC是不同的。我的筆記本電腦和家用服務(wù)器都是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。
但是我的家用服務(wù)器內(nèi)核與筆記本電腦內(nèi)核幾乎是相同的(盡管不完全相同),使用相同的GCC構(gòu)建,并沒有重現(xiàn)崩潰。所以,現(xiàn)在我們必須得出結(jié)論:用來(lái)構(gòu)建內(nèi)核的編譯器和內(nèi)核本身(或其配置?)都有問題。
為了進(jìn)一步縮小范圍,我在家用服務(wù)器(linux-4.13.9-gentoo)上編譯了筆記本電腦上的內(nèi)核樹,并確認(rèn)它出現(xiàn)了崩潰。然后,我把家用服務(wù)器上的.config復(fù)制過(guò)來(lái)并編譯,發(fā)現(xiàn)它沒有崩潰。這么做是因?yàn)槲覀兿胍獙ふ覂?nèi)核配置之間的差異和編譯器之間的差異:
- 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 - 沒有崩潰
兩個(gè).config,一個(gè)好,一個(gè)壞。需要一點(diǎn)時(shí)間來(lái)查看它們之間的差異。當(dāng)然,這兩個(gè)配置文件是完全不同的(因?yàn)槲蚁矚g定制我的內(nèi)核配置,讓它只包含特定機(jī)器上所需的驅(qū)動(dòng)程序),所以我不得不在重復(fù)編譯內(nèi)核來(lái)縮小差異。
我決定從“壞”的.config開始著手,從中刪除一些東西。由于要測(cè)試崩潰需要等待一定的時(shí)間,所以測(cè)試“崩潰”比“不崩潰”更容易。在22個(gè)內(nèi)核的構(gòu)建過(guò)程中,我對(duì) config 文件做了簡(jiǎn)化,使其不支持網(wǎng)絡(luò)、沒有文件系統(tǒng)、沒有塊設(shè)備核心,甚至不支持PCI(但它仍然可以在虛擬機(jī)上正常工作!)?,F(xiàn)在編譯一下內(nèi)核不到60秒的時(shí)間,內(nèi)核大小大約是我常用內(nèi)核的四分之一左右。
然后,我轉(zhuǎn)移到“好”的.config文件上來(lái),刪除了所有不必要的垃圾,同時(shí)確保它不會(huì)崩潰(這比之前的測(cè)試更加棘手更加慢)。也有一些有問題的分支,我在這些分支上修改了一些東西,接著就開始崩潰了。但是,我誤認(rèn)為這些分支是“不會(huì)崩潰”的。所以,當(dāng)崩潰發(fā)生的時(shí)候,我不得不找回以前的內(nèi)核,并找出引起崩潰的確切的原因。最后,我一共編譯了7個(gè)內(nèi)核。
最后,我把范圍縮小到.config中的幾個(gè)不同的選項(xiàng)上來(lái)。其中有幾個(gè)嫌疑很大,特別是CONFIG_OPTIMIZE_INLINING。經(jīng)過(guò)仔細(xì)地測(cè)試,我得出結(jié)論:這個(gè)選項(xiàng)就是罪魁禍?zhǔn)住0阉P(guān)掉,就會(huì)產(chǎn)生崩潰,啟用,就不會(huì)崩潰。這個(gè)選項(xiàng)在打開的時(shí)候允許GCC自己確定哪個(gè)inline函數(shù)真的需要內(nèi)聯(lián),而不是強(qiáng)制內(nèi)聯(lián)。這也解釋了:內(nèi)聯(lián)行為可能隨著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
那么接下來(lái)做什么呢? 我們知道CONFIG_OPTIMIZE_INLINING這個(gè)選項(xiàng)使得測(cè)試結(jié)果出現(xiàn)不同,但是它可能會(huì)改變整個(gè)內(nèi)核中每一個(gè)inline的行為。 如何查明問題的真相呢?
我有一個(gè)主意。
基于散列的差異化編譯
我們要做的是在選項(xiàng)打開的情況下編譯內(nèi)核的一部分,在選項(xiàng)關(guān)閉的情況下編譯另一部分。 通過(guò)測(cè)試生成的內(nèi)核并檢查問題是否重現(xiàn),可以推導(dǎo)出內(nèi)核編譯單元的哪個(gè)子集的代碼有問題。
我沒有列舉出所有的目標(biāo)文件,或是進(jìn)行某種二分法搜索,而是決定采用基于散列的方法。 我為GCC編寫了這個(gè)包裝器腳本:
#!/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[@]}"
這個(gè)腳本使用SHA-1來(lái)取目標(biāo)文件名的散列值,然后從前32位中檢查散列的給定位(由環(huán)境變量$BIT進(jìn)行標(biāo)識(shí))。 如果這個(gè)位的值是0,則編譯的時(shí)候不帶CONFIG_OPTIMIZE_INLINING, 如果是1,則帶上CONFIG_OPTIMIZE_INLINING。 我發(fā)現(xiàn)內(nèi)核大約有685個(gè)目標(biāo)文件,這需要大約10個(gè)位來(lái)進(jìn)行唯一標(biāo)識(shí)。 這種基于散列的方法有一個(gè)很好的屬性:我可以選擇產(chǎn)生崩潰可能性比較大的結(jié)果(即位的值是0),因?yàn)橐C明給定的內(nèi)核不會(huì)崩潰是很困難的(因?yàn)楸罎⑹歉怕市猿霈F(xiàn)的, 可能需要相當(dāng)一段時(shí)間才會(huì)發(fā)生)。
我構(gòu)建了32個(gè)內(nèi)核,只花了29分鐘的時(shí)間。然后,我開始對(duì)它們進(jìn)行測(cè)試,每當(dāng)發(fā)生崩潰的時(shí)候,我都會(huì)將可能的SHA-1散列的正則表達(dá)式縮小到那些在這些特定位置上是0的散列。在發(fā)生了8次崩潰的時(shí)候,我把范圍縮小到4個(gè)目標(biāo)文件。一旦出現(xiàn)了10次崩潰之后,就只剩下唯一的一個(gè)了。
$ grep '^[0246][012389ab][0189][014589cd][028a][012389ab][014589cd]' objs_0.txt 6b9cab4f arch/x86/entry/vdso/vclock_gettime.o
vDSO的代碼。當(dāng)然。
vDSO在搗鬼
內(nèi)核的vDSO實(shí)際上并不算是內(nèi)核代碼。 vDSO是內(nèi)核放置在每個(gè)進(jìn)程地址空間中的一個(gè)小型共享庫(kù),它允許應(yīng)用程序在不離開用戶模式的情況下執(zhí)行特定的系統(tǒng)調(diào)用。這大大提高了系統(tǒng)性能,同時(shí)仍然允許內(nèi)核根據(jù)需要更改這些系統(tǒng)調(diào)用的實(shí)現(xiàn)細(xì)節(jié)。
換句話說(shuō),vDSO是用GCC編譯的代碼,與內(nèi)核一起構(gòu)建,最終與每個(gè)用戶空間的應(yīng)用程序進(jìn)行鏈接。它是用戶空間的代碼。這就解釋了為什么內(nèi)核和它的編譯器都與此有關(guān):這并不是跟內(nèi)核本身有關(guān),而是與內(nèi)核提供的共享庫(kù)有關(guān)! Go使用vDSO來(lái)提升性能。Go也正好有一個(gè)重建自己的標(biāo)準(zhǔn)庫(kù)的戰(zhàn)略,所以,它沒有使用任何標(biāo)準(zhǔn)的Linux glibc的代碼來(lái)調(diào)用vDSO,而是使用了自己的代碼。
那么改變CONFIG_OPTIMIZE_INLINING的值對(duì)vDSO有什么作用呢?我們來(lái)看看這段匯編。
設(shè)置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> [...]
設(shè)置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這個(gè)本應(yīng)該讓GCC內(nèi)聯(lián)變少的標(biāo)志,實(shí)際上卻讓內(nèi)聯(lián)變得更多:vread_tsc在該版本中內(nèi)聯(lián),而不在CONFIG_OPTIMIZE_INLINING=n版本中。但是vread_tsc根本沒有標(biāo)記為內(nèi)聯(lián),所以GCC完全有權(quán)限這么做。
但誰(shuí)在乎函數(shù)是否內(nèi)聯(lián)了呢?真正的問題在哪里呢?那么,仔細(xì)觀察一下非內(nèi)聯(lián)版本吧……
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會(huì)分配超過(guò)4KB的棧呢?這不是棧分配,這是棧探測(cè),或者更具體地說(shuō)是GCC-fstack-check 特性 的結(jié)果。
Gentoo Linux在默認(rèn)的配置文件中啟用了-fstack-check。這是為了規(guī)避 Stack Clash 漏洞。-fstack-check是GCC的一個(gè)很老的功能,它有一個(gè)副作用,會(huì)引發(fā)一些非常愚蠢的行為,每個(gè)非葉子函數(shù)(也就是一個(gè)函數(shù)調(diào)用的函數(shù))只會(huì)探測(cè)棧指針前4KB的空間。換句話說(shuō),用-fstack-check編譯的代碼可能至少需要4 KB的??臻g,除非它是一個(gè)葉子函數(shù)。
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!
實(shí)際上,104個(gè)字節(jié)并不是對(duì)每個(gè)人都?jí)蛴?,?duì)我的內(nèi)核來(lái)說(shuō)也一樣。
需要指出的是,vDSO的規(guī)范沒有提到最大的棧使用保證,所以,Go做了一個(gè)無(wú)效的假設(shè)。
結(jié)論
這完美地詮釋了問題出現(xiàn)的原因。棧探測(cè)器是一個(gè)orq,它是跟0做邏輯或運(yùn)算。這是一個(gè)無(wú)操作,但有效地探測(cè)了目標(biāo)地址(如果它是未映射的,就會(huì)出現(xiàn)段錯(cuò)誤)。但是我們沒有在vDSO代碼中看到段錯(cuò)誤,那么Go為什么會(huì)出現(xiàn)呢?實(shí)際上,跟0做邏輯或運(yùn)算并不是真的無(wú)操作。由于orq不是一個(gè)原子指令,而實(shí)際上是CPU讀取內(nèi)存地址,然后再寫回來(lái)。這時(shí)候就出現(xiàn)了競(jìng)爭(zhēng)條件。如果其他線程在其他的CPU上并行運(yùn)行,那么orq就可能會(huì)消除同時(shí)發(fā)生的內(nèi)存寫操作。由于寫入超出了棧的邊界,這可能會(huì)侵入其他線程的棧或隨機(jī)數(shù)據(jù)。這也是為什么GOMAXPROCS=1能夠解決這個(gè)問題的原因,因?yàn)檫@可以防止兩個(gè)線程同時(shí)運(yùn)行Go代碼。
那么怎么修復(fù)呢?我把這留給了Go的開發(fā)人員。他們最終的解決方案是在調(diào)用vDSO函數(shù)之前 轉(zhuǎn)到更大的棧
上。這會(huì)引入了一個(gè)小小的速度延遲(納秒級(jí)),但這是可以接受的。在用修復(fù)過(guò)的Go工具鏈構(gòu)建node_exporter之后,崩潰消失了。