在 Delphi 中平行轉譯 PDF 頁面歸結為一條規則:為每個工作執行緒提供其專屬的轉譯器。losLab PDF Library 針對該工作提供了 RenderPagesToFilesParallel,將頁面範圍分發到 TTask 池中,且每個工作執行緒各使用一個 TPDFlib 執行個體,使多核心機器能夠將批次點陣化工作轉換為接近核心數量的吞吐量。反之,若在執行緒之間共用單一執行個體,執行時不僅不會平穩減速,反而會導致記憶體損毀並崩潰
當每晚的排程工作必須將 500 頁的 PDF 轉換為 500 張 PNG,且主機有 16 個核心處於閒置狀態,而您首次嘗試使用執行緒卻在 GDI+ 內部崩潰時,這篇文章就是您所需要的。簡而言之,這裡的執行緒安全是一種結構性屬性,而不是您設定的旗標,本文的其餘部分將探討為什麼安全的架構是這樣設計的,以及實際的效能提升上限在哪裡
TPDFlib 在平行轉譯中是執行緒安全的嗎?
不安全。在圍繞此進行設計之前,其原因值得深入瞭解。單一的 TPDFlib 執行個體是宣告為單執行緒使用的,而其最棘手的地方在於 TPDFPageTree.GetPage:作為選取頁面的副作用,它會在該執行個體上寫入共用的 FPagePointer 欄位。呼叫同一個執行個體的兩個執行個體會在該欄位上產生資料競爭,因此當工作執行緒 B 將頁面樹重新指向第 40 頁時,工作執行緒 A 可能才處理到第 3 頁的一半。此 API 沒有限制您撰寫如下的程式碼,它甚至能在出錯前執行幾頁,而這正是此類錯誤最糟糕的表現方式
// 請勿這樣做:多個執行緒共用一個執行個體
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
Pdf.LoadFromFile('report.pdf', '');
TParallel.For(1, Pdf.PageCount,
procedure(Page: Integer)
begin
// 每個執行緒都重新進入同一個執行個體 -> 在 FPagePointer 上產生資料競爭
Pdf.RenderPageToFile(150, Page, 0, 'page' + IntToStr(Page) + '.png');
end);
Pdf.Free;
end;
該錯誤不是確定性發生的,這正是它能通過簡單的冒煙測試,然後在具有不同核心數和更重文件的客戶機器上浮現的原因。在 RenderPageToFile 週邊也沒有您可以使用的簡單鎖定來修復此問題,因為在整個轉譯呼叫中持有互斥鎖(mutex)會使工作序列化,從而失去您追求的平行化優勢
為什麼每個轉譯工作執行緒都需要自己的 TPDFlib 執行個體?
因為執行個體就是隔離的單位。一旦每個工作執行緒都擁有獨立載入檔案的私有 TPDFlib,每個執行緒就會有自己的頁面樹、自己的 FPagePointer 以及自己的轉譯狀態,因此沒有任何共用資源會產生競爭。這種安全性是有代價的,您應該提前評估:每個工作執行緒都會將整份文件解析到記憶體中,因此峰值記憶體佔用量大約是單一執行個體成本的 N 倍。在 300 MB 的 PDF 上執行八個工作執行緒意味著同時存在八個完整的解析資料,在非常大的輸入上,這是決定您工作執行緒數量的限制因素,而不是 CPU。當文件龐大且您受限於記憶體而非 CPU 時,無完整文件解析的大型 PDF 處理中介紹的直接存取路徑通常是比增加轉譯執行緒更好的選擇
一鍵呼叫的 API:RenderPagesToFilesParallel
losLab PDF Library 將整個安全模式封裝在單一方法中,因此在常見情況下您無需手動處理任何部分。RenderPagesToFilesParallel 接收檔案名稱和密碼、DPI、包含起始與結束頁面、直接傳遞給每頁點陣化路徑的 Options 值、以 %p 被取代為頁碼的輸出格式,以及工作執行緒上限(任何小於或等於零的值均表示自動設定)。它會傳回成功轉譯的頁面數量,且這是一個僅限 Windows 的路徑,因為它依賴 CoInitialize 和 GDI+
var
Pdf: TPDFlib;
Rendered: Integer;
begin
Pdf := TPDFlib.Create;
try
// 檔案名稱, 密碼, DPI, 起始頁面, 結束頁面, 選項, 輸出格式, 最大工作執行緒數
Rendered := Pdf.RenderPagesToFilesParallel(
'report.pdf', '', 150.0, 1, 500, 0, 'out\page_%p.png', 0);
// 最大工作執行緒數 = 0 -> 自動:取頁數和 CPU 核心數的最小值
WriteLn(Format('%d pages rendered', [Rendered]));
finally
Pdf.Free;
end;
end;
為什麼要在每個工作執行緒上呼叫 CoInitialize?
GDI+ 是頁面轉譯背後的點陣化器,而 GDI+ 是單執行緒套間(apartment-threaded)的:它期望在任何呼叫它的執行緒上初始化 COM。VCL 應用程式的主執行緒通常已經設定好此環境,但新建立的 TTask 工作執行緒則不然,從未初始化的執行緒呼叫轉譯路徑是導致崩潰的常見原因。因此,每個工作執行緒在進入時需成對呼叫 CoInitialize(nil),並在退出時呼叫 CoUninitialize,以涵蓋其整個生命週期。這與主執行緒之外的任何 GDI+ 或 COM 工作所需的規範相同,這也是使每個工作執行緒隔離能夠發揮作用的後半部分,前半部分則是私有的執行個體。同一個 GDI+ 點陣化路徑驅動了選擇 PDF 輸出的轉譯引擎中介紹的單執行緒引擎
靜態分片與動態頁面宣稱
在 8 個工作執行緒中分配 500 頁的直觀方法是給每個工作執行緒分派約 62 頁的固定切片。losLab PDF Library 不會這樣做,原因在於負載平衡。每頁的轉譯成本差異極大:一頁本文文字只需幾毫秒即可完成繪製,而一頁密集的向量地圖或全版掃描影像可能會花費五十倍以上的時間。將工作分成固定分片,繪製重度切片的工作執行緒在其他執行緒閒置後仍會執行很長時間,因此您的實際總時間取決於最慢的分片,而不是平均時間。相反地,每個工作執行緒都會在短暫的臨界區段內從共用計數器中獲取下一頁,進行轉譯,然後返回獲取下一頁,從而使每個核心都保持忙碌,直到整個範圍耗盡為止
// 每個工作執行緒在集區內執行的操作(簡化版)
NextPage := StartPage;
IdxLock := TCriticalSection.Create;
WorkerProc :=
procedure
var
LocalLib: TPDFlib;
PageNum: Integer;
begin
CoInitialize(nil); // GDI+ 是單執行緒套間的
try
LocalLib := TPDFlib.Create; // 每個工作執行緒一個私有執行個體
try
LocalLib.LoadFromFile(FileName, '');
while True do
begin
IdxLock.Enter; // 以不可分割的方式獲取下一頁
try
PageNum := NextPage;
Inc(NextPage);
finally
IdxLock.Leave;
end;
if PageNum > EndPage then Break;
LocalLib.RenderPageToFile(DPI, PageNum, 0,
Format('page_%d.png', [PageNum]));
end;
finally
LocalLib.Free;
end;
finally
CoUninitialize;
end;
end;
跨工作執行緒的結構化日誌紀錄
在沒有日誌的情況下,對在 500 頁中的第 213 頁中斷的批次處理進行除錯是極其痛苦的,而一個不成熟的日誌其本身就是個並行錯誤。losLab PDF Library 提供了 TPDFlibLogger,透過 TPDFlib.Logger 屬性附加,且預設為 nil,使得無日誌路徑保持零成本。它是回呼優先的:您設定 OnLog 並將記錄導向主機需要的任何地方,並透過 llDebug / llInfo / llWarn / llError 等層級進行篩選,而 PDFlibErrorMessage 則將原始數值代碼轉換為易讀文字,使 Error 記錄不僅僅是個單純的整數。選用的檔案輸出是唯一的共用資源,且它受到 TCriticalSection 的保護,正是為了讓多個工作執行緒可以安全地附加至同一個日誌檔案。請注意其實際限制:只有該檔案輸出是同步的,因此如果您在手動建構的集區中共用一個日誌記錄器,且您的 OnLog 涉及使用者介面,您仍然必須自行將其封送(marshal)回主執行緒
var
Pdf: TPDFlib;
Log: TPDFlibLogger;
begin
Log := TPDFlibLogger.Create;
Log.Level := llInfo; // llDebug, llInfo, llWarn, llError 等層級
Log.FileName := 'render.log'; // 選用的共用輸出(受鎖定保護)
Log.OnLog :=
procedure(Level: TPDFlibLogLevel; Code: Integer; const Msg: WideString)
begin
if Level = llError then
// 自行封送至 UI 執行緒;OnLog 在工作執行緒上觸發
WriteLn(Format('[%d] %s', [Code, PDFlibErrorMessage(Code)]));
end;
Pdf := TPDFlib.Create;
Pdf.Logger := Log; // 預設為 nil;未設定時零成本
try
Pdf.RenderPagesToFilesParallel('report.pdf', '', 150.0, 1, 500, 0,
'out\page_%p.png', 0);
// 現在 Error 包含文字,例如 401 -> "密碼錯誤或權限被拒絕"
finally
Pdf.Free;
Log.Free;
end;
end;
您實際上應該期望多少效能提升?
請坦白評估時間花在哪裡,因為平行轉譯只有在工作確實受限於 CPU 時才划算。高 DPI 輸出以及複雜的向量或漸層頁面是計算密集的,在飽和 CPU 之前,這些工作的效能提升與核心數量幾乎呈線性關係。一般的頁面則完全不同:在這種情況下,每個工作執行緒的 LoadFromFile 額外負擔,加上寫入輸出檔案的磁碟成本,可能會蓋過轉譯本身的開銷,八個工作執行緒在一個慢速磁碟上爭奪可能會比乾淨的序列迴圈還要慢。請將 MaxWorkers 設定為您的實體核心數量,而不是某些理想值,並在來源 PDF 龐大時注意記憶體。如果批次處理結果受限於 I/O,解決方法是使用更快的儲存裝置或減少工作執行緒,而不是增加執行緒。在為其量身打造的工作中,此處介紹的批次轉譯路徑是標準 losLab PDF Library(適用於 Delphi 和 C++Builder)的一部分,它能將閒置的核心轉換為完成的頁面,且無需您自行解決任何執行緒安全陷阱