技術記事

Delphiでの並行PDFページレンダリング:スレッドセーフティ

DelphiからPDFページを並行してレンダリングする際の鉄則は、「各ワーカースレッドに専用 of レンダラーを割り当てる」という点に尽きます。losLab PDF Libraryは、まさにその目的のためにRenderPagesToFilesParallelを提供しており、ワーカーごとに1つのTPDFlibインスタンスを割り当てて、ページ範囲の処理をTTaskプールに分散させます。これにより、マルチコアマシンにおいて、一括ラスタライズ処理のパフォーマンスをコア数にほぼ比例してスケールさせることができます。逆に、スレッド間で単一のインスタンスを共有すると、単に処理が遅くなるだけでなく、メモリが破損してクラッシュする原因となります

この記事は、夜間ジョブで500ページのPDFを500枚のPNGに変換する必要があり、サーバーには16個の空きコアがあるにもかかわらず、スレッド化の最初の試みがGDI+の内部で失敗してしまったような場合に役立ちます。結論から言えば、ここでのスレッドセーフティは設定可能なフラグのようなものではなく、構造的な設計特性です。この記事の残りの部分では、なぜ安全な設計がこのような形状になるのか、そして実際の高速化の限界がどこにあるのかを説明します

TPDFlibは並行レンダリングにおいてスレッドセーフですか?

いいえ、スレッドセーフではありません。その設計を行う前に、理由を理解しておく価値があります。単一のTPDFlibインスタンスはシングルスレッドでの使用を前提として宣言されており、特に危険なのはTPDFPageTree.GetPageです。このメソッドは、ページを選択する際の副作用として、インスタンス上の共有フィールドであるFPagePointerに書き込みを行います。2つのスレッドが同じインスタンスを呼び出すと、このフィールドでデータ競合が発生します。そのため、ワーカーAがページ3のレンダリングの途中であるにもかかわらず、ワーカーBがページツリーの参照先をページ40に書き換えてしまうといった事態が起こり得ます。API自体は以下のコードを記述することを禁止していないため、エラーが発生する前に数ページだけ正常に実行できてしまうことがあり、これがこの種のバグの最も厄介な挙動です

// DO NOT do this: one shared instance, many threads
var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  Pdf.LoadFromFile('report.pdf', '');
  TParallel.For(1, Pdf.PageCount,
    procedure(Page: Integer)
    begin
      // every thread reenters the same instance -> data race on FPagePointer
      Pdf.RenderPageToFile(150, Page, 0, 'page' + IntToStr(Page) + '.png');
    end);
  Pdf.Free;
end;

この失敗は決定的ではないため、簡単な動作確認(スモークテスト)を通過してしまい、コア数が異なりドキュメントがさらに重い顧客の環境で突然発生することがあります。また、RenderPageToFile呼び出しをロックで囲むといった単純な方法では解決できません。なぜなら、レンダリング処理全体にわたってミューテックス(Mutex)を保持すると、処理が直列化され、並行処理によるメリットが完全に失われてしまうからです

なぜ各レンダリングワーカーに専用のTPDFlibインスタンスが必要なのですか?

インスタンスこそが分離の単位だからです。各ワーカーが個別にファイルをロードしたプライベートなTPDFlibインスタンスを所有していれば、それぞれが独自のページツリー、独自のFPagePointer、および独自のレンダリング状態を持つことになるため、競合が発生する共有リソースは存在しません。ただし、この安全性には前もって考慮すべきコストが伴います。すべてのワーカーがドキュメント全体をメモリにロードして解析するため、ピーク時のメモリフットプリントは、単一インスタンス時のコストのほぼN倍になります。300MBのPDFに対して8つのワーカーを動作させると、8つの完全な解析データが同時にメモリ上に常駐することになり、非常に大きな入力データを扱う場合は、CPUではなくメモリ容量がワーカー数を決定する制約となります。ドキュメントが巨大で、CPUバウンドではなくメモリバウンドである場合は、レンダリングスレッドを増やすよりも、ドキュメント全体の解析を行わずに大きなPDFを処理するで説明している直接アクセスルートを採用する方が効果的です

ワンコールAPI:RenderPagesToFilesParallel

losLab PDF Libraryは、この安全なパターン全体を単一のメソッドにパッケージ化しているため、一般的なユースケースでは手動で実装する必要はありません。RenderPagesToFilesParallelは、ファイル名とパスワード、DPI、開始ページと終了ページ(両端を含む)、ページごとのラスタライズパスにそのまま渡されるOptions値、ページ番号が%pに置換される出力パターン、および上限ワーカー数(0以下の値を指定すると自動設定されます)を受け取ります。このメソッドは正常にレンダリングされたページ数を返します。この処理はCoInitializeおよびGDI+に依存しているため、Windows専用のパスとなっています

var
  Pdf: TPDFlib;
  Rendered: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    // FileName, Password, DPI, StartPage, EndPage, Options, Pattern, MaxWorkers
    Rendered := Pdf.RenderPagesToFilesParallel(
      'report.pdf', '', 150.0, 1, 500, 0, 'out\page_%p.png', 0);
    // MaxWorkers = 0 -> auto: min(page count, CPU cores)
    WriteLn(Format('%d pages rendered', [Rendered]));
  finally
    Pdf.Free;
  end;
end;

なぜ各ワーカースレッドでCoInitializeを行うのですか?

GDI+はページレンダリングの基盤となるラスタライザーであり、アパートメントスレッドモデルを採用しています。つまり、自身を呼び出すスレッド上でCOMが初期化されていることを前提としています。VCLアプリケーションのメインスレッドは通常、このセットアップが完了していますが、新しく生成されたTTaskワーカーは初期化されていません。初期化されていないスレッドからレンダリングパスを呼び出すと、確実にクラッシュします。そのため、各ワーカーは処理의 開始時にCoInitialize(nil)を呼び出し、終了時にCoUninitializeを呼び出すことで、そのライフサイクル全体を囲みます。これは、メインスレッド以外でGDI+やCOMの処理を行う際の標準的な手順であり、プライベートインスタンスの確保と並んで、ワーカーごとの完全な分離を実現するための重要な要素です。同じGDI+ラスタライズパスは、PDF出力用のレンダリングエンジンの選択で紹介しているシングルスレッドエンジンでも使用されています

静的シャージングと動的ページ取得の比較

500ページを8つのワーカーに分割する最も分かりやすい方法は、各ワーカーに約62ページの固定スライスを割り当てることです。しかし、losLab PDF Libraryはそのような方法を取りません。理由は負荷分散にあります。ページのレンダリングコストはページ内容によって大きく異なります。本文テキストのみのページは数ミリ秒でレンダリングできますが、高密度のベクターマップや全面スキャンされた画像を含むページは、その50倍以上の時間がかかることがあります。作業を固定スライスに分割してしまうと、重い処理が集中したスライスを担当するワーカーが、他のワーカーがアイドル状態になった後も長時間動作し続けることになり、全体の処理時間は平均ではなく最も運の悪いワーカーの処理時間によって決定されてしまいます。代わりに、各ワーカーは短いクリティカルセクションのもとで共有カウンターから次のページ番号を取得し、レンダリングを行い、再び次のページを取得しに戻ります。これにより、すべてのページが処理されるまで、すべてのコアを常に稼働させることができます

// What each worker does inside the pool (simplified)
NextPage := StartPage;
IdxLock := TCriticalSection.Create;
WorkerProc :=
  procedure
  var
    LocalLib: TPDFlib;
    PageNum: Integer;
  begin
    CoInitialize(nil);              // GDI+ is apartment-threaded
    try
      LocalLib := TPDFlib.Create;   // one private instance per worker
      try
        LocalLib.LoadFromFile(FileName, '');
        while True do
        begin
          IdxLock.Enter;            // claim the next page atomically
          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レコードが単なる整数値以上の意味を持つようになります。オプションのファイル出力先は唯一の共有リソースとなりますが、複数のワーカーが1つのログファイルに安全に追記できるように、TCriticalSectionによって保護されています。注意すべき制限として、同期されるのはそのファイル出力処理のみであるため、独自に構築したスレッドプールで1つのロガーを共有し、OnLogでUIを操作する場合は、開発者自身でその処理をメインスレッドにマージ(マーシャリング)する必要があります

var
  Pdf: TPDFlib;
  Log: TPDFlibLogger;
begin
  Log := TPDFlibLogger.Create;
  Log.Level := llInfo;                   // llDebug, llInfo, llWarn, llError
  Log.FileName := 'render.log';          // optional shared sink (lock-guarded)
  Log.OnLog :=
    procedure(Level: TPDFlibLogLevel; Code: Integer; const Msg: WideString)
    begin
      if Level = llError then
        // marshal to the UI thread yourself; OnLog fires on worker threads
        WriteLn(Format('[%d] %s', [Code, PDFlibErrorMessage(Code)]));
    end;
  Pdf := TPDFlib.Create;
  Pdf.Logger := Log;                     // nil by default; zero-cost when unset
  try
    Pdf.RenderPagesToFilesParallel('report.pdf', '', 150.0, 1, 500, 0,
      'out\page_%p.png', 0);
    // an Error now carries text, e.g. 401 -> "Wrong password or permission denied"
  finally
    Pdf.Free;
    Log.Free;
  end;
end;

実際にどの程度の高速化が期待できますか?

並行レンダリングが効果を発揮するのは、処理が純粋にCPUバウンドである場合に限られるため、どこで処理時間が消費されているかを客観的に見極める必要があります。高解像度の出力や、複雑なベクターオブジェクト、グラデーションの塗りつぶしを含むページは計算負荷が高く、CPUが飽和するまではコア数にほぼ比例してスケールします。しかし、シンプルなページの場合は異なります。各ワーカーでのLoadFromFileのオーバーヘッドや、出力ファイルをディスクに書き出すコストがレンダリング処理自体を上回ってしまうことがあり、低速な1つのディスクに対して8つのワーカーが競合すると、シンプルなシリアルループ処理よりも遅くなることがあります。MaxWorkersには希望的観測に基づく数値ではなく、実際の物理コア数を設定してください。また、元のPDFが巨大な場合はメモリ使用量に注意し、もしIOバウンドであることが判明した場合は、スレッドを増やすのではなく、ストレージの高速化やワーカー数の削減を行ってください。設計通りのタスクに適用すれば、ここで示した一括レンダリングパスはDelphiおよびC++Builder向けの標準のlosLab PDF Libraryで動作し、スレッドセーフティに関する複雑な問題を個別に解決することなく、遊休コアをフル活用してページ生成を完了させることができます