技術記事

Delphi PDFium非同期レンダリングにおけるWaitForIdleのデッドロック

バッチレンダリングが途中で凍りついてしまうのは、PDFium Component内のエグゼキュータが、応答がディスパッチされるまでタスクを完了したとは見なさないからだ。padSynchronizeのもとでは、その応答はメインスレッド上で実行される。メインスレッドがCheckSynchronizeをポンピングすることなくブロックしていると、ワーカーはメインスレッドを待ち、メインスレッドはアイドルを待つことになる

デバッガの画面は、一度見てしまえば見間違えようがない。凍りついたプロセスを一時停止すると、メインスレッドはあなた自身のバッチループの何フレームか下、アイドルイベントの待機の中に座っている。どのワーカースレッドに切り替えても、それはTThread.Synchronizeの中に座っており、渡すことのできない完了済みの結果を抱えている。何もスピンしておらず、CPUは何も燃やしておらず、プロセスはただ駐車されているだけだ。この記事は、そもそもなぜそのような状態が存在するのかについて、そして、PDFium上のDelphiワーカープールが行儀よく振る舞うか噛みつくかを決める3つの隣接する規則——QueueCapacityが実際に何を制限しているか、シャットダウンがどの順序でキャンセルしなければならないか、そしてPDFiumのオブジェクト所有権に関して並列性が何を買ってくれないか——について述べる

なぜWaitForIdleはメインスレッドをハングさせるのか

それがハングするのは、TPdfAsyncExecutorにおけるアイドルが、ワーカーの完了だけでなく応答のディスパッチも含むと定義されているからだ。実行中カウントは、ワーカーがタスクを取り出すときにDequeueTaskの中でインクリメントされ、TaskFinishedの中でデクリメントされる。そしてこのTaskFinishedは、TPdfAsyncTaskOperation.Executeが戻った後になって初めてワーカーが呼び出すものだ。このメソッドはワーカー本体を実行し、結果を記録し、それからTPdfAsyncDispatchModeに従って応答をディスパッチする。padSynchronizeのもとではこのディスパッチはTThread.Synchronize呼び出しであるため、メインスレッドがそれを実行し終えるまでExecuteは戻らない

Delphiはこの契約の後半をあなたに委ねている。TThread.Synchronizeはそのメソッドをグローバルキューに追加し、呼び出し元のスレッドをイベントの上でブロックする。メインスレッド上の何かが、そのイベントがシグナルされる前にCheckSynchronizeを呼ばなければならない。VCLのメッセージループはメッセージとメッセージの間でこれをあなたの代わりに行っており、これこそがこのバグが対話的な使用中には見えず、ブロッキングなバッチループを書いた瞬間に現れる理由である。ブロックしているメインスレッドとは、メッセージループを離れてしまったメインスレッドであり、メッセージループの外にあるメインスレッドは誰のこともドレインしていない

uses
  System.Classes, FPdfAsync, PDFium;

// The shape that deadlocks: a synchronized reply plus a blocking main thread
Task := Executor.Submit(RenderPageWorker, PageRendered, papNormal,
  padSynchronize);
Task.WaitFor(High(Cardinal));   // the main thread now parks in a kernel wait

// Meanwhile TPdfAsyncTaskOperation.Execute has reached:
//   TThread.Synchronize(AWorkerThread, DispatchReply);
// which enqueues DispatchReply and waits for the main thread to drain it.
// The main thread is draining nothing, so both sides wait forever.

タスクの完了とエグゼキュータのアイドルは2つの異なるマイルストーンである

この2つは意図的に切り分けられており、自分がどちらを待っているのかを知ることこそがこの修正のすべてである。IPdfAsyncTask.WaitForは、ワーカーの結果が決まった瞬間に満たされる:Completeは、いかなる応答も検討される前に、最終的なTPdfAsyncTaskStateを書き込み完了イベントをセットする。TPdfAsyncExecutor.WaitForIdleはもっと後で満たされる。キューされたカウントと実行中カウントの両方がゼロになったときであり、実行中カウントは応答が届くまで下がらない。したがって、あるタスクはpatsSucceededとなりSnapshotを通じて観測可能でありながら、エグゼキュータは依然として正当にビジーであるということがありうる

// TPdfAsyncExecutor.WaitForIdle already pumps for you: it waits on the idle
// event in short slices and calls CheckSynchronize(0) between them.
if not Executor.WaitForIdle(30000) then
  ReportBatchTimeout;

// Any hand-rolled main-thread wait has to do the same thing explicitly.
function WaitForTaskOnMainThread(const ATask: IPdfAsyncTask;
  ATimeoutMs: Cardinal): Boolean;
var
  StartedAt: UInt64;
begin
  StartedAt := PdfAsyncTick;
  repeat
    if ATask.WaitFor(10) then
      Exit(True);
    CheckSynchronize(0);        // release any pending padSynchronize reply
    Result := PdfAsyncTickDelta(StartedAt, PdfAsyncTick) < ATimeoutMs;
  until not Result;
end;

身に付けておく価値のある1つの帰結がある:例外を発生させる応答は、履歴を書き換えたりしない。DispatchReplyはその例外を捕まえてReplyErrorMessageに保存し、StateCancellationReasonErrorMessageはワーカーが決定したとおりに正確に残す。サムネイルを描画中に爆発するUIコールバックは、したがって成功したレンダリングを失敗したものへと変えてしまうことは決してなく、あなたのテレメトリはレンダーエンジンが実際に行ったことを報告し続ける。プールではなく単一の操作に対するコールバック形のAPIが欲しいなら、キャンセル可能なfutureによるバックグラウンドレンダリングがその経路を扱っている

QueueCapacityは実行中のワーカーも制限するのか

しない。PDFium ComponentのQueueCapacityはキューに入っているタスクだけを数え、すでにワーカー上で実行中のものは決して数えない。これは意図的なものだ:容量は待ち行列に対する本物の背圧を表現するためのものであり、固定された並行スロットを同じ数値に折り込めば、それらを二重に数えてしまうことになる。ワーカーが4つで容量が8なら、実行中のタスクは12個ありうる。そしてGetStatsQueuedCountRunningCountを通じてこの内訳を正直に報告する

var
  Stats: TPdfAsyncExecutorStats;
  Task: IPdfAsyncTask;
begin
  // TrySubmit never raises: it returns False when the waiting line is full or
  // the executor is already shutting down, and bumps RejectedCount.
  if not Executor.TrySubmit(RenderPageWorker, PageRendered, Task, papHigh,
    padSynchronize) then
  begin
    Stats := Executor.GetStats;
    // QueuedCount is what QueueCapacity bounds. RunningCount is bounded by
    // WorkerCount and is never charged against the capacity.
    LogBackpressure(Stats.QueuedCount, Stats.RunningCount,
      Stats.RejectedCount);
    Exit;
  end;

TPdfAsyncPriorityの4つのレーンは、重み付けではなく厳格なものである。DequeueTaskpapCriticalからpapLowへと下りながら歩き、最初の空でないレーンを取る。各レーン内ではFIFOの順序が保たれる。これにより対話的な要求はまだ始まっていないバッチの前に綺麗に割り込むことができるが、すでに実行中の作業を中断することは決してなく、papCriticalを送り続ける呼び出し元はpapLowを無期限に飢えさせることができてしまう。上位2つのレーンは人間が目に見えて待っているものだけのために取っておき、一括エクスポートはpapNormalかそれ以下に置くこと。満杯のキューがプログラミング上の誤りでありEPdfAsyncQueueFullに値するときはSubmitを使い、それが処理するつもりの通常の状態であるときはTrySubmitを使うこと

なぜShutdownはエグゼキュータのロックの外でキャンセルするのか

ロックの内側でキャンセルすることはロックの順序を逆転させ、実行しようとしているそのシャットダウン自体をハングさせてしまうからだ。Shutdown(True)はエグゼキュータのロックを取り、シャットダウンフラグを反転させ、保留中のすべてのタスクをAppendSnapshotを通じてローカルなスナップショット配列に追記する。それからロックを解放し、その後になって初めてスナップショットを走査し各エントリに対してCancelを呼ぶ。あるタスクをキャンセルすると、そのトークンソースに登録されたユーザーコールバックが発火するが、それらのコールバックは普通のアプリケーションコードだ:GetStatsを問い合わせたり、代償的な作業を送信したり、アイドルを待ったりするかもしれない。そのそれぞれがエグゼキュータのロックへ再入するため、そのロックが保持されている間に呼び出されるコールバックは自分自身に対してデッドロックしてしまう

トークンソースも1階層下で同じ規律に従う。CancelWithReasonはソースのロックを取り、唯一の勝者となるキャンセラーを決め、ReasonCancellationMessageCancelledAtTickを書き込み、その後になって初めてキャンセル済みフラグをアトミックに反転させる。反転の前に公開するということが、そのメタデータを安全に読めるものにしている:IsCancelledがTrueであると観測したどのスレッドも、その背後に完全な理由を見つけることが保証されており、後から来た呼び出し元はレースに負けてFalseを返し、最初の理由を上書きすることはできない。登録されたコールバックはロックの内側でスナップショットされクリアされるが、ロックの外側で呼び出され、それぞれが1つの失敗したハンドラが残りを抑制してしまわないようラップされている。すでに実行中のタスクは決して強制終了されない。それらはそのワーカー本体が次にThrowIfCancelledを呼んだときに協調的に終わる。これがShutdownがスレッドをジョインする前にポンピングするWaitForIdleで終わる理由だ

並列ワーカーはPDFiumのオブジェクト所有権を緩めるのか

緩めない。そしてこれは最も誤読されやすい境界だ。TPdfAsyncExecutorは作業をスケジュールするだけであり、その作業の内側であなたが触れるものについてスレッドアフィニティに関する主張は一切しない。生きたTPdfインスタンスは、たまたま2つのワーカーがそれを呼び出したからといって並行アクセス可能になるわけではなく、内部のレンダーロックは重なり合うレンダー呼び出しに対する防御であって、文書を複数スレッドで共有する許可証ではない。並列レンダリングやエクスポートとは、ワーカーごとに1つのTPdfを意味し、それはジョブの内側で作成され破棄される

type
  TPageRenderJob = class
  private
    FFileName: string;
    FPageIndex: Integer;
  public
    procedure Run(const AToken: IPdfCancellationToken);
  end;

procedure TPageRenderJob.Run(const AToken: IPdfCancellationToken);
var
  LocalPdf: TPdf;      // one document instance per worker, never shared
  Bmp: TBitmap;
begin
  LocalPdf := TPdf.Create(nil);
  try
    LocalPdf.FileName := FFileName;
    LocalPdf.Active := True;
    LocalPdf.PageNumber := FPageIndex;
    AToken.ThrowIfCancelled;
    Bmp := LocalPdf.RenderPage(0, 0, 1024, 1448);
    try
      HandOffBitmap(FPageIndex, Bmp);   // ownership moves to the reply stage
    finally
      Bmp.Free;
    end;
  finally
    LocalPdf.Free;
  end;
end;

そのコストは本物であり、名指ししておく価値がある:すべてのワーカーが自分自身のパースと自分自身のページキャッシュのコストを支払うため、メモリは文書数ではなくワーカー数に比例してスケールする。それが、あるワーカーが他の誰かを破壊することなくキャンセルされたりクラッシュしたりできるというモデルの代償である。もしあなたのワーカーが代わりにビューア側の文書を実際に共有しているなら、それに関するロックの規則はレンダーロックとそれを見逃す呼び出しで扱っており、単一文書のキャンセル可能な経路はキャンセル可能な段階的レンダリングにある

vtableを壊さずに公開インターフェースを拡張する

IPdfCancellationTokenIPdfCancellationTokenSourceは、外部バイナリがすでに消費しているかもしれないCOMスタイルのインターフェースであるため、どちらかにメソッドを追記すればvtable内のそれ以降のすべてのスロットがずれてしまい、古いレイアウトに対してコンパイルされた呼び出しを静かに誤配線してしまう。したがって、診断、待機、除去可能なコールバック、アトミックなキャンセルといった機能は、変更するのではなく継承するIPdfCancellationTokenExIPdfCancellationTokenSourceExの中に住んでいる。NewRunは既存の呼び出し元のために元の意味論を保つ。新しいコードはCancelWithReasonWaitForCancellation、あるいは管理されたIPdfCancellationRegistrationが欲しいときにNewExNewTimeoutRunExに手を伸ばす。継承は公開インターフェースを成長させる唯一の安全な方法であり、その代償は世代ごとに1つ余分な型が増えることだ

NewTimeoutについては正直な注釈に値する。それぞれのタイムアウトソースは、キャンセルイベントか締切のいずれか早いほうを待つ軽量なスレッドを1つ所有する。少数、あるいは数十個の締切に対してはこれは単純で低遅延であり、Delphi、Lazarus、C++Builderの間で同一である。数千の短い締切に対してはこれは間違った形だ。その場合は数千のスレッドを待機させ続けるのではなく、1つのアプリケーションレベルのタイマーからキャンセルを駆動するべきである

これらの規則はどれも書き出してしまえば奇特なものではないが、そのどれもが書かれていなければ本番環境のインシデントになる。正しいマイルストーンを待ち、何かにsynchronizeキューをポンピングさせ、QueueCapacityを待ち行列だけに対する上限として読み、ロックの外側でキャンセルし、すべてのワーカーに自分自身の文書を与えること。ここで説明した非同期レイヤーは、それがスケジュールするレンダリング・テキスト・フォームのAPIとともに、Delphi PDFium Componentの一部として提供される