技術文章

Delphi 中並行 ZIP 解壓縮:HotXLS 讀取閘門

兩條執行緒共用同一個包住 .xlsx 套件的 TFileStream。執行緒 A 把 Position 設到 sheet1.xml 的第一個壓縮位元組,稍後執行緒 B 把它設到 sheet2.xml 的第一個位元組,這時 A 的 Read 讀回來的緩衝區就屬於另一個成員。A 的解壓器此刻正在解別人 deflate 串流的中段;zlib 通常解沒幾個區塊就停在 Z_DATA_ERROR,就算碰巧產出內容,成員的 CRC32 也對不上。問題不在 deflate,競態整個出在串流的位置游標上,只要在「定位加讀取」這組操作外面套一層鎖,就能讓多個工作者同時解壓同一個套件裡的成員

大家想這麼做的理由是記憶體。假設活頁簿在磁碟上佔 80 MB,工作表 XML 解壓後會膨脹五到十倍。先萃取再剖析的開啟路徑,得為這些解壓後的位元組付出代價,而且還疊在正在建構的活頁簿上,高峰出現在第一個儲存格誕生之前。本文談的是套件層級的並行,直接省掉萃取這一步。再往上、配置器造成的天花板,請看並行 XLSX 剖析與記憶體管理員一文;完全不建活頁簿的一次讀取 API 則在串流式直接讀取器指南裡

暫存式設計,以及它在記憶體裡放了什麼

保守的 XLSX 並行開啟做法是一條三階段管線,只有中間那段交給工作者。階段 A 在呼叫執行緒上走訪工作表清單,建立每張工作表、讀取它的關聯 part,然後把整份解壓後的工作表 XML 複製進私有的 TMemoryStream。階段 B 把 XML 剖析分派到執行緒池。階段 C 回到封存檔,一樣在呼叫執行緒上,處理小型衛星 part:註解、討論串註解、繪圖、圖表與表格

這個形狀聽起來無懈可擊:ZIP 封存檔和解壓狀態都不是執行緒安全的,那就把所有碰到封存檔的操作留在單一執行緒,連鎖都省了。代價是八張忙碌工作表的活頁簿會在同一瞬間握住八份完整解壓的工作表緩衝區,而這些緩衝區是整條開啟路徑上最大的暫態物件。以上面假設的 80 MB 套件來說,就是好幾百 MB 的 XML 文字,存在的目的只是讓剖析器讀一次

Delphi 暫存式 XLSX 開啟路徑示意圖:階段 A 先把每份解壓後的工作表 XML 複製進記憶體,階段 B 才並行剖析工作表
階段 A 在單一執行緒上把每份解壓的工作表複製進私有記憶體串流,八張忙碌工作表的活頁簿在高峰時就握住八份大型緩衝區

HotXLS 保留階段 A 和 C,但拿掉複製。階段 B 的每個工作者在共用套件上開自己的項目串流,直接交給 XML 讀取器,解壓後的工作表從不以整塊形式存在。之所以行得通,是因為 "不執行緒安全" 那套論證其實混著兩件不同的事

兩條執行緒能從一個 ZIP 串流解壓縮嗎?

可以,前提是每條執行緒擁有自己的解壓器,共用的位元組來源在讀取這一步被序列化。保守論證把兩種狀態折進同一句話裡。解壓狀態確實不能共用:zlib 的 z_stream 帶著 RFC 1951 定義的 32 KB 滑動視窗、目前的 Huffman 表,以及單一壓縮成員內的位元位置,兩條執行緒把位元組灌進同一個 z_stream 只會產出垃圾。位元組來源則是另一回事。檔案串流值得保護的可變狀態只有一樣:它的位置

ZIP 容器讓這個切分站得住腳。PKWARE 的 APPNOTE(4.3 節)把套件排成一個個獨立成員,各有自己的本機檔案標頭、自己位移上的壓縮資料,CRC32 與大小則記在中央目錄。沒有任何字典跨成員共用,不像 7z 的固實區塊,所以解壓項目 N 不必讀到項目 M 的一個位元組。讓每個工作者在自己的位元組範圍上持有私有解壓器,剩下的碰撞就只有定位。HotXLS 裡這個碰撞由 lxZipArchive.pas 的 TZipReadGate 處理,整個類別一個螢幕就放得下

type
  TZipReadGate = class
  private
    FBaseStream: TStream;
    FLock: TXLSCriticalSection;   // lxPlatform 對重要區段的封裝
  public
    constructor Create(ABaseStream: TStream);
    destructor Destroy; override;
    function ReadAt(AOffset: Int64; var Buffer; Count: Longint): Longint;
  end;

function TZipReadGate.ReadAt(AOffset: Int64; var Buffer;
  Count: Longint): Longint;
begin
  if Count <= 0 then
  begin
    Result := 0;
    Exit;
  end;
  XLSEnterCriticalSection(FLock);
  try
    FBaseStream.Position := AOffset;
    Result := FBaseStream.Read(Buffer, Count);
  finally
    XLSLeaveCriticalSection(FLock);
  end;
end;

讀取閘門鎖什麼,什麼留在閘門外

TZipReadGate.ReadAt 守著一個不可分割的操作:定位共用串流並從中讀取。中央目錄剖析完成後,TZipArchive.OpenArchive 就會建出閘門,檔名與 TStream 兩個多載都一樣,釋放封存檔時一併釋放。為寫入而建立的封存檔不會有閘門,因為沒人會同時從裡面讀

其他一切留在鎖外面,因為它們要嘛本來就是私有的,要嘛已經凍結。TZipArchive.OpenFile 傳回的每個項目串流,在自己的成員內維護自己的位置,直到呼叫 ReadAt 那一刻才換算成套件的絕對位移。包在外面的解壓器按項目建立,視窗大小 -15,zlib 把它解讀成沒有 zlib 標頭的裸 deflate,也絕不交給別條執行緒。中央目錄連同本機標頭在任何工作者啟動前就完整剖析完畢,所以並行開始之後,按名稱查詢項目只是唯讀的雜湊查找。查找不分大小寫,名稱裡帶 .. 段的一律拒絕

HotXLS 在 Delphi 的讀取閘門架構圖:並行的工作表工作者各自解壓私有位元組範圍,僅共用串流的搜尋與讀取由單一重要區段序列化
每個工作者擁有自己的子串流位置與 z_stream,唯一的共享狀態是套件游標;TZipReadGate 為單次定位讀取握住鎖

用自己的串流呼叫 TXLSXWorkbook.Open(Stream) 時,有一個後果要留意。閘門包在這條串流外面,它的鎖協調的是程式庫內部的工作者,對你的程式碼一無所知。若 Open 執行期間,應用程式裡另一條執行緒讀取或重新定位同一個 TStream,第一段那個游標競態就回來了,所以把串流交出去之後,Open 返回前請別再碰它

爭用之下,一把鎖的成本有多高?

比 "對封存檔上一把全域鎖" 聽起來的還低,關鍵在每次取得鎖涵蓋多少工作。解壓器以 64 KB 壓縮位元組為單位補填輸入,所以過一次閘門就餵給工作者 64 KB 的 deflate 資料,之後的解碼與剖析都不必握著任何東西。按前面假設的五到十倍膨脹,這次補填約等於 320 到 640 KB 的工作表標記。鎖本身罩住的只是定位讀取,通常作業系統的檔案快取就直接交貨

有三個地方比例會翻轉,全都跟輸入或機器有關。未壓縮儲存的成員每次讀取呼叫都要過一次閘門,兩次取得之間卻沒有任何解壓工作;多數寫入器會對工作表 part 做 deflate,但碰上把所有東西都直接儲存的工具產出的套件,序列化會更嚴重。慢速或網路儲存上的冷檔案會拉長重要區段,因為裡面的讀取變成真正的傳輸。而工作者一多,閘門反正也不是第一道牆:工作表剖析大量配置記憶體,Delphi 記憶體管理員早就把跨執行緒的配置序列化了,輪不到讀取閘門飽和

最後一點決定了執行緒數。TXLSXWorkbook.ParallelParseThreads 預設 0,每個邏輯處理器起一個工作者,絕不超過工作表數。四到八個邏輯處理器的筆電上,這個預設值夠用。32 執行緒的建置伺服器開啟幾十張工作表的活頁簿時,請設個明確的小上限,例如 4:多出來的工作者大都在配置器上排隊,卻各自握著一份解壓器和緩衝區。想替自己的檔案找到對的數字,就把屬性設成 1、2、4、8,在迴圈裡開啟同一份代表性活頁簿,比較實際耗時與工作管理員或 Process Explorer 裡的工作集高峰

把項目讀到結尾,CRC 檢查才會觸發

封存檔傳回的每個項目串流都會自我檢查,但只在讀到終點時。位元組流過時它累計執行中的 CRC32,位置抵達中央目錄記錄的未壓縮大小就比對兩個值,並多探一個位元組。失敗以帶完整訊息的例外浮現:'ZIP entry uncompressed size mismatch'、'ZIP entry CRC32 mismatch'、'ZIP entry exceeds its declared size',提前用盡的成員則是 'ZIP entry ended before its declared size'

XML 讀取器停在結尾的 </worksheet> 標籤。後面的東西往往只是換行符,維持未讀狀態,位置到不了宣告大小,比對也就永遠不跑。暫存式設計白撿了這項檢查,把項目複製進緩衝區本來就會讀完每個位元組。直接讀項目串流的剖析器沒這個福利,所以每個 HotXLS 工作者在釋放串流前,會用 32 KB 暫存緩衝區把剖析器剩下的部分清空,釋放最外層串流時也一併釋放解壓器和底下的子串流

Delphi 的 HotXLS 工作者串流鏈示意圖:從共用套件經讀取閘門到已驗證串流,並附上讓大小與 CRC32 檢查得以觸發的清空迴圈
清空迴圈把位置推進到宣告的項目大小,讓大小與 CRC32 比對得以執行,攔下 XML 讀取器讀不到的損毀

自己的程式碼用 lxZipArchive 的 TZipArchive 時也適用同一條規則,例如把套件交給活頁簿載入器前先做個完整性體檢。只讀每個 part 開頭幾 KB 嗅出根元素,證明不了什麼,要讀到結尾:

uses
  Classes, SysUtils, lxZipArchive;

// 把套件 part 讀到宣告的結尾,讓大小與 CRC32 檢查得以執行
// PartIsIntact 是你自己寫的輔助函式,不是程式庫的一部分
function PartIsIntact(Zip: TZipArchive; const PartName: WideString;
  out Problem: string): Boolean;
var
  Stream: TStream;
  Buffer: array [0..32767] of Byte;
begin
  Result := False;
  Problem := '';
  Stream := Zip.OpenFile(PartName);   // part 不存在時為 nil
  if Stream = nil then
  begin
    Problem := 'part not found';
    Exit;
  end;
  try
    try
      while Stream.Read(Buffer, SizeOf(Buffer)) > 0 do
        ;
      Result := True;
    except
      on E: Exception do
        Problem := E.Message;          // 例如 'ZIP entry CRC32 mismatch'
    end;
  finally
    Stream.Free;
  end;
end;

var
  Zip: TZipArchive;
  Problem: string;
begin
  Zip := TZipArchive.Create;
  try
    Zip.OpenArchive('quarterly-consolidation.xlsx');
    if not PartIsIntact(Zip, 'xl/worksheets/sheet1.xml', Problem) then
      Writeln('sheet1.xml: ', Problem);
  finally
    Zip.Free;
  end;
end;

那段程式碼的第二個陷阱是倒帶。項目串流只能向前:讀 Size 沒問題,宣告大小早就知道,但偷看之後設 Position := 0 會噴 'ZIP entry stream is forward-only'。元件非要定位不可,就用 OpenFile 把項目再開一次,成本低,而且拿到的是從位移零起步的全新串流。多條執行緒可以對同一個以讀入模式開啟的封存檔各自呼叫 OpenFile,活頁簿載入器幹的就是這件事,只要串流還活著就別釋放封存檔

哪些仍舊序列執行,以及關掉它的開關

階段 A 照跑,只是不再萃取。它在呼叫執行緒上建立每張工作表並讀取關聯,靠這一步,工作者啟動後所有共用的對照表才得以維持唯讀。共用字串表與儲存格格式表在階段 B 開跑前就完備,期間也絕不寫入。階段 C 之後仍按順序走訪每張工作表,收註解、繪圖、圖表與表格

uses
  SysUtils, lxHandleX;

var
  Wb: TXLSXWorkbook;
begin
  Wb := TXLSXWorkbook.Create;
  try
    Wb.ParallelParse := True;        // 預設;False 一次剖析一張工作表
    Wb.ParallelParseThreads := 4;    // 0 = 每個邏輯處理器一個工作者
    Wb.SelectedSheets.Add('Q3 Detail');  // 選用:只剖析這張工作表的儲存格
    if Wb.Open('quarterly-consolidation.xlsx') <> 1 then
      raise Exception.Create('Workbook open failed');
    // 無論哪條路徑剖析,載入的活頁簿都相同
  finally
    Wb.Free;
  end;
end;

Open 之前把 ParallelParse 設成 False,同樣的每表工作會以單一執行緒執行,執行緒池退化成呼叫執行緒上的普通迴圈。只有一張工作表的活頁簿無論如何都走這條路。診斷開啟問題時記得這個開關:一行就能把執行緒因素從盤面上拿掉,而且兩條路徑跑的是同一份剖析程式碼,拿回來的活頁簿不取決於這個設定

錯誤處理也維持序列的形狀。某個工作者拋出例外時,後續工作表不再分派,所有工作者合流,最低工作表索引的例外在呼叫執行緒上重新拋出,所以損毀的 sheet3.xml 依然以單一例外從 Open 浮現。RecoveryMode 設 True 時,剖析失敗的工作表改為跳過,並在活頁簿診斷裡記一筆警告。沒進 SelectedSheets 的工作表根本不會開啟項目串流,MetadataOnly 則整個跳過並行階段。開啟路徑更全面的調校,請看Delphi 大型活頁簿效能指南

本文展示的讀取閘門、並行開啟階段與項目層級的 ZIP 存取,都是HotXLS Delphi Excel 元件的一部分,支援 Delphi 與 C++Builder,附完整原始碼,所以你可以對著產品頁描述的 TXLSXWorkbook 開啟屬性直接讀 lxZipArchive.pas