取樣對上插樁:兩種問「時間花到哪去了?」的方式
一個會當機的程式,大聲地告訴你出了問題。一個只是跑得慢的程式,什麼都不告訴你——它只是安靜地燒掉時間,而你的工作是找出它燒在哪裡。要找出來有兩種根本不同的方式,而選錯就是第一個錯誤。插樁式的做法是在每個函式前後加一個小計時器:進入時記下時鐘、離開時記下時鐘、把差值加起來。這很精確——你會知道每一次呼叫的真實成本——但它有一個你從第 1 篇的觀察者效應已經半懂的殘酷缺陷:那段計時程式碼本身在每一次呼叫上都會跑,所以一個被呼叫一百萬次的函式,現在多了一百萬次時鐘讀取。量測改變了它所量測的東西,而一個微小的熱門函式,可能光是因為被觀看就慢上好幾倍。
取樣式的做法則相反,而那正是持續剖析器所用的。你不去替每一次呼叫計時,而是讓程式不受打擾地跑,只是每秒偷瞄它很多次——比方說每秒 99 次——而每一次偷瞄你只記一件事:當前的呼叫堆疊,也就是從最內層函式一路向外到 main() 的那串返回位址。其他什麼都不做。每次偷瞄幾乎不花成本,所以程式幾乎以全速執行。代價是你不再有精確的時間;你有的是一幅統計的圖像。但那幅圖像恰恰是你想要的,而推理很簡單:如果某個函式是 CPU 花掉 40% 時間之處,那麼大約 40% 的隨機偷瞄會抓到 CPU 正在那個函式裡頭。在樣本中出現的頻率,與所花的時間成正比。這就是一個取樣式剖析器的全部想法。
讀懂火焰圖:寬度是時間,深度是呼叫鏈
一旦你手上有了數萬個堆疊樣本,你就面對一個新問題:你要怎麼看它們?一份原始清單毫無用處。答案是系統工作裡最出色的視覺化之一,火焰圖,而一旦你能讀它,就能一眼診斷出一個慢程式。訣竅是把所有共享同一個堆疊的樣本疊在一起,排成一格格巢狀的矩形。每一個方塊是一個函式。一個方塊坐在呼叫它的那個方塊之上,所以縱軸就是呼叫堆疊——main() 在最底下、最深的被呼叫者在最頂端。而這裡是要緊的部分:一個方塊的寬度,是有多少樣本抓到那個函式在堆疊上,也就是有多少時間花在它、以及它所呼叫的一切之上。
|<--------------------- all samples (100%) --------------------->|
[ main ]
[ handle_request ][ log_line ][ idle ]
[ parse_json ][ db_query ][ format ]
[ strlen ][ memcpy ][ recv ][ parse_row ]
^^^^^^^^^^^^^
width = time spent (sample count) -> WIDE box = hot
height = depth of the call stack
a wide PLATEAU at the top = a leaf burning CPU on its own讀它會變成一種反射。一根又高又細的尖刺,是一條深的呼叫鏈、幾乎不花成本——別理它。一個寬的方塊,才是你的時間所在之處。沿著頂端的邊緣掃過去:頂端一塊寬的平台意味著一個葉子函式本身在燒 CPU,而不是再往下呼叫,那個葉子就是你的頭號嫌疑犯。關鍵是,x 軸不是時間順序——方塊被排序以合併相同的堆疊,所以左到右毫無意義;只有寬度與堆疊才承載意義。一個誠實的告誡:這種「在 CPU 上(on-CPU)」的火焰圖顯示的是 CPU 在哪裡忙,所以它對花在等待上的時間是盲的——卡在一把鎖上、睡在一個阻塞的讀取裡、停在 CPU 之外。如果你的程式很慢、火焰圖卻看起來很閒,那時間就是花在等待上,而你會想改用一份「離開 CPU(off-CPU)」的剖析,而下一個想法讓它的收集變得便宜。
為什麼剖析如今能在每個地方常駐開著
傳統上你靠重現一個慢的情況來剖析:在你的筆電上把程式跑在剖析器底下、拿一個負載測試去打它、讀結果。但真正要緊的臭蟲,往往是那個只在凌晨三點、在一千台機器裡的某一台上、在一種你無法重現的流量組合下才現身的。現代的答案是持續剖析:從每一台機器上的每一個行程,一直收集一份低頻率的堆疊取樣剖析,並把它送到一個你幾週後還能查詢的中央儲存。當某件事在上週二很慢時,你不用重跑任何東西——你只要去看那份早已在記錄的剖析。它把剖析從一場特別的遠征,變成環境裡隨手可得的資料,正是把第 1 篇那種可觀測性的哲學,套用到 CPU 時間上。
讓這件事變得新近負擔得起的,是第 2 篇那套機械裝置。一個持續剖析器通常是一個小小的 eBPF 程式,掛在 CPU 的取樣計時器上:每一聲滴答,核心端的 eBPF 程式碼就走訪當前的堆疊、並對一個以那個堆疊為鍵的 BPF map 裡的計數器加一,整件事都在核心裡,沒有任何原始樣本的副本越界進入使用者空間,直到你做聚合。額外開銷落在 CPU 的不到百分之一——便宜到足以在正式環境裡永遠開著。而因為 eBPF 也能走訪核心堆疊,一個剖析器就能在單一一張統一的火焰圖裡,看見一個請求從你的函式、向下穿過一個系統呼叫、進入核心的流動——這是任何只在使用者空間的剖析器永遠無法給你看的。
誠實地談談極限,因為一個會說謊的剖析器比沒有還糟。取樣是統計性的,所以一個跑了一微秒、卻剛好從不在某聲滴答時出現在堆疊上的函式,可能是隱形的——取樣對胖的部分很厲害,對細的、不常出現的部分則很弱。如同我們說過的,預設的每 CPU 剖析也漏掉等待(離開 CPU)的時間。而在核心裡走訪一個堆疊需要可靠的框架資訊;一個被編譯成省略框架指標的二進位檔,可能給走訪器一個被截斷或錯誤的堆疊,除非它有 DWARF 回溯資訊、或編譯器保留了框架指標。把一份剖析當成關於熱路徑的一個有力的統計提示,在你動手最佳化之前先確認它——量測,別猜這個紀律,對剖析器自己的主張也同樣適用。
核心傾印:死亡那一瞬間記憶體的快照
剖析是給那個太慢的程式的。這篇的另一半,是給那個已經死掉的程式的。當一個行程犯下一個致命錯誤——解參考一個空指標而吃到一次區段錯誤、或踩到一個斷言——核心可以在它走向終結的途中,寫出一個核心傾印:一個檔案,裝著那個行程在它死掉的確切瞬間、記憶體與暫存器的凍結快照。把它想成一張屍體的照片。堆積的每一個位元組、每一條執行緒完整的呼叫堆疊、rip 裡那個指向出錯指令本身的值、rax 與 rsp 與 rbp 的內容——全部,都被捕捉了。當機已經發生、行程也走了,但證據在磁碟上,而你可以從容地檢視它。
你用兩級之前做即時除錯時用的那同一個除錯器去打開那張照片,只是現在你把它對準屍體:gdb ./myprog core。除錯器載入這份傾印,彷彿行程正暫停在當機的那一刻,而你問的第一件事,正是能當場解掉大多數當機的那一個——要一份回溯。指令 bt 會印出回溯:從出錯的那個函式、向外穿過每一個呼叫者、到 main() 的那串堆疊框架。在一個畫面裡,你就看見哪一行、哪個函式當掉了,以及通向那裡的確切呼叫路徑。然後你可以印出任何區域變數、或跟著任何指標走,把那個已死程式的記憶體當成單純的資料來讀——那個指標是 0x0 嗎、那個索引超出陣列尾端了嗎、那個長度大得荒謬嗎?
當核心自己死掉:kdump 與正式環境的紀律
一個核心傾印捕捉一個死掉的行程。但有時死掉的東西,是核心自己——一次核心恐慌,作業系統撞上一個它無法復原的錯誤、停下整台機器。在那一刻你無法寫一個普通檔案,因為那個負責寫檔案的子系統,本身就是剛剛死掉的東西的一部分;你不能信任正在執行的核心去傾印它自己。聰明的解法是 kdump:一個第二個、微小的當機核心,在開機時被載入到一塊保留的記憶體裡、保持休眠。當主核心恐慌時,它不只是凍住,而是把控制權交給那個預先載入的當機核心,它在保留記憶體裡開機、把死掉的核心的記憶體當成一個巨大的核心傾印、並把它寫到磁碟上以供分析。
無論你讀的是一個行程核心檔、還是一個 kdump 映像,讓正式環境當機分析真正行得通的紀律是一樣的,而把它明白地當成一套例行公事說出來是值得的。一次當機只是一個症狀;傾印是你的證據;目標是根本原因,而不是那個剛好出錯的行號。這套有紀律的走法很短,而你在職業生涯裡會跑它很多次。
- 確認傾印存在且相符:用產生它的那個確切版本載入核心檔(gdb ./prog core),檢查二進位檔與傾印是同一個版本——一個對不上的二進位檔會給出一份垃圾的、說謊的回溯。
- 取得回溯:跑 bt 去看哪一行出錯、以及完整的呼叫者鏈,然後在各個框架之間上下移動去讀區域變數——光是這一步就能解釋大多數的當機。
- 檢視出錯時的狀態:看暫存器、以及那個惹禍的指標或索引——它是 0x0(一次空解參考)、一個野位址、還是剛好超出某個陣列的尾端?把那個值對應到一個已知的臭蟲類別。
- 和線索交叉比對:把這份傾印對齊到第 1 篇那些圍繞著那個時間戳的日誌、指標與追蹤,好讓你理解導致它的條件,而不只是最後那條指令。
- 說出根本原因、修那個,而不是症狀:一次空解參考通常是上游少了一個檢查,而不是當掉那一行的臭蟲——修上游的原因,並補上那個原本就該攔住它的檢查。
把兩半綁在一起
退一步看,這兩個工具是同一種直覺、對準一個程式的兩種狀態。持續剖析觀看那個活著的系統——一直取樣、統計性的,在機隊規模上、靠 eBPF 以近乎零的成本回答「時間花到哪去了?」。當機分析檢視那個死掉的系統——一張凍結的快照、精確的,透過一個核心傾印與一份回溯回答「它死掉時處於什麼狀態?」。一個是健康程式的一部連續影片;另一個是失敗那一刻的單張清晰照片,而一個認真的正式環境工程師,會伸手去拿情況所要求的那一個。
兩者也共享一個值得帶出這一級的誠實主題:量測的好壞,只取決於它背後那份符號與相符的版本。一份剝了符號的二進位檔的剖析,是一面十六進位的牆;一個對著錯誤版本打開的核心傾印,是一個自信的謊言。讓這篇裡的一切行得通的、那個便宜又無趣的習慣,是把你所交付的每一個版本、那個未剝符號、帶完整除錯資訊的二進位檔,收在某個安全的地方——這樣無論是機隊很慢、還是某一台機器死了,那些原始位址永遠能被解析回你的函式名稱與行號。這一級的最後一篇會走出最後一步:跟隨一個單一的請求,不是向下進入一台機器的堆疊,而是橫越許多台機器,用分散式追蹤。