技術文章

PDFium Thread Safety:為什麼每文件一把鎖在 Delphi 行不通

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 內部,開了多少份文件都一樣

PDFium Component 示意圖:兩條執行緒各自在不同文件上跑獨立的 TPdf 實例,每個呼叫卻都匯入同一份載入的 pdfium.dll 模組,其字型快取、頁面模組與其他行程層級全域結構為全體共用,於是產生載入失敗、access violation 與 fail-fast 退出
PDFium 的不安全狀態放在模組裡、不是文件裡,兩條執行緒上的兩個 TPdf 實例寫進的是同一個字型快取,檔案再不相干也一樣

跨文件的損壞在 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 工作重疊進行

PDFium Component 的 ValidatePdfFilesParallel 示意圖:每個 worker 在 TPdf 建立、載入、preflight 與釋放全程握著同一把行程層級的 critical section,而對擷取報告的規則評估在鎖外平行執行,批次的 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 在自己的私用模組裡打開存好的文件,逐步渲染頁面、步與步之間檢查取消狀態,最後銷毀程式庫、卸載複本、刪掉檔案

PDFium Component 的 RenderPagesParallel 示意圖:呼叫執行緒先存下文件快照,接著每個 worker 把 PDFium DLL 複製成獨一無二的 temp 檔案、當作擁有自己全域的獨立模組載入,逐步渲染頁面並檢查取消狀態,最後卸載複本
真正的平行來自模組隔離: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 := 1
  • TPdf.RenderPagesParallel 是真平行,因為每個 worker 載入的是隔離的 PDFium 模組複本
  • 自己的執行緒、task 與 future 需要一把行程層級的鎖,把每個 TPdf 從 Create 罩到 Free

PDFium Component 為 Delphi 包裝 PDFium 引擎,附批次 preflight 與驗證、隔離式平行渲染、可取消的背景工作與詳盡的載入診斷。細節與版本資訊見 PDFium Component 產品頁