跳至主要內容
OpenAI

2026年6月30日

工程

核心傾印流行病學:修復存在 18 之久的錯誤

運用族群層級分析,為資料基礎架構中難以排查的當機問題偵錯。

載入中…

OpenAI 的模型和智慧體越來越仰賴可擴充的資料基礎架構,以便在推論時,也就是模型思考你的問題時,搜尋相關資料。其中一些服務以 C++ 撰寫;C++ 對系統的低階控制讓我們能最大化效能並盡量降低記憶體用量。隨著規模擴大,這些效率優勢很重要,但 C++ 缺乏記憶體安全性,代表錯誤可能因寫入不正確或不存在的記憶體位址而導致當機。

幾個月前,我們觀察到 Rockset 服務內部出現當機問題。Rockset 是我們 ChatGPT 資料基礎架構中的客製化部分,對許多資料外掛程式以及對話搜尋都很關鍵。在每一次當機中,一個普通的 C++ 函式似乎執行完畢後回到一個假的位址,導致核心停止程式,因為指令指標不再指向程式碼。有時堆疊框架中的返回位址槽是 NULL。有時堆疊指標 CPU 暫存器本身似乎偏移了 8 個位元組,彷彿 %rsp 不知為何在正常執行途中被遞減了。兩種情況下,都是發生在返回時發生當機。

這些並不是應用程式碼的正常失敗模式。一次意外寫入剛好只落在已儲存的返回位址上是可能的,但機率極低。在不涉及行內組合語言、setcontextlongjmp(我們都沒有使用)的情況下,有錯誤讓 %rsp 偏移 8 更加奇怪,因為編譯後的程式碼只會在函式前言和結尾直接調整該暫存器。我們(或 ChatGPT)想得到的每個假設都有強力反證,因此這個錯誤看起來不可能存在。

我們原以為是一個問題,最後發現其實是兩個無關的錯誤,只是碰巧同時被發現。第一,是一台 Azure 主機上的無聲硬體損壞,CPU 就是無法正確運算。第二,是 GNU libunwind 中存在 18 年的競態條件,這是一個廣泛使用的開源函式庫中未被察覺的錯誤。

這篇文章講述我們如何像流行病學家一樣思考,並建立涵蓋所有當機族群的高品質資料集,藉此找出並修復看似無法解釋的當機。

第一次偵錯嘗試:仔細檢查少數核心傾印

首先,讓我們更深入談談 Rockset。這套雲端原生資料系統用於搜尋和即時分析;我們在 OpenAI 將其應用於許多內部案例,例如同步連接器(Rockset 於 2024 年由 OpenAI 收購)。串流更新用來維持工作區知識庫的最新索引,讓 ChatGPT 在回答問題或執行動作時能搜尋相關資訊。

Rockset 的執行層以 C++ 撰寫。C++ 語言提供對 CPU 的低階存取,這有利於效能和效率,但也代表應用程式錯誤可能導致無效的記憶體存取和段錯誤。為了協助追蹤這些問題,我們使用 folly 的致命訊號處理器,在當機發生時記錄堆疊追蹤,並將對應的核心傾印(程式當機時狀態的快照)上傳到 Azure blob 儲存體,以便日後分析。Rockset 的所有查詢處理葉節點都有複本,這能將當機對用戶端的影響降到最低。不過,每一次段錯誤都代表有一個錯誤需要修復,才能達到我們的可靠性與品質目標。

我們最初的做法,是把這些核心傾印當成傳統偵錯問題:非常仔細地檢查幾個核心傾印,形成假設,再逐一排除。

多數當機都發生在名為 DocumentTree::updateDocument 的方法中。在這些當機事件中,看起來像是 updateDocument 呼叫了某個未知函式 X,堆疊在 X 執行期間遭到破壞,接著 X 返回到一個不是可執行程式碼的位址。在某些情況下,X 剛被彈出的框架看起來有效,只是其儲存的返回位址是 NULL。在其他情況下,堆疊指標本身看起來不對,但下一個有效框架似乎仍然是 updateDocument

我們不知道堆疊是在何時遭到破壞,這讓搜尋空間變得巨大。updateDocument 是一個大型方法,經過大量內嵌,因此 X 的候選數量多到難以處理。

這是我們 C++ 程式碼中的錯誤嗎?是編譯器或連結問題?是我們某個執行階段函式庫的問題?是 Linux 核心在訊號遞送或內容切換上的 錯誤?還是更罕見的東西?如果這是一次意外寫入,為什麼沒有我們的 ASAN 預備環境沒有抓到?

我們嘗試用應用程式層級的日誌找出所有問題案例,但堆疊破壞錯誤很難只靠日誌分類,因為記錄下來的堆疊追蹤本身已損壞或缺失。我們無法建構一個同時沒有誤報與漏報的日誌查詢。我們手動檢查了更多核心傾印,也找到一些額外例子,但這個流程太耗費人力,無法產出可信的資料集。

調查到這個階段時,我們(錯誤地)排除了硬體錯誤,因為我們在多個區域和多種硬體類型上都看到當機,所以仍在尋找純軟體原因。有幾天,我們深入研究單一個 %rsp 未對齊的當機,利用堆疊和暫存器內容重建當機前的歷史。這產生了一些可能線索,但因為我們沒有放下最初「所有錯誤都有同一原因」的結論,所以仍無法突破僵局。

來自堆疊的線索

在談到調查轉捩點之前,有必要先說明我們從核心檔案中擷取的是哪類資訊。

Rockset 以 -fno-omit-frame-pointer 編譯,因此作用中的堆疊框架總能透過 %rbp 取得,而呼叫端會形成一串框架指標鏈結串列。

在 Linux x86_64 上,AMD64 System V ABI 也會在 %rsp 下方保留 128 個位元組作為 red zone。這個區域可供使用者空間程式碼使用;更重要的是,核心承諾在遞送訊號時不會覆寫它,這是 ABI 合約的一部分。

red zone 是我們偵錯返回後當機的關鍵,因為它保留了返回前的一些資訊。當 SIGSEGV 觸發時,folly 的致命訊號處理器會在當機執行緒的堆疊上執行。不再作用中的堆疊框架(因為其函式已返回)會被訊號處理器覆寫,最後 128 個位元組除外。因此我們才會說「X 剛被彈出的堆疊框架看起來有效,只是返回位址是 NULL」。red zone 會保留部分非作用中框架,有時則只保留某個非作用中框架的尾端。

堆疊示意圖,顯示受損的堆疊框架可能覆寫返回位址並造成當機。

我們找到一個堆疊未對齊的當機,其中涉及的函式都非常小。這讓我們看出 %rsp 是在一個相對簡單的函式執行期間變得未對齊,而且之後還有更多呼叫成功執行。程式直到作用中的函式最後嘗試返回時才當機。那些程式碼路徑都沒有使用例外、行內組合語言、setcontextlongjmp,所以如果堆疊指標真的如核心傾印所示那樣改變,使用者空間程式碼中沒有合理的錯誤能解釋這個問題。

這把我們推向了核心。

Rockset 使用訊號的方式比大多數程式更積極。查詢執行會被拆成許多交換資料的輕量工作。這對高效率處理高 QPS 工作負載很重要,但也讓每個查詢的 CPU 計算變得棘手,因為許多查詢的工作會多工到同一個執行緒池上。

我們的解法是一個稱為 coarse_thread_cputime_clock 的機制,它以極低成本提供近似 clock_gettime(CLOCK_THREAD_CPUTIME_ID, ...) 的結果,可在每個工作邊界取樣。timer_create API 可用來依據幾種時間流逝概念排程週期性訊號遞送,包括 CPU 時間累積。我們排程一個訊號(SIGUSR2),讓它每隔幾毫秒 CPU 時間遞送一次,此時訊號處理器會更新執行緒本地值。雖然許多工作在執行期間看不到粗略時鐘前進,但把所有差值加總,會得到查詢實際 CPU 時間的無偏估計。

由於我們如此頻繁地遞送訊號,內容切換或訊號遞送附近的罕見核心錯誤看起來是可能的。我們花時間閱讀錯誤報告、核心原始碼,以及 Azure 專用的核心修補程式。我們也嘗試了壓力測試。但沒有找到任何看似相關的東西。

到那時,我們決定退一步,改用不同方法。

醫師,還是流行病學家?

偵錯這類問題,大致有兩種方式。

一種是像某種醫師:專注於一位病人,做很多檢查,並嘗試根據詳細證據診斷單一病例。

另一種更像流行病學家:觀察整個族群,詢問是否存在單一病例無法揭露的模式。這個錯誤是從某個特定版本開始的嗎?它是否與某一種硬體 SKU(特定 CPU 與伺服器型號)、某個區域或某個核心版本相關?看似同一種症候群之下,是否藏著多個不同群集?

我們先前大多處於醫師模式。關鍵轉變是,我們決定必須蒐集高品質的族群資料。

清理資料

我們先前試圖自動找出所有問題案例都失敗了,因為我們嘗試對日誌做文字搜尋。核心傾印本身包含的資訊多很多,但手動查看無法擴展。我們決定投入心力建立一條能自動分析核心傾印的管線。

我們讓 ChatGPT 撰寫腳本,下載每個核心檔案的前綴、擷取暫存器、使用日誌過濾已知誤報,並自動將當機標記為 return-to-null、misaligned-stack 或其他。接著,我們將該腳本並行套用到前一年所有生產環境 Rockset 核心傾印。

這就是轉捩點。

一旦有了乾淨的資料集,相關性立刻浮現。我們一直當成單一奇怪錯誤的問題,其實是兩個不同的當機族群。

return-to-null 核心傾印分布在許多叢集和地理區域。它們的頻率最近有所上升,但沒有明確的開始日期,也沒有清楚的基礎架構邊界。

堆疊未對齊的當機看起來完全不同。它們全都來自同一區域,有清楚的開始日期,而且從未發生在已長時間運行的節點上。雖然它們涉及多台 Azure VM(託管於雲端的虛擬機器),但模式看起來像是某台實體機器硬體不良,導致剛好落在上面的任何 VM 出問題。

按叢集與時間呈現當機率的點圖,顯示多數當機集中在叢集 2、3 和 6,且叢集 1 在期間尾聲附近出現高峰。

就在那一刻,我們意識到自己在思考上把兩個錯誤混為一談了。因為我們一直混用兩個錯誤的反例,所以找不到單一且一致的解釋。

錯誤 #1:故障主機

有了乾淨的 Kubernetes 節點清單和時間戳記,我們得以將堆疊未對齊的當機追溯到單一實體主機,而這台主機很容易加入拒絕清單。

即使經過數週壓力測試,我們仍無法在受控環境中重現該主機上的暫存器損壞。不過,問題主機一停用,堆疊未對齊的當機就消失了。

移除故障主機並不是永久解法,因為它無法防止相同問題再次發生。但我們可以修改軟體,讓類似問題再度發生時,能輕易偵測並處理。我們改進了致命訊號處理器,使其包含暫存器狀態,如此只靠日誌就能偵測復發(不需要核心傾印)。我們修改了控制平面,讓 VM 通常被重複使用而不是回收,這使得在我們這一層基礎架構堆疊上偵測故障節點容易得多。我們也更新了執行手冊(以及團隊的心智模型),納入這種可能性。

將故障主機造成的當機分離出來後,剩下的 return-to-null 核心傾印就容易理解多了。早先我們曾排除例外展開,因為以為有反例:當機發生在絕對沒有使用例外的程式碼路徑中。但那些反例全都來自硬體損壞群集。

當我們帶著這點重新檢視剩餘核心傾印時,發現先前的結論正好相反:這些當機全都發生在例外展開期間。

例外處理是一種動態控制轉移

當 C++ 拋出例外時,執行階段必須找出應由哪個 catch 區塊接收,以及途中應執行哪些解構函式或清理處理器。編譯器會產生這些中繼資料,但實際決定由哪個 catch 區塊處理例外,是在執行階段動態完成的。

例外展開其實不是由呼叫 throw 的函式執行,而是由產生的編譯程式碼所呼叫的輔助函式執行。這些執行階段常式會檢查堆疊、取得堆疊上函式的中繼資料、動態尋找清理處理器與 catch 區塊,然後將控制轉移到其中一個位置。轉移控制包括展開所有介於其間的堆疊框架(也包括輔助函式的框架)。

從操作上看,這更接近 longjmp 或 fiber 切換,而不是一般的呼叫與返回。被呼叫端儲存暫存器必須還原,堆疊框架暫存器 %rbp%rsp 也必須還原。

我們的二進位檔連結到兩個函式庫,它們都包含執行 C++ 例外展開函式的實作:libgcc 和 GNU libunwind。動態連結器選用的是 GNU libunwind 的定義。這讓我們很意外;我們原本預期因符號版本規則,libgcc 的實作會勝出;然而檢查執行中的二進位檔後,發現事實並非如此。

撤回最後一個假設

此時我們放寬了另一個先前以為只有一個錯誤時做出的假設,因此工作假設也隨之改變。

也許我們看到的不是普通函式返回到 NULL。也許我們看到的是一次展開轉移,實際上類似 setcontext 風格的暫存器還原,只是目的地指令指標在控制轉移前變成了 NULL。換言之,問題來自展開函式庫的不正確資料,而不是堆疊上不正確的返回位址槽。

這讓問題範圍大幅縮小。可能是 GNU libunwind 算出了錯誤的目的地狀態,也可能是它算出的狀態正確,但還沒套用就被其他東西破壞了。

我們閱讀 GNU libunwind 原始碼,發現它會在堆疊上合成一個 ucontext_t,填入清理處理器框架所需的暫存器狀態,然後把該 struct 的指標交給內部組合語言常式:_Ux86_64_setcontext

到這裡,我們已經掌握所有拼圖。

合成的 ucontext_t 位於 _Ux86_64_setcontext 在執行期間會展開的其中一個堆疊框架內。_Ux86_64_setcontext 是否在改變 %rsp 後才讀取該 struct,而此時該 struct 已不再屬於作用中堆疊?這會讓它容易被訊號遞送覆寫,例如我們頻繁使用的 SIGUSR2

錯誤 #2:libunwind 錯誤

答案是肯定的。

以下是我們使用的 GNU libunwind 版本中,_Ux86_64_setcontext 的最後六個指令;它們主要是從記憶體載入到目的地暫存器的 mov 指令:

純文字

1
74: mov UC_MCONTEXT_GREGS_RSP(%rdi),%rsp
2
75:
3
76: /* push the return address on the stack */
4
77: mov UC_MCONTEXT_GREGS_RIP(%rdi),%rcx
5
78: push %rcx
6
79:
7
80: mov UC_MCONTEXT_GREGS_RCX(%rdi),%rcx
8
81: mov UC_MCONTEXT_GREGS_RDI(%rdi),%rdi
9
82: retq

%rdi 指向配置在堆疊上的 ucontext_t,而 UC_MCONTEXT_* 巨集只是展開為特定暫存器儲存位置的固定偏移。)

第一個指令就是競態窗口的開始。它會更新 %rsp,使其指向作用中堆疊的新底部。這一發生,%rdi 指向的 struct 就不再屬於作用中堆疊(或 red zone),也不再是核心不可碰觸的範圍。

通常這不會造成問題,但如果訊號在剛好正確(或該說錯誤?)的時刻抵達,核心會在 %rsp-128 建立訊號框架。這可能會覆寫 %rdi 指向的記憶體。

如果這發生在下一個指令讀取 UC_MCONTEXT_GREGS_RIP(%rdi) 之前,那麼還原後的指令指標就可能遭到破壞。在我們的當機中,它變成了 NULL。

問題就出在這裡。

為何核心傾印看起來像普通的錯誤返回

這段組合語言也解釋了曾讓我們困惑的一個觀察:為什麼函式 X 前一個堆疊框架的返回位址槽中是 NULL。

setcontext 的設計是要還原所有暫存器,包括 %rdi,因此在控制轉移的最後一刻,不能用該暫存器讀取 UC_MCONTEXT_GREGS_RIP(%rdi)。相反地,它會先讀取該值、儲存到堆疊、再還原幾個暫存器,最後使用 retq 讀取已儲存的值並轉移控制。

核心傾印中看似「某個函式返回到 NULL」,其實是「unwinder 在堆疊上合成了一個目標返回位址,但該目標在轉移完成前已遭破壞」。我們原以為返回位址槽的破壞必然是在原地發生,因為我們不知道有任何地方會故意把(可被破壞的)資料寫到返回位址槽。

單一指令寬度的競態窗口

這個錯誤之所以顯得荒謬,是因為競態窗口極其狹窄。在這類競態條件中,外部事件(訊號)必須發生在另一個執行緒採取的兩個步驟之間。這些步驟彼此越接近,競態條件發生的可能性就越低。

在這個案例中,脆弱窗口真的只有一個指令寬!訊號必須在 %rsp 被改變之後、下一個指令載入 %rip 之前被遞送。在現代超純量亂序 CPU 上,每個週期可以執行數個像這樣的簡單指令,因此競態窗口約為一百皮秒。

當我們找到這個競態時,第一個反應是它一定太罕見,不足以解釋觀察到的當機率。我們在整個機群每天看到超過十幾次 return-to-null 當機。例外清理期間的一個單指令競態,真的能造成這麼多當機事件嗎?

我們轉向費米估算。如果脆弱窗口約為 101010^{-10} 秒,而 SIGUSR2 每 10210^{-2} 秒 CPU 時間抵達一次,那麼每個例外清理處理器或 catch 區塊輸掉競態的機率約為 10810^{-8}

Rockset 將例外作為其內部擷取反壓機制的一部分。單一過載主機每秒可能拋出約 10410^{4} 個例外。這意味著使用反壓的主機,平均故障間隔為 10410^{4} 秒,也就是每隔幾小時當機一次。以機群規模來看,這足以解釋觀察到的當機頻率。

為什麼 libunwind 錯誤現在才出現?

GNU libunwind 的錯誤已有 18 年以上歷史,存在於第一個支援 C++ 例外展開的 x86_64 版本中。

那麼,它為什麼現在才浮現?

當機率大致與拋出例外的數量以及遞送訊號的數量成正比。它也取決於訊號處理器消耗多少堆疊。

Rockset 在這三個面向都不尋常。我們將高頻率拋出例外作為正常過載控制的一部分;因為 coarse_thread_cputime_clock,我們異常頻繁地遞送 SIGUSR2;而今年稍早,我們又加入對 timer_getoverrun 的呼叫,讓 SIGUSR2 處理器使用更多堆疊,以便計算合併訊號。

最後這項變更似乎很重要。如果處理器使用的堆疊夠少,可能就不會觸及並覆寫過期的 ucontext_t 記憶體。在那項變更之前,我們完全沒有觀察到這些當機。變更之後,直到我們提高某些使用案例的負載、使反壓機制承受壓力前,發生率仍然很低。

換言之,libunwind 錯誤一直都在,但我們的例外率、訊號率與處理器堆疊使用量三者的乘積,直到最近才跨過讓它在營運上可見的門檻。

這個機制也解釋了一個巧合:硬體錯誤和 libunwind 錯誤的當機大多都發生在 DocumentTree::updateDocument 內。libunwind 造成的當機強烈偏向這個方法,因為在我們拋出例外以施加擷取反壓時,它總是處於作用中。%rsp 未對齊的當機也強烈集中於此,因為有問題的硬體節點屬於我們用於大量擷取的 SKU,而這類工作大多數 CPU 時間都花在該方法中。

我們的立即緩解措施,是從 GNU libunwind 切換到 libgcc 的 unwinder。這本身就是一個好取捨:libgcc 的實作受益於大量降低鎖競爭的工作,這在擴展到大型 VM 時很重要。

我們也向 GNU libunwind 上游提交了一個自含式重現程式和一個修正(在新視窗中開啟),並確認其他 unwinder 沒有類似問題。

族群層級診斷的力量

這段偵錯歷程讓我們學到很多關於動態連結、DWARF 展開中繼資料、Linux 訊號遞送、System V ABI,以及 C++ 例外機制的具體細節。但最主要的教訓比這些都簡單。

最重要的一步,不是巧妙閱讀組合語言,也不是對細節有深厚知識。而是建立高品質資料集。缺少這個資料集時,我們把兩個不同現象混成一個故事,試圖靠推理走出混亂。一旦有了準確且完整的族群資料,問題結構就變得很明顯:一個當機族群屬於故障主機,另一個屬於 libunwind 中的競態。資料變好後,偵錯也變容易了。

對 Rockset 這類基礎架構系統而言,這非常重要。這次調查讓我們更加堅定投入深度檢測、自動化調查,以及持續改善營運工具。可靠性不只是事後修 錯誤,而是建立資料、工作流程與技能,把看似不可能的問題轉化為可診斷、可解決的問題。

作者

By Nathan Bronson、Member of Technical Staff