HotXLS,這套給 Delphi 與 C++Builder 用的原生 Excel 元件庫,能同時從單一開啟的 ZIP 套件解壓縮多個 XLSX 工作表。機制是 TZipReadGate,這是 lxZipArchive.pas 裡一個很小的類別,持有套件串流加上一個臨界區段,只公開一個方法。它把 seek 與 read 這一對操作序列化。這對操作之上的一切都是並行執行的
逼出這個設計的問題,是每個打開過大型活頁簿的 Delphi 開發者都遇過的。一份 80 MB 的 xlsx 是 80 MB 被 deflate 過的 XML,裡面的工作表部分展開後大約是五到十倍。如果你的開啟路徑在剖析每個工作表之前先把它擷取進一個記憶體串流,你付的成本就是在正在建構的活頁簿之上,再加上已展開的位元組,而且峰值會在任何一個儲存格建立之前就先到來。這篇文章談的是移除那道暫存步驟的套件層級並行性。坐在它上方的分配器上限,涵蓋於並行 XLSX 剖析與記憶體管理員一文;讀一次、永不實體化的 API,涵蓋於串流式直接讀取器導覽
為何舊的開啟路徑要把每個工作表都暫存在記憶體裡
HotXLS 原本的並行開啟,是一條三階段管線,只有中間那個階段真正跑在工作執行緒上。階段 A 依序走訪工作表清單,建立每個工作表、讀取它的關聯部分,把整份展開後的工作表 XML 複製進一個私有的 TMemoryStream。階段 B 把 ParseWorksheetXml 分散到執行緒池。階段 C 回到呼叫執行緒,去封存檔取那些小的附屬部分:註解、串連註解、繪圖、圖表、表格。這個形狀是有理由的。lxParallelParse.pas 上的標頭註解過去明白寫著,zip 封存檔與它的解壓縮狀態不是執行緒安全的,內部筆記更進一步:別費心鎖封存檔,因為一旦解壓縮狀態機按項目序列化,鎖什麼也買不到。階段 A 存在的目的,就是把每一次對封存檔的碰觸都留在同一條執行緒上。代價是一份有八個活躍工作表的活頁簿,會同時在記憶體裡持有八份完全展開的工作表 XML 緩衝區,而這些緩衝區正是整條開啟路徑裡最大的暫時性物件
兩條執行緒能從一個 ZIP 串流解壓縮嗎?
可以,而舊的判斷在一個具體、可定位的地方是錯的:它把兩種不同的狀態壓進了一句話裡。解壓縮狀態確實不可共用。一個 zlib 的 z_stream 帶著滑動視窗、霍夫曼表與某個壓縮成員的位元位置,兩條執行緒往同一個推位元組,只會產出垃圾。底層的位元組來源是完全不同的問題,而那裡的答案是,一個檔案串流只有一項值得保護的可變共用狀態:它的位置游標
ZIP 容器讓這個拆分成為合法的。ZIP 封存檔裡的每個成員都是獨立壓縮的:自己的本地檔案標頭、自己在自己 DataOffset 上的 deflate 位元串流、自己在中央目錄裡的 CRC32 與大小值。這裡沒有像實心 7z 區塊那樣橫跨多個成員的共用字典。項目 N 可以在不碰項目 M 的情況下被解壓縮。給每個工作者自己的 z_stream,讓它作用在自己的位元組範圍上,它們唯一會碰撞的就只有那次 seek。TZipReadGate 移除的正是這個碰撞,整個類別短到能在一個畫面裡讀完
type
TZipReadGate = class
private
FBaseStream: TStream;
FLock: TRTLCriticalSection;
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;
EnterCriticalSection(FLock);
try
FBaseStream.Position := AOffset;
Result := FBaseStream.Read(Buffer, Count);
finally
LeaveCriticalSection(FLock);
end;
end;
TZipReadGate 保護什麼,又刻意不保護什麼
TZipReadGate.ReadAt 守護的是一個不可分割的操作:定位共用串流並從中讀取,僅此而已。TZipArchive.OpenArchive 在中央目錄成功剖析之後,在 FInputStream 上建構這個閘門,TZipArchive.Close 則釋放它。為寫入而開啟的封存檔永遠不會有一個。因此工作者對套件執行的每一次讀取,都會匯流過一個臨界區段,持有時間僅限一次緩衝讀取
其餘的一切都留在鎖外面,因為它們早已是私有的,或早已是不可變的。TZipSubStream 維護自己的 FPosition,所以每個工作者在自己的項目裡追蹤自己的位置。TZipEntry.GetStream 建構在那個子串流上的 TZLibStream 是逐項目的,以 windowBits 為 -15 的原始 deflate 建立,從不共用。中央目錄在任何工作者開始之前就已完整剖析完畢,包括每個本地標頭,所以 GetEntryByName 在並行開始時只是一次唯讀的雜湊查詢。這個路由本身在 TZipSubStream.Read 裡只有三行,無閘門的分支正是讓每個既有的單執行緒呼叫端維持在舊程式路徑上的關鍵
function TZipSubStream.Read(var buffer; Count: longint): longint;
var
rest: Int64;
rc: longint;
begin
rest := FSize - FPosition;
if (Count > rest) then
Count := rest;
if FReadGate <> nil then
rc := FReadGate.ReadAt(FOffset + FPosition, buffer, Count)
else
begin
FBaseStream.Position := FOffset + FPosition;
rc := FBaseStream.Read(buffer, Count);
end;
FPosition := FPosition + rc;
Result := rc;
end;
這個閘門在爭用下代價多大?
比「封存檔全域鎖」這句話聽起來的要小,這是因為 TZLibStream 剛好使用的粒度。它的輸入緩衝區是 BufferSize,定義為 $4000,所以 ReadInputBuffer 每次補充會拉進 16 KB 的壓縮位元組,交給 zng_inflate。因此一次鎖定會涵蓋 16 KB 的 deflate 輸入,對工作表 XML 而言展開後大約是 100 KB 等級的標記文字,工作者接著在不持有任何東西的情況下解碼並剖析。這個鎖持有的時間,對應的是一次針對作業系統快取的已定位讀取;它所閘住的工作則是以毫秒計
誠實的邊界是這個比率反轉的地方。用儲存而非 deflate 的項目,會以一比一的方式讀過這個閘門,沒有解壓縮工作可以掩蓋延遲,所以一個裝滿儲存成員的套件會嚴重得多地序列化。冷檔案在慢速媒介上會拓寬那個臨界區段,因為裡面的讀取現在是一次真正的磁碟傳輸,而不是一次快取命中。而過了小貓小狗幾隻工作者之後,這個閘門也不是你會先撞上的東西:工作表剖析是分配密集型的,Delphi 記憶體管理員早在讀取閘門成為瓶頸之前,就會把跨執行緒的分配序列化。這正是為何 TXLSXWorkbook.ParallelParseThreads 預設是一個自動上限,而不是每個核心一條執行緒
工作者本體,以及容易被遺忘的清空迴圈
有了這個閘門,HotXLS 就徹底刪除了階段 A 的暫存動作。工作者現在直接開啟自己的項目串流,直接餵給剖析器。兩個暫時性欄位承載輸入:FParZip 在並行階段期間持有封存檔,FParSheetPartNames 持有部分名稱,兩者都會在 finally 區塊裡清空,讓任何失敗的開啟都不會留下懸空指標。從 TZipArchive.OpenFile 回來的串流,是一個 TZipVerifiedStream 包著一個 TZLibStream 包著一個 TZipSubStream,釋放最外層就會釋放整條鏈
procedure TXLSXWorkbook.ParseSheetJob(AIndex: Integer);
var
Stream: TStream;
DrainBuffer: array [0..32767] of Byte;
PartName: WideString;
begin
PartName := WideString(FParSheetPartNames[AIndex]);
Stream := FParZip.OpenFile(PartName);
if Stream = nil then
Exit;
try
ParseWorksheetXml(Stream, FSheets.ByPos[AIndex], FParSst,
FParRels[AIndex], FParFontMap, FParFillMap, FParBorderMap,
FParNumFmtMap, FParAlignMap, FParProtMap, FParDateMap);
// Consume any trailing bytes so the ZIP entry size and CRC are verified.
while Stream.Read(DrainBuffer, SizeOf(DrainBuffer)) > 0 do
;
finally
Stream.Free;
end;
end;
這個清空迴圈是一個直接照搬舊程式碼的移植會漏掉的細節,而漏掉它會悄悄停用完整性檢查。TZipVerifiedStream 在位元組通過時累計一份持續運算的 CRC32,只有在它的位置抵達中央目錄記錄的未壓縮大小時,才會呼叫 VerifyComplete;大小不符與 CRC32 不符的例外就是從那裡來的,還有一次一位元組的探測讀取,用來抓一個比宣稱長度更長的項目。一個 XML 讀取器會在結束元素處停下,通常會留下一個換行或幾個位元組未讀的尾端空白,所以少了這個清空動作,位置永遠抵達不了宣稱的大小,檢查也永遠不會觸發。把剩餘部分讀進一個暫存緩衝區不花什麼成本,卻能恢復這些檢查。當暫存串流還存在時,XlsxCopyStreamAll 是無意間做了這件事
還有什麼仍是序列執行,以及把它整個關掉的旗標
階段 A 存活下來了,只是少了擷取動作。它仍在呼叫執行緒上建立每個工作表、讀取它的關聯,這正是讓每個共用映射表在工作者開始之前保持不可變的原因。階段 C 之後仍然依序走訪工作表,處理註解、繪圖、圖表與表格,它的守衛從對舊暫存陣列的空值檢查,改成了對照部分名稱的 zip.Exists。工作者會碰到的共用唯讀輸入,也就是共用字串表與 cellXf 映射表,在階段 B 開始之前就已完備,並且在整個過程中從未被寫入
var
Wb: TXLSXWorkbook;
begin
Wb := TXLSXWorkbook.Create;
try
Wb.ParallelParse := True; // default; False forces one sheet at a time
Wb.ParallelParseThreads := 4; // 0 selects the automatic cap
Wb.Open('quarterly-consolidation.xlsx');
// ... workbook is identical either way ...
finally
Wb.Free;
end;
end;
在 Open 之前把 ParallelParse 設為 False,會用執行緒數 1 分派同一個工作程序,RunParallelJobs 會退化成呼叫執行緒上的一個單純迴圈。這值得知道有兩個原因:這是若現場出現任何執行緒相關疑慮時的一行式答案;也代表序列與並行路徑共用同一套剖析程式碼主體,而不是各走各的。工作者例外會被捕捉,索引最小的工作獲勝,錯誤會在每個工作者都會合之後,在呼叫執行緒上重新丟出,所以一個損毀的工作表依然會如預期般,在正確的地方呈現成一個例外。周邊開啟路徑的一般調校,涵蓋於Delphi 大型活頁簿效能指南
這裡描述的讀取閘門、並行開啟階段與串流式項目存取,隨標準版 HotXLS Excel 元件一併出貨,附完整原始碼;產品頁面收錄完整的 TXLSXWorkbook 參考,包括並行開啟屬性