Exit code: 0 Wall time: 1.1 seconds Output: Exit code: 0 Wall time: 0.8 seconds Output: Exit code: 0 Wall time: 1.2 seconds Output: HotXLS、zlib-ng CRC32 與 Delphi 執行緒堆疊溢位

技術文章

HotXLS、zlib-ng CRC32 與 Delphi 執行緒堆疊溢位

當 HotXLS 在一次呼叫中為大型工作表 XML 部件計算檢查碼時,可能讓 Delphi 工作執行緒直接當機,而且沒有任何可捕捉的例外:輸入一旦超過約 119 KB,zlib-ng 就會切換到它的 Chorba 演算法,而該演算法的通用 C 版本會配置一塊大到足以衝破預設 1 MB 執行緒堆疊的暫存陣列。Delphi 完全來不及反應,因為堆疊溢位不是 try/except 當初設計來捕捉的那種例外

HotXLS 是用於讀寫 Excel 活頁簿的原生 Delphi 與 C++Builder 程式庫,而這次當機最後追溯到它的工作表寫入器。問題的第一個訊號是一張支援單:某個每晚執行的匯出作業大約每週當機兩次,總是在執行途中,沒有 Delphi 例外對話方塊,也沒有記錄任何錯誤,只剩一個憑空消失的處理程序,以及一筆指向不了任何有用資訊的 Windows 錯誤報告項目。要在桌上重現它完全是另一回事。小型活頁簿儲存正常。大型活頁簿也儲存正常——只要儲存在主執行緒上執行,而且早已附掛除錯器。直到把一批真正生產規模的檔案送上實際的多執行緒匯出路徑,當機才被帶到眼前,而此時磁碟 I/O、記憶體壓力和一個可疑範本早已一一被排除

工作表儲存如何變成一次巨大的 CRC32 呼叫

XLSX 檔案是 ZIP 容器,而 ZIP 格式要求為每個項目計算 CRC-32 檢查碼,並同時記錄在本機檔頭與中央目錄中。HotXLS 在 SaveAs 於記憶體中組好工作表的 XML 之後,透過一個名為 ZLibCRC32 的小包裝函式計算這個檢查碼,再由它呼叫 zlib-ng 自家的 crc32 常式,而且在很長一段時間裡,這個呼叫都一次把整個未壓縮的緩衝區送進去。對小型工作表而言,這是合理的設計。可是一旦工作表屬於我們的 HotXLS 大型活頁簿效能指南所涵蓋的那種——單張工作表的 XML 在壓縮前就經常超過數百 KB——它就成了一次非常巨大的呼叫

為什麼 zlib-ng 的 CRC32 會需要巨大的堆疊緩衝區?

zlib-ng 並不是對每次呼叫都用同一種 CRC-32 實作。低於某個大小閾值時,它以查表和摺疊技巧走訪緩衝區,幾乎不需要額外記憶體;高於該閾值——約 119 KB,在 HotXLS 連結的組建中精確說是 118,960 位元組——它就切換到一個名為 Chorba 的專用快速演算法。這條路徑的通用 C 實作用記憶體換速度:它在堆疊而非堆積上配置一塊暫存陣列,大小是為了讓演算法的內層迴圈跑得快,而不是為了剛好塞進呼叫執行緒碰巧攜帶的堆疊額度。這一切從呼叫端完全看不見。檢查碼函式通常只是末端呼叫:讀幾個位元組、傳回一個數字,沒有值得討論的記憶體配置,而這個假設對絕大多數進入 zlib-ng 的呼叫都成立——直到某個大到跨過 Chorba 閾值的緩衝區走了進來

HotXLS Delphi 工作表儲存圖解:單次大型 CRC32 呼叫越過 zlib-ng 約 119 KB 的 Chorba 門檻,快速路徑預留的暫存陣列溢出 1 MB 工作執行緒堆疊
對整個工作表緩衝區的一次呼叫就越過 zlib-ng 的 Chorba 門檻,該快速路徑保留的堆疊暫存陣列於是溢出 1 MB 的工作執行緒堆疊
function BuildWorksheetPartCrc(const XmlBytes: TBytes): LongWord;
begin
  // 對整個工作表 XML 緩衝區做單次呼叫:對小工作表沒問題,
  // 但夠大的輸入會把 zlib-ng 推上它的 Chorba
  // 快速路徑,而該路徑的暫存緩衝區會吃掉大量堆疊
  Result := ZLibCRC32(0, XmlBytes[0], Length(XmlBytes));
end;

為什麼工作執行緒會撞上它,互動式除錯卻從來不會

要觸發這次當機,需要兩個條件同時成立:一個大到跨過 zlib-ng Chorba 閾值的工作表 XML 部件,以及一條只有普通預設堆疊、而非更寬裕堆疊的執行緒。生產環境的匯出作業兩者全中。它們以把 HotXLS 寫入分散到工作執行緒集區的伺服器端批次作業的形式執行,每條執行緒都帶著 Windows 在呼叫端未要求更多時所保留的預設 1 MB 堆疊,而且每一條都在處理大到不容忽視的客戶活頁簿。桌上除錯則兩個條件都不穩定成立:樣本檔案通常小於閾值,單步執行又往往發生在主執行緒而非剛誕生的工作執行緒裡,於是生產環境中必須同時到位的兩個條件,在開發人員的桌上幾乎從不同時到位

矩陣顯示:HotXLS Delphi 堆疊溢位需要工作表 XML 組件達 119 KB 以上與預設 1 MB 工作執行緒堆疊同時成立——因此正式批次執行會當掉,開發機除錯卻從未發生
當機需要 119 KB 以上的工作表部件與預設堆疊的工作執行緒同時湊齊,桌面除錯因此一直撲空

追查一場怪錯函式的當機

團隊到手的當機報告都指向 zlib-ng 的 deflate 函式內部,不指向任何 HotXLS 程式碼,也看不出與 CRC-32 程式碼有關。單是這個細節,就把調查的第一輪帶向了壓縮路徑:傳給 deflate 的緩衝區大小、window bits、壓縮等級——這些都是編解碼器吐出原生當機時的常見嫌疑犯。結果沒有一個成立

誤導人的最上層堆疊框架

堆疊溢位是一種很難做符號化的當機,因為等到它被回報出來時,堆疊指標早已越過為它保留的空間。產生當機報告的元件,多半只能把出錯位址解析到它還找得到的最近符號,而坐在真正元凶旁邊最近的匯出進入點恰好是 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);  // 工作表夠大時,處理程序會在這裡
                                      // 當機:try/except 完全
                                      // 沒有機會執行
    except
      on E: Exception do
        LogError('Export failed: ' + E.Message);
    end;
  finally
    Workbook.Free;
  end;
end;

那個 except 區塊看起來像一張安全網,對大多數故障而言它確實是,但在這裡它什麼也做不了。團隊在實務中也證實了這一點:try/except 什麼都沒攔到,finally 區塊也從未獲得可靠執行的機會,操作員看到的是一個死掉的處理程序,應用程式層級的日誌裡一筆記錄都沒有——正如原始支援單所描述的那樣

修正方式:以 64 KB 切片餵給 CRC32,不再單次巨型呼叫

HotXLS 所發佈的修正,既沒有改動 zlib-ng 本身,也沒有改動寫入活頁簿所用的壓縮等級。ZLibCRC32 現在以固定的 64 KB 切片走訪輸入,每片 65,536 位元組,每片呼叫一次 zlib-ng 的 crc32,並把累積中的檢查碼值從一次呼叫帶進下一次。CRC-32 在結構上本來就是可增量的演算法,因此分多片累積出來的檢查碼,與對同樣位元組做單次呼叫所算出的結果逐位元相同:這個修正改變的是工作如何切分,而不是它算出什麼

HotXLS 修復圖解:在工作執行緒上以 64 KB 切片把工作表緩衝區餵給 zlib-ng CRC32,累計校驗和逐呼叫傳遞,讓每次呼叫都低於 Chorba 門檻
把緩衝區切成 64 KB 一段呼叫,zlib-ng 就不上 Chorba 路徑,CRC-32 值仍然分毫不差
function ZLibCRC32(crc: LongWord; const buffer; count: Longint): LongWord;
const
  // 64 KB 讓每次呼叫都穩穩低於 Chorba 閾值
  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 Delphi Excel Component 的標準寫入管線一同出貨,呼叫端不需要設定任何東西,也沒有負責開關的屬性