PDFium 在模組層級不是 thread-safe,所以兩條執行緒各用一個 TPdf 處理兩個不同的檔案,照樣能把彼此弄壞。Delphi 版 PDFium Component 用兩種手法應對:v3.125.1 起,ValidatePdfFilesParallel 把每一個原生 PDFium 呼叫都排進同一把行程層級的鎖後面序列化執行,而 TPdf.RenderPagesParallel 給每個 worker 各一份隔離的 PDFium 模組複本。逼出這次修正的 bug 是間歇性問題裡最惡劣的那種:批次驗證測試大部分時候通過,偶爾把兩個好檔案之一回報成失敗,偶爾用 access violation 把同一個行程裡的下一個測試炸掉,有時乾脆留下一個 exit code 帶走整個 test runner,連堆疊都沒有。測試本身沒錯,任何單一文件也沒錯,錯的是假設:每條執行緒一個 TPdf,不是隔離
為什麼每條執行緒一個 TPdf 還不夠?
每條執行緒一個 TPdf 不夠用,是因為 PDFium 把不安全的狀態放在模組裡,不是文件裡。每個 TPdf 擁有自己的 FPDF_DOCUMENT handle,但行程裡所有 handle 都由同一份載入的 DLL 服務,而那個 DLL 握著整個行程共用的 singleton:字型快取、頁面模組,以及其他文件載入、解析、渲染都會碰的全域結構。兩條執行緒載入兩個毫不相干的檔案,就是兩條執行緒同時往同一個字型快取裡寫。這份資料在 Delphi 端沒有任何人擁有,所以 Delphi 端也沒有任何東西能按文件把它鎖起來
元件確實有鎖,而且很容易從它推出錯誤結論。TPdf 把自己的渲染路徑包在內部 critical section 裡(EnterRenderLock / LeaveRenderLock,TPdf 的私用方法)。那把鎖是 per instance 的:它能擋住兩條執行緒同時驅動同一個 TPdf——這是真實的風險——但它看不見另一條執行緒上的第二個實例,跨實例的並行就這樣大搖大擺走過去。通用規則簡單到一行講得完:在同一份載入的 PDFium 模組裡,任何一刻最多只能有一條執行緒身在 PDFium 內部,開了多少份文件都一樣
跨文件的損壞在 Delphi 行程裡長什麼樣子?
跨文件的損壞看起來像一堆不相干故障的隨機混合,而且傷害比肇因的程式碼活得更久。v3.125.1 之前,ValidatePdfFilesParallel 給每條 worker 執行緒建一個 TPdf,然後在共用模組上並行跑 Active := True 和 preflight report 的建構。Delphi 與 Free Pascal 建置上看到的症狀涵蓋了整個光譜:
- 明明有效的檔案載入失敗,或批次跑完被回報成失敗,其實它該通過
- access violation 在之後某個不相干的呼叫裡浮現,往往出現在不同的測試或不同的文件上
- Delphi 裡出現
External exception C000001D。這個代碼是STATUS_ILLEGAL_INSTRUCTION,由 PDFium 內部CHECK與IMMEDIATE_CRASH巨集在不變量破功時執行的ud2指令觸發 - 行程以
0xC0000409(fail-fast,被回報為堆疊緩衝區滿溢)或0xC0000374(堆積損壞)退出,Delphi 例外半個都沒有
最後兩點就是這個 bug 難以定罪的原因。平行驗證跑完了,被弄壞的全域狀態留在現場,同一個行程裡的下一個 fixture 就絆了上去。在一次 Delphi Win64 regression run 裡,一波 C000001D 失敗砸在從沒碰過批次驗證的測試上——它們只是損害發生後第一個用到 PDFium 的程式碼。實測數字把規模說得很明白:同一份樣本跑兩個 worker 的 Delphi 探測程式,一輪 160 份文件掛了 122 份,另一輪掛了 138 份,其中一輪還直接噴出 External exception C000001D。8 份文件、4 個 worker、5 輪的壓力案例,在 Free Pascal Win64 上 5 跑 5 掛。修正之後,同一個探測程式 1,200 份文件掛了 0 份
v3.125.1 起 ValidatePdfFilesParallel 怎麼保持安全
ValidatePdfFilesParallel 現在把每個工作的原生那一半序列化,受管的那一半照舊平行。每個 worker 在建立 TPdf 之前先搶下一把單元層級的 critical section,然後從 FileName、Active := True、preflight report 建構一路握到 Free。建立與銷毀特意留在鎖內:關文件跟載入一樣,都會回呼進模組。worker 拿到擷取好的 TPdfPreflightReport 記錄之後就釋放鎖,對著那份記錄評估驗證規則——這一步不碰任何 PDFium 狀態,所以上一份檔案的規則評估可以與下一份的 PDFium 工作重疊進行
這次修正還附帶兩個小改動。載入失敗現在會擲出帶著 LastLoadReport.ErrorMessage 的 EPdfError,該項目的 ErrorMessage 於是點出真正的解析問題,而不是次要的「no active document」錯誤。代價也講清楚:批次裡的 PDFium 部分現在是序列的,若一批文件的大頭是解析與 preflight,多加 worker 買不到多少東西。如果您還在 v3.125.1 之前的版本,把 WorkerCount 設成 1,並行沒了,損壞也跟著沒了
uses
System.SysUtils, PDFium, FPdfPreflightReport;
procedure ValidateBatch(const Files: array of string);
var
Registry: TPdfValidationRuleRegistry;
Options: TPdfBatchValidationOptions;
Report: TPdfBatchValidationReport;
I: Integer;
begin
Registry := CreateDefaultPdfValidationRuleRegistry;
try
Options := TPdfBatchValidationOptions.Default;
Options.WorkerCount := 4; // 0 = 用處理器數量,上限 8
Options.Standards := [ppsPdfA];
// 帶明確 registry 時,請自行挑選相符的 profile。
// Profiles 給空清單就跑遍所有已註冊規則,而沒做 preflight
// report 的標準,其規則一律回報「未通過」
SetLength(Options.ValidationOptions.Profiles, 1);
Options.ValidationOptions.Profiles[0] := 'PDF/A';
Report := ValidatePdfFilesParallel(Files, Registry, Options);
finally
Registry.Free;
end;
for I := 0 to High(Report.Results) do
case Report.Results[I].Status of
pbvisPass: Writeln('PASS ', Report.Results[I].FileName);
pbvisFail: Writeln('FAIL ', Report.Results[I].FileName);
pbvisError: Writeln('ERROR ', Report.Results[I].FileName, ': ',
Report.Results[I].ErrorMessage);
else
Writeln('SKIP ', Report.Results[I].FileName); // pbvisCancelled
end;
Writeln(Report.PassedDocumentCount, ' passed, ',
Report.FailedDocumentCount, ' failed, ',
Report.ErrorDocumentCount, ' errors');
end;
registry 直接傳 nil 是比較省事的路:ValidatePdfFilesParallel 會自己建立預設 registry、從 Options.Standards 推導 profile 清單,返回時順手釋放。結果永遠按輸入順序回來,worker 誰先跑完都不影響。報告格式與包著同一套引擎的命令列工具,見用 PDFium Component CLI 產生批次 PDF preflight 報告;PDF/A 檢查本身涵蓋什麼,見Delphi 裡的 PDF/A preflight 驗證
RenderPagesParallel 怎麼真正做到平行渲染頁面?
TPdf.RenderPagesParallel 能平行,是因為它的 worker 從不共用 PDFium 模組。方法先在呼叫執行緒上把作用中的文件存進來源儲存區;接著每個 worker 把載入的 PDFium DLL 複製成 temp 目錄裡一個名字獨一無二的檔案,用 LoadLibrary 載入那份複本並初始化。Windows 把從不同路徑載入的 DLL 當成不同模組,所以每份複本都有自己的全域:自己的字型快取、自己的頁面模組、自己的一切。worker 在自己的私用模組裡打開存好的文件,逐步渲染頁面、步與步之間檢查取消狀態,最後銷毀程式庫、卸載複本、刪掉檔案
隔離不是白來的,預設值也反映了這一點。每個 worker 的成本是磁碟上一份 DLL 複本、記憶體裡第二套 PDFium 全域,以及文件的一次全新解析。MaxWorkers = 0 意味著最多 4 個 worker;MaxPixelsPerPage 與 MaxTotalOutputBytes 給原始輸出設上限;反相與夜間雙色調兩種渲染選項則直接拒收,因為緩衝區是原樣回傳的。結果是一份 TPdfParallelRenderReport,其 Results 陣列按請求順序為每個請求的頁面保存一塊 top-down 32 位元緩衝區
procedure RenderAllPages(Pdf: TPdf);
var
Options: TPdfParallelRenderOptions;
Report: TPdfParallelRenderReport;
Pages: array of Integer;
I: Integer;
begin
SetLength(Pages, Pdf.PageCount);
for I := 0 to High(Pages) do
Pages[I] := I + 1; // 頁碼從 1 起算
Options := TPdfParallelRenderOptions.Default;
Options.Dpi := 150;
Options.MaxWorkers := 4;
// 來源快照是在共用模組上拍的,若其他執行緒也在用 TPdf,
// 請一併持有整個行程層級的 PDFium 鎖
PdfiumLock.Acquire;
try
Report := Pdf.RenderPagesParallel(Pages, Options);
finally
PdfiumLock.Release;
end;
for I := 0 to High(Report.Results) do
if Report.Results[I].Status = pprsSucceeded then
SavePageBuffer(Report.Results[I]) // Width, Height, Stride, PixelFormat, Pixels
else
Writeln('Page ', Report.Results[I].PageNumber, ': ',
Report.Results[I].ErrorMessage);
end;
注意包住呼叫的那把鎖。worker 的模組是私用的,但開頭的快照步驟是由呼叫執行緒在共用模組上跑 SaveAs。行程裡若沒有別的東西會同時碰 TPdf,鎖可以省;只要有,快照就需要與其他所有共用模組呼叫相同的保護
| 模式 | 跨文件是否安全 | PDFium 工作是否平行 | 代價 |
|---|---|---|---|
每條執行緒一個 TPdf,無共用鎖 | 否 | 是,直到把東西弄壞 | 間歇性當機、行程狀態受損 |
| 所有 PDFium 呼叫共用一把行程層級的鎖 | 是 | 否 | PDFium 部分為序列 |
ValidatePdfFilesParallel(v3.125.1 起) | 是 | 否;規則評估為平行 | 解析與 preflight 為序列 |
TPdf.RenderPagesParallel | 是 | 是 | 每個 worker 一份 DLL 複本、一份記憶體與一次全新解析 |
自己的多執行緒 PDFium 程式碼該怎麼組織?
自己的執行緒應該共用一把行程層級的鎖,並且在其使用的每個 TPdf 完整生命週期內握著它,否則就用替您隔離模組的元件 API。這把鎖必須是整個行程唯一的物件,不能每執行緒一把、每表單一把、每文件一把——兩條執行緒沒共用的鎖,什麼也保護不了。下面的模式與元件自 v3.125.1 起內部的做法同款:建立、載入、讀取、釋放都在鎖內,不碰 PDFium 的事全部挪到鎖外
uses
System.Classes, System.SysUtils, System.SyncObjs, PDFium;
var
PdfiumLock: TCriticalSection; // 整個行程一把鎖
type
TTextExtractThread = class(TThread)
private
FFileName: string;
FText: string;
protected
procedure Execute; override;
public
constructor Create(const AFileName: string);
property ExtractedText: string read FText;
end;
constructor TTextExtractThread.Create(const AFileName: string);
begin
inherited Create(True);
FFileName := AFileName;
end;
procedure TTextExtractThread.Execute;
var
Pdf: TPdf;
Page: Integer;
Raw: TStringBuilder;
begin
Raw := TStringBuilder.Create;
try
PdfiumLock.Acquire;
try
Pdf := TPdf.Create(nil);
try
Pdf.FileName := FFileName;
Pdf.Active := True;
if not Pdf.Active then
raise EPdfError.Create(Pdf.LastLoadReport.ErrorMessage);
for Page := 1 to Pdf.PageCount do
begin
Pdf.PageNumber := Page;
Raw.AppendLine(Pdf.Text);
end;
finally
Pdf.Free; // 關文件同樣是 PDFium 的活
end;
finally
PdfiumLock.Release;
end;
// 這行以下不再碰 PDFium,所以這段可以平行跑
FText := Raw.ToString.Trim;
finally
Raw.Free;
end;
end;
initialization
PdfiumLock := TCriticalSection.Create;
finalization
PdfiumLock.Free;
幾條規則讓這個模式在真實應用裡不走樣:
- 把
TPdf.Create與Free放進鎖內,別只顧那些一眼看上去危險的呼叫。載入、關閉、PageCount這類屬性讀取、換頁、抽文字、渲染、存檔,全都伸進模組裡 - 指派
Active之後檢查它。載入失敗會讓Active停在False,LastLoadReport.ErrorMessage會說明原因 - 鎖按文件握,而不是按呼叫握。更細的鎖原理上可行,但前提是沒有任何
TPdf成員跑到鎖外,而元件自己倚仗的正是粗粒度版本 - 資料庫寫入、建索引、網路呼叫這類與 PDFium 無關的慢活留在鎖外,否則一個慢客戶就能把一切序列化
- 別把每實例私用的渲染鎖當替代品。它只防得了一個
TPdf自己,僅此而已
同樣的謹慎也適用於不是以裸執行緒寫的程式碼。背景 future 是讓長渲染離開 UI 執行緒的好辦法,用可取消的 future 做背景 PDF 渲染一文有詳細說明,但 future 執行器並不會自備一把全域 PDFium 鎖。如果多個 future 可能同時驅動不同的 TPdf 實例,請在每個 worker 內部拿同一把行程層級的鎖,並把主執行緒上的檢視器當成共用模組的又一位客戶。透過非同步 API 的跨實例使用沒有另外稽核過,保守起見,假設它需要與手寫執行緒相同的序列化。當您需要的 PDFium 平行化不在頁面渲染這一類,分離的 worker 行程讓每個工作天生就有自己的模組
速查:Delphi 裡的 PDFium 執行緒規則
- PDFium 的不安全狀態是模組層級的:字型快取、頁面模組與其他全域結構由行程裡每份文件共用
- 每條執行緒一個
TPdf什麼也隔離不了;兩條執行緒上的兩個實例照樣能把彼此弄壞 - 典型症狀是載入失敗、後續程式碼裡的 access violation、
External exception C000001D,以及以0xC0000409或0xC0000374退出 - 損壞在行程裡持續存在,事發的呼叫往往不是肇事的源頭
ValidatePdfFilesParallel自 v3.125.1 起安全;更舊的版本請用WorkerCount := 1TPdf.RenderPagesParallel是真平行,因為每個 worker 載入的是隔離的 PDFium 模組複本- 自己的執行緒、task 與 future 需要一把行程層級的鎖,把每個
TPdf從Create罩到Free
PDFium Component 為 Delphi 包裝 PDFium 引擎,附批次 preflight 與驗證、隔離式平行渲染、可取消的背景工作與詳盡的載入診斷。細節與版本資訊見 PDFium Component 產品頁