技術記事

PDFiumのスレッド安全:ドキュメントごとのロックがDelphiで失敗する理由

PDFiumはモジュールレベルではスレッドセーフではないので、2つのTPdfインスタンスが2つのスレッドで別々のファイルを扱っていても、互いを破壊できます。Delphi向けPDFium Componentはこれを2通りで処理します。v3.125.1からはValidatePdfFilesParallelが、1つのプロセス全体ロックの後ろでネイティブPDFium呼び出しを直列化し、一方TPdf.RenderPagesParallelは各ワーカーにPDFiumモジュールの独立したコピーを与えます。修正を迫ったバグは、最悪の種類の断続的バグでした。バッチ検証のテストはたいていのとき通り、それから2つの正常なファイルの片方を失敗と報告し、その次のテストを同じプロセス内でアクセス違反でクラッシュさせ、ときにはスタックトレースの代わりに終了コードでランナー全体を持っていきました。テストには何の問題もなく、単独のドキュメントにも問題はありませんでした。間違っていたのは想定です。スレッドごとに1つのTPdfは分離ではありません

スレッドごとに1つのTPdfではなぜ足りないのか

スレッドごとに1つのTPdfでは足りません。PDFiumが非安全な状態をモジュールに、ドキュメントに保持していないからです。各TPdfは自分のFPDF_DOCUMENTハンドルを所有しますが、プロセス内のすべてのハンドルは同じロード済みDLLに世話になっており、そのDLLはプロセス全体のシングルトンを抱えています。フォントキャッシュ、ページモジュール、そしてドキュメントのロード、パース、描画がすべて触れるその他のグローバル構造です。無関係な2つのファイルをロードする2つのスレッドは、同じフォントキャッシュへ同時に書き込む2つのスレッドなのです。Delphi側でそのデータを所有する者は誰もいないので、Delphi側でドキュメントごとにロックできるものも何もありません

コンポーネントにはロックがあり、そこから誤った結論を引き出すのは簡単です。TPdfは自分の描画経路を内部のクリティカルセクション(EnterRenderLock / LeaveRenderLock、TPdfのプライベートメソッド)で包みます。このロックはインスタンスごとです。2つのスレッドが同時に同じTPdfを運転するのを止めます。これは本物の危険です。しかし別スレッドの2つ目のインスタンスは見えないので、インスタンス間の同時実行はまっすぐ通り過ぎます。一般則は1行で言えるほど単純です。ロード済みの1つのPDFiumモジュールでは、どれだけ多くのドキュメントが開いていようと、どの瞬間でもPDFiumの内側にいてよいスレッドは最大1つです

PDFium Componentの図。2つのスレッドが別々のドキュメントで別々のTPdfインスタンスを動かしながら、すべての呼び出しが1つのロード済みpdfium.dllモジュールへ収束し、フォントキャッシュ、ページモジュール、その他のプロセス全体のグローバルが共有されるため、ロード失敗、アクセス違反、フェイルファスト終了が起きます
PDFiumが非安全な状態を保持するのはモジュールであってドキュメントではありません。だから2つのスレッドの2つのTPdfインスタンスは、ファイルがどれほど無関係でも、同じフォントキャッシュへ書き込みます

ドキュメント間の破壊はDelphiプロセスでどんな姿になるのか

ドキュメント間の破壊は、無関係な失敗のランダムな混ぜ物に見え、被害は原因のコードより長生きします。v3.125.1より前、ValidatePdfFilesParallelはワーカースレッドごとに1つのTPdfを作り、Active := Trueとプリフライトレポート構築を共有モジュール上で並行して走らせました。DelphiとFree Pascalの両ビルドで見られた症状は、全範囲をカバーしました:

  • 有効なファイルのロードが失敗する。あるいはバッチから、通るはずなのに失敗として戻ってくる
  • アクセス違反が後の無関係な呼び出しで表面化する。多くは別のテストや別のドキュメントで
  • DelphiではExternal exception C000001Dが現れます。このコードはSTATUS_ILLEGAL_INSTRUCTIONで、不変条件が破れたときにPDFiumの内部のCHECKとIMMEDIATE_CRASHマクロが実行するud2命令が上げます
  • プロセスは0xC0000409(フェイルファスト、スタックバッファオーバーランとして報告)か0xC0000374(ヒープ破壊)で終了し、Delphiの例外はまったく上がりません

最後の2点こそ、バグの特定がこれほど難しかった理由です。並列検証は終わり、壊れたグローバル状態は後ろに残り、同じプロセスの次のフィクスチャがそれにつまずきました。あるDelphi Win64の回帰実行では、C000001Dの失敗の波が、バッチ検証に一切触れないテストを打ちました。被害の後にPDFiumを使った最初のコードだっただけです。実測の数字が規模をはっきりさせます。同じサンプルを2ワーカーで走らせたDelphiのプローブは、ある実行で160中122のドキュメントに失敗し、別の実行で160中138に失敗し、その片方ではExternal exception C000001Dがあからさまに上がりました。8ドキュメント、4ワーカー、5ラウンドのストレスケースは、Free Pascal Win64で5回中5回、失敗かクラッシュでした。修正後、同じプローブは1,200中0のドキュメントしか失敗しません

v3.125.1以降、ValidatePdfFilesParallelはどう安全を保つのか

ValidatePdfFilesParallelは今、各ジョブのネイティブ側を直列化し、マネージ側を並列のまま保ちます。すべてのワーカーは、自分のTPdfを作る前にユニットレベルのクリティカルセクションを1つ取り、FileName、Active := True、プリフライトレポート構築、Freeを通して保持します。生成と破壊が意図的にロックの内側にあるのは、ドキュメントを閉じることがロードと同じくモジュールへコールバックするからです。ワーカーはTPdfPreflightReportレコードを捕まえたらロックを解放し、そのレコードに対して検証ルールを評価します。ここはPDFiumの状態に触れないので、あるファイルのルール評価が次のファイルのPDFium仕事と重なります

PDFium ComponentのValidatePdfFilesParallelの図。各ワーカーがTPdfの生成、ロード、プリフライト、解放を通して1つのプロセス全体クリティカルセクションを保持する一方、捕まえたレポートのルール評価はロックの外で並列に走るため、バッチのPDFium側は設計上直列になります
生成と破壊がロックの内側に留まるのは、ドキュメントを閉じることがモジュールへコールバックするからです。一方、レポート評価はPDFiumの状態に触れず、次のファイルと重なります

修正と一緒に来た小さな変更が2つあります。ロード失敗は今、LastLoadReport.ErrorMessage付きのEPdfErrorを上げるので、項目のErrorMessageは二次的な「アクティブなドキュメントなし」エラーではなく、実際のパース問題を名指します。そしてコストは正直に言明されています。バッチのPDFium側は今は直列なので、パースとプリフライトが支配的なバッチでは、ワーカーを増やしても得るものはわずかです。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];
    // 明示的なレジストリでは、一致するプロファイルを自分で選ぶ。
    // 空のProfilesリストは登録済みの全ルールを実行し、プリフライト報告していない
    // 規格のルールは「合格しなかった」と報告する
    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;

レジストリにnilを渡すのが短い道です。ValidatePdfFilesParallelはその場合、デフォルトレジストリを自分で作り、Options.Standardsからプロファイルリストを導き、戻るときにレジストリを解放します。結果は、ワーカーがどんな順で終わっても、必ず入力順で戻ります。レポート形式と同じエンジンのコマンドラインラッパーは、PDFium Component CLIによるバッチPDFプリフライトレポートを、PDF/Aチェック自体が何をカバーするかは、DelphiでのPDF/Aプリフライト検証をご覧ください

RenderPagesParallelはどうページを本当に並列で走らせるのか

TPdf.RenderPagesParallelが並列で走るのは、ワーカーがPDFiumモジュールを決して共有しないからです。メソッドはまず、呼び出しスレッド上でアクティブなドキュメントをソースストアへ保存します。各ワーカーはその後、ロード済みPDFium DLLを一意な名前のファイルとしてtempディレクトリへコピーし、そのコピーをLoadLibraryでロードし、初期化します。Windowsは別のパスからロードされたDLLを別のモジュールとして扱うので、各コピーは自分のグローバルを得ます。自分のフォントキャッシュ、自分のページモジュール、自分のすべてです。ワーカーは自分のプライベートモジュールで保存済みドキュメントを開き、ステップの間にキャンセルチェックを挟みながらページを漸進的に描画し、それからライブラリを破壊し、コピーをアンロードし、ファイルを削除します

PDFium ComponentのRenderPagesParallelの図。呼び出しスレッドがドキュメントのスナップショットを保存し、それから各ワーカーがPDFium DLLを一意なtempファイルへコピーし、自分のグローバルを持つ別モジュールとしてロードし、キャンセルチェック付きでページを描画し、コピーをアンロードします
本物の並列性はモジュール分離から来ます。Windowsは各DLLコピーを別のモジュールとして扱うので、ワーカーが共有するのは、呼び出しスレッドがロックの下で保存したスナップショットだけです

分離はただではなく、デフォルトはそれを反映します。各ワーカーはディスク上のDLLコピー、メモリ内の第2のPDFiumグローバル集合、そしてドキュメントの新規パースを支払います。MaxWorkers = 0は最大4ワーカーを意味し、MaxPixelsPerPageとMaxTotalOutputBytesが生出力に上限を付け、反転とナイトデュオトーンの描画オプションは拒否されます。バッファーが生のまま返るからです。結果はTPdfParallelRenderReportで、そのResults配列は要求ページごとに1つのトップダウン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;

呼び出しの周りのロックに注目してください。ワーカーモジュールはプライベートですが、冒頭のスナップショットステップは、呼び出しスレッドから共有モジュールにSaveAsを実行します。プロセス内の他の何もTPdfに同時アクセスしないならロックは落とせます。何かが触れるなら、スナップショットには他のすべての共有モジュール呼び出しと同じ保護が要ります

パターンドキュメント間で安全PDFium仕事は並列コスト
スレッドごとに1つのTPdf、共有ロックなしいいえはい、破壊するまで断続的なクラッシュ、プロセス状態の損傷
すべてのPDFium呼び出しの周りに1つのプロセス全体ロックはいいいえPDFium側は直列
v3.125.1以降のValidatePdfFilesParallelはいいいえ。ルール評価は並列パースとプリフライトは直列
TPdf.RenderPagesParallelはいはいワーカーごとにDLLコピー、メモリ、新規パース

自分のマルチスレッドPDFiumコードはどう組むべきか

自分のスレッドは、使うすべてのTPdfの一生の間、1つのプロセス全体ロックを共有して保持するか、さもなければモジュールを分離してくれるコンポーネントAPIを使うべきです。ロックはプロセス全体で1つのオブジェクトでなければなりません。スレッドごと、フォームごと、ドキュメントごとでは駄目です。2つのスレッドが共有しないロックは何も保護しません。以下のパターンは、v3.125.1以降にコンポーネントが内部でやっていることの写しです。ロックの内側で生成、ロード、読み取り、解放し、PDFiumに触れないものはすべて外側でやります

uses
  System.Classes, System.SysUtils, System.SyncObjs, PDFium;

var
  PdfiumLock: TCriticalSection;        // プロセス全体で1つのロック

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の仕事、データベース書き込み、インデックス作成、ネットワーク呼び出しは、ロックの外に置きます。そうしないと、1人の遅い消費者がすべてを直列化します
  • プライベートなインスタンスごとの描画ロックを代用として扱わないでください。1つのTPdfを自分自身から守るだけで、それ以上の何ものでもありません

同じ注意は、生スレッドとして自分で書いていないコードにも当てはまります。バックグラウンドfutureは長い描画をUIスレッドから離す良い方法です。キャンセル可能なfutureによるバックグラウンドPDF描画に述べたとおりです。ただしfuture実行器は、独自のグローバルPDFiumロックを足しません。複数のfutureが同時に別々のTPdfインスタンスを運転し得るなら、各ワーカーの内側で同じプロセス全体ロックを取り、メインスレッドのビューアーを共有モジュールのもう1人のクライアントとして扱います。非同期APIを通じたインスタンス間利用は別途監査されていないので、控えめな想定は、手書きスレッドと同じ直列化を必要とする、です。ページ描画以外の目的で本物のPDFium並列性が要るときは、別のワーカープロセスが構成上、各ジョブに自分のモジュールを与えます

クイックリファレンス:DelphiのためのPDFiumスレッドルール

  • PDFiumの非安全状態はモジュール全体です。フォントキャッシュ、ページモジュール、その他のグローバルは、プロセス内のすべてのドキュメントで共有されます
  • スレッドごとに1つのTPdfは何も分離しません。2つのスレッドの2インスタンスは、今も互いを破壊できます
  • 典型的な症状は、ロード失敗、後続コードでのアクセス違反、External exception C000001D、そして0xC0000409か0xC0000374での終了です
  • 破壊はプロセス内に残るので、失敗する呼び出しは、原因の呼び出しではないことがよくあります
  • ValidatePdfFilesParallelはv3.125.1から安全です。古いバージョンではWorkerCount := 1を使ってください
  • TPdf.RenderPagesParallelが本当に並列なのは、各ワーカーがPDFiumモジュールの分離されたコピーをロードするからです
  • 自分のスレッド、タスク、futureには、各TPdfをCreateからFreeまで覆う1つのプロセス全体ロックが要ります

PDFium Componentは、バッチプリフライトと検証、分離された並列描画、キャンセル可能なバックグラウンド仕事、詳細なロード診断付きで、DelphiのためにPDFiumエンジンをラップします。詳細とエディションは、PDFium Component product pageにあります