當 HotXLS 在一次呼叫中為大型工作表 XML 部分計算校驗和時,Delphi 工作執行緒可能在沒有可捕捉異常的情況下當機:當輸入資料超過約 119 KB 時,zlib-ng 會切換到 Chorba 算法,而該算法的 generic-C 變體會在堆疊上分配足以耗盡預設 1 MB 執行緒堆疊的临時陣列。Delphi 沒有机會作出響應,因為堆疊溢位並不是 try/except 所設計用來捕捉的異常類型
HotXLS 是一個用於讀寫 Excel 活頁簿的原生 Delphi 和 C++Builder 庫,這次當機最终追溯到了它的工作表寫入器。最初的線索來自一张支援工單:夜間匯出作業每周大約當機兩次,總是在執行中途發生,沒有 Delphi 異常對話框,也沒有記錄錯誤,只有一個突然消失的進程,以及一條沒有提供有用線索的 Windows 錯誤報告。後來要在開發人员桌面上複現却完全是另一回事。小型活頁簿儲存正常,大型活頁簿也能正常儲存,只要儲存操作在主執行緒上執行且除錯器已經附加。直到讓一批接近生產規模的檔案走過真實的多執行緒匯出路徑,才终於稳定複現當機;在那之前,磁盤 I/O、內存壓力和可疑模板都已經分別排刪除
工作表儲存如何變成一次巨大的 CRC32 呼叫
XLSX 檔案是 ZIP 容器,而 ZIP 格式要求為每個條目計算 CRC-32 校驗和,並同時記錄在本地檔案頭和中央目錄中。HotXLS 透過一個名為 ZLibCRC32 的小型包装器計算該校驗和:SaveAs 在內存中完成工作表 XML 的組装後,包装器會呼叫 zlib-ng 自己的 crc32 例程。很長一段時間里,這個呼叫會在一次呼叫中傳入完整的未壓縮緩衝區。對於小型工作表,這是合理的設計;但對於我们的 HotXLS 大型活頁簿性能指南所讨论的那類工作表,單個工作表的 XML 在壓縮之前就經常超過几百 KB,此時它會變成一次非常大的呼叫
zlib-ng 為什麼需要巨大的 CRC32 堆疊緩衝區
zlib-ng 並不會對每次呼叫都使用同一個 CRC-32 實作。在低於尺寸阈值時,它透過查表和折叠技巧遍歷緩衝區,几乎不需要额外內存;超過該阈值時,也就是約 119 KB,具體來說是 HotXLS 所連結建立中的 118,960 位元組,它會切換到一種名為 Chorba 的專用快速算法。該路徑的 generic-C 實作以空間換速度:它不是在堆上分配,而是在堆疊上分配临時陣列,大小是為了讓算法的內層循環執行得更快,並沒有考虑呼叫執行緒實際拥有的堆疊預算是否足夠。呼叫方無法從外部看到這些細節。校驗和函式通常是叶子呼叫,讀取一些位元組後回傳一個數字,不會進行值得讨论的分配;绝大多數 zlib-ng 呼叫都符合這種假設,直到一個足夠大的緩衝區跨過 Chorba 阈值
function BuildWorksheetPartCrc(const XmlBytes: TBytes): LongWord;
begin
// One call over the whole worksheet XML buffer: fine for a small
// sheet, but a large enough input pushes zlib-ng onto its Chorba
// fast path and that path's stack-hungry scratch buffer
Result := ZLibCRC32(0, XmlBytes[0], Length(XmlBytes));
end;
為什麼工作執行緒會觸發,而互動式除錯却不會
要觸發這次當機,必須同時滿足兩個條件:工作表 XML 部分足夠大,跨過 zlib-ng 的 Chorba 阈值;執行緒只有普通的預設堆疊,而不是更寬裕的堆疊。生產匯出作業恰好同時滿足這兩個條件。它们作為將 HotXLS 寫入操作分摊到工作執行緒池中的服務端批處理作業執行,每個執行緒都使用 Windows 在呼叫方未要求更大堆疊時預留的預設 1 MB 堆疊,並且處理足夠大的客户活頁簿。桌面除錯很難可靠滿足任一條件:示例檔案通常小於阈值,而單步執行往往發生在主執行緒上,而不是新創建立的工作執行緒中,因此生產環境中必須同時出現的兩個條件几乎不會在開發人员桌面上同時出現
追查一場把錯誤歸咎於錯誤函式的當機
团隊能夠拿到的當機報告指向 zlib-ng 的 deflate 函式內部,既沒有指向任何 HotXLS 代碼,也沒有明顯指向 CRC-32 代碼。這個細節讓調查的第一轮轉向壓縮路徑:傳給 deflate 的緩衝區大小、視窗口位數、壓縮等級,這些都是原生编解碼器發生當機時最常見的嫌疑對象。但它们都經不起驗證
誤導性的頂部堆疊框架
堆疊溢位是一種很難進行符號化的當機,因為報告生成時,堆疊指针已經越過為它預留的空間。生成該當機報告的元件很可能只能把故障地址解析到仍能找到的最近符號,而恰好位於真正故障點旁邊的最近匯出入口是 deflate。真正的故障發生在 CRC-32 路徑中 Chorba 的临時緩衝區分配處,該代碼被编譯進同一個庫,二進製位置又足夠接近,因此被誤認為是實際正在執行的函式
不用除錯器,而用時間戳進行二分定位
一次拖垮整個進程的當機不會留下任何東西供普通 Delphi 除錯器會話捕捉,因此团隊改用 GetTickCount,在每個可疑呼叫前後寫入檢查點,並沿儲存路徑手動二分,逐步縮小進程死亡瞬間正在執行的操作範圍。同時,一個已知正常的基線建立與目前建立並行處理同一批生產檔案,專門用於排刪除目前這一轮自身改動引入回歸的可能。只有兩項檢查都回傳正常後,調查才確定問題來自第三方依賴對完全有效的輸入執行了出乎意料的操作
為什麼 try/except 無法捕捉堆疊溢位
堆疊溢位不是 Delphi 代碼會主動引發的異常,它的傳遞方式也不同於 Windows 傳遞訪問衝突或刪除零錯誤的方式。它表現為硬件保護頁故障,透過 Delphi try/except 所依賴的同一套結構化異常處理机製報告;但故障觸發的準確時刻通常已經沒有足夠的堆疊空間來執行處理程序、展開清理代碼,甚至無法完整地報告故障。在一個只帶預設 1 MB 保留堆疊的工作執行緒上,如果临時緩衝區已經消耗了大部分剩余空間,執行時就沒有可用空間了
procedure TExportWorker.Execute;
var
Workbook: TXLSXWorkbook;
begin
Workbook := TXLSXWorkbook.Create;
try
try
BuildWorksheet(Workbook);
Workbook.SaveAs(FTargetFile); // crashes the process here on a
// large enough sheet: try/except
// never gets a chance to run
except
on E: Exception do
LogError('Export failed: ' + E.Message);
end;
finally
Workbook.Free;
end;
end;
這個 except 代碼區塊看起來像一张安全網,對大多數故障確實如此,但在這里不起作用。团隊在實践中確認了這一點:try/except 什麼也捕捉不到,finally 代碼區塊也沒有可靠的机會執行,操作人员看到的是一個已經結束的進程,應用層完全沒有日志記錄,這與最初支援工單描述的情況完全一致
修正方式:將 CRC32 分成 64 KB 分片,而不是一次性巨型呼叫
HotXLS 發佈的修正既沒有改動 zlib-ng 本身,也沒有改動寫入活頁簿時使用的壓縮等級。ZLibCRC32 現在會將輸入分成固定的 64 KB 分片,每片 65536 位元組,每個分片呼叫一次 zlib-ng 的 crc32,並將正在計算的校驗和值從一次呼叫傳遞到下一次呼叫。CRC-32 天然就是增量算法,因此分多個分片累积得到的校驗和,與對相同位元組進行一次完整呼叫得到的結果逐位相同:修正改變的是任務拆分方式,而不是計算結果
function ZLibCRC32(crc: LongWord; const buffer; count: Longint): LongWord;
const
// 64 KB keeps every call comfortably under the Chorba threshold
CrcChunkSize = 65536;
var
Cursor: PByte;
ThisChunk: Longint;
begin
Result := crc;
Cursor := PByte(@buffer);
while count > 0 do
begin
ThisChunk := count;
if ThisChunk > CrcChunkSize then
ThisChunk := CrcChunkSize;
Result := zng_crc32(Result, Cursor, Cardinal(ThisChunk));
Inc(Cursor, ThisChunk);
Dec(count, ThisChunk);
end;
end;
外圍的 SaveAs 呼叫無需為此改變,HotXLS 寫入的 ZIP 條目也沒有任何變化:最终寫入本地檔案頭和中央目錄的 CRC-32 值,與一次巨型呼叫本應產生的值完全相同,只是透過更小的片段逐步組装。降級 zlib-ng,或改用更慢但分配內存更少的 CRC-32 實作,也能避開當機,但會讓原本根本沒有接近阈值的每個檔案都付出實際代价,因此這兩種方案都沒有發佈
如果你在自己的工作執行緒中呼叫 zlib-ng,這意味著什麼
這里描述的堆疊溢位故障模式並不專屬於試算表。任何應用只要在執行緒僅拥有平台預設堆疊的情況下,把大型緩衝區交給 zlib-ng 進行壓縮、解壓縮或校驗和計算,就可能遇到同样的限制,因為庫會根據輸入大小選擇算法,而其中一些算法預設呼叫堆疊還有充足空間。有兩種防護方式無需改動 zlib-ng:對於天然支援增量處理、且會受輸入大小影響的例程,將大型緩衝區固定分區塊傳入,可以直接消刪除觸發條件;如果無法分區塊,则讓呼叫執行緒拥有大於平台預設值的堆疊是另一種手段。無论選擇哪一種,都比從一份把錯誤歸咎於錯誤函式的生產當機報告中得知未公開的尺寸阈值更省代价
直到一個足夠大的生產活頁簿在錯誤類型的執行緒上跨過該阈值,這個具體阈值才暴露出來,這正是只有代碼面對真實檔案而不是小型測試样例時才會出現的故障類型。現在,分區塊 CRC-32 路徑已經作為標準寫入流程的一部分,隨面向 Delphi 和 C++Builder 的HotXLS Excel Component一同提供,呼叫方無需配置,也沒有用於啟用或禁用它的屬性