技術記事

Delphiでレンダーロックを忘れていた6つのPDFium呼び出し

PDFiumPasのレンダーロックは、文書ごとのクリティカルセクションである——TPdf上のTRTLCriticalSectionフィールドに裏付けられたEnterRenderLockLeaveRenderLock——これはPDFiumのラスタライザへのすべての呼び出しをラップし、実行中のレンダリングの足元でページがアンロードまたはリロードされないようにするためのものだ。TPdfTPdfViewに均等に分かれた6つのメソッドが、PDFiumのビットマップとサムネイル抽出APIを直接呼び出し、そのロックを完全に迂回していた。このギャップはPDFiumPas v2.26.0で閉じられ、この6つすべてを、他のあらゆるレンダリングのエントリポイントがすでに使っていたのと同じロックのペアで包んだ

ここで扱うギャップは、このブログの他所で扱ったcdecl呼び出し規約の不一致と、同じPDFiumバインディング内のFPC Win64ポインタ幅の切り捨てを説明したABI強化パスとは異なる。以下はより狭くより機械的なものである:PDFiumのレンダリング経路に届く6つの呼び出しサイトのロックカバレッジのチェックリスト、それぞれがなぜ見逃されやすかったか、そしてロックの欠落から生じる競合状態がなぜこのコードベースの中で要求に応じて再現するのが最も難しい欠陥の一つなのか

レンダーロックが実際に保護するもの

PDFiumPasがレンダリングを直列化するのは、PDFiumのロード済みページが、あるスレッドから読み取られている間に別のスレッドが自由にそれを解放できるのは安全ではないからだ。TPdfFRenderLockTRTLCriticalSectionを保有しており、コンストラクタで初期化され、FRenderLockReadyフラグによって保護される。これにより、破棄後に到着した呼び出しは、削除されたクリティカルセクションに入るのではなく静かな無操作になる。EnterRenderLockLeaveRenderLockだけが、そのセクションへの唯一の認められた出入り口である

procedure TPdf.EnterRenderLock;
begin
  if FRenderLockReady then
    EnterCriticalSection(FRenderLock);
end;

procedure TPdf.LeaveRenderLock;
begin
  if FRenderLockReady then
    LeaveCriticalSection(FRenderLock);
end;

TPdf.RenderPageRenderTileRenderPageProgressiveは、この監査が始まるずっと前からその規律に従っていた。それぞれがPDFiumを呼び出す前にロックを取得し、finallyブロックでそれを解放する。そのため、バックグラウンドの事前レンダリングと、同じTPdfインスタンス上のフォアグラウンドのUnloadPageは重なることができない。PDFiumPas v2.26.0が見つけたギャップはそれらの明白なエントリポイントにはなかった——それは、それぞれがピクセルをラスタライズするようPDFiumに求めなければ何も返せないにもかかわらず、レンダーというよりアクセサのように読める6つのメソッドの中に現れた

どの6つの呼び出しがレンダーロックを迂回していたのか

TPdf.GetObjectBitmapTPdf.GetBitmapTPdf.GetThumbnailがリストの半分を占め、TPdfView.GetObjectBitmapTPdfView.GetBitmapTPdfView.GetThumbnailが残り半分を占めた——同じ3つの操作が、同じ基礎となるページを公開する2つのコンポーネントクラスにまたがって複製されていた。この6つすべては最終的にFPDFImageObj_GetBitmapFPDFPage_GetThumbnailAsBitmapのどちらかを呼び出し、そのどちらのPDFiumエントリポイントも、すでにレンダリングされた何かへの参照を返すのではなくその場でラスタライズを行う。この6つのメソッド名のどれもレンダーとは言っていない。これは、なぜそれらが最初にRenderPageRenderTileと同じチェックリストに対して書かれなかったかについての妥当な説明である

function TPdf.GetObjectBitmap(Index: Integer): TBitmap;
var
  Bitmap: FPDF_BITMAP;
begin
  Result:= nil;
  EnterRenderLock;
  try
    Bitmap:= FPDFImageObj_GetBitmap(GetObjectHandle(Index));
  finally
    LeaveRenderLock;
  end;
  if Bitmap<> nil then
    try
      Result:= ToBitmap(Bitmap);
    finally
      FPDFBitmap_Destroy(Bitmap);
    end;
end;

なぜTPdfViewはそのロック呼び出しをnilチェックで守っているのか

TPdfViewは自身のクリティカルセクションを保有していない——その6つのロック呼び出しはすべてFPdf.EnterRenderLockFPdf.LeaveRenderLockへ転送され、まず関連するTPdf参照がnilでないことをチェックするラップの中にある。このガードが存在するのは、TPdfViewがデザイン時にフォーム上に座っていたり、ある文書が閉じられ次が開かれるまでの間の短い間、FPdfにまだTPdfが割り当てられていないことがあるからだ。このガードを省略することは、あるクラッシュを別のクラッシュと交換するだけになる。なぜなら、nil参照に対するロック呼び出しは、そのロックが防ぐために存在する競合よりも優雅に失敗するわけではないからだ

function TPdfView.GetThumbnail: TBitmap;
var
  PdfBitmap: FPDF_BITMAP;
begin
  CheckActive;
  Result:= nil;
  if FPdf<> nil then
    FPdf.EnterRenderLock;
  try
    PdfBitmap:= FPDFPage_GetThumbnailAsBitmap(Page);
  finally
    if FPdf<> nil then
      FPdf.LeaveRenderLock;
  end;
  if PdfBitmap<> nil then
    try
      Result:= ToBitmap(PdfBitmap);
    finally
      FPDFBitmap_Destroy(PdfBitmap);
    end;
end;

なぜRenderPage(HDC)は同じ監査に含まれるのか

デバイスコンテキストに対するTPdfView.RenderPageはこの6つの中には含まれない——それは1つ前のリリース、PDFiumPas v2.25.0で見つかった。そしてこのチェックリストの中に居場所を得るのは、それが同じ欠陥が別の署名を身にまとったものだからだ。そのオーバーロードはEnterRenderLockも、古いDelphiコンパイラでFPU例外を防ぐSetArithmeticMask呼び出しも伴わずにFPDF_RenderPageを直接呼び出していた。一方、同じクラスの数行下に座っているTBitmapのオーバーロードはすでにその両方を持っていた。2つの監査パスが1リリース離れて同じ失敗モードを捕まえたということは、単一のメソッドについてよりも、このバグの形についてより多くを物語る:それは、兄弟が正しく見える限り誰も読み直さないどんなオーバーロードにも隠れる

procedure TPdfView.RenderPage(DeviceContext: HDC; Left, Top, Width,
  Height: Integer; Rotation: TRotation; Options: TRenderOptions);
var
  ArithmeticMask: TArithmeticMask;
begin
  CheckActive;
  if FPdf<> nil then
    FPdf.EnterRenderLock;
  ArithmeticMask:= SetArithmeticMask;
  try
    FPDF_RenderPage(DeviceContext, FPage, Left, Top, Width, Height,
      Ord(Rotation), EncodeRenderOptions(Options));
  finally
    RestoreArithmeticMask(ArithmeticMask);
    if FPdf<> nil then
      FPdf.LeaveRenderLock;
  end;
end;

なぜこの競合はほぼ再現不可能なのか

PDFiumPasのレンダーロックのギャップは、実行のたびに失敗するわけでも、ほとんどの実行で失敗するわけでもない。なぜなら、それには2つの特定のことが同じTPdfインスタンス上で同時に起こる必要があるからだ:すでに進行中のラスタライズ呼び出しと、その同じウィンドウ内に到着する並行したUnloadPageまたはReloadPageである。シングルスレッドのテストはこの経路を一切演習せず、本当にマルチスレッドなワークロードでさえ、バックグラウンドのレンダーと文書のライフサイクルイベントが1ページの生涯の中でたまたま重なったときにだけそれを引っかける。最も現実的な引き金はキャンセル可能なフューチャーの上に構築されたバックグラウンドPDF事前レンダリングである。そこではワーカースレッドが次のページをラスタライズしている間に、UIスレッドがユーザー入力に応じて現在のページをリロードまたはアンロードする

FPDFImageObj_GetBitmapFPDFPage_GetThumbnailAsBitmapは、UnloadPageが走査の途中で自由に解放できるページオブジェクト構造を歩く。そのため実際に発火する競合は、必ずしも即座のアクセス違反を生むわけでもない。少し遅れて読み取られた構造は、ガラクタのピクセルを返すこともあれば、PDFページには一切触れなかった関数の中で、数回無関係な割り当てが行われた後になって初めてクラッシュするヒープメタデータを破損させることもある。これが、この種のバグが複数のリリースサイクルにわたってコードベースの中で生き延びられる正直な理由である:失敗時点でのスタックトレースは、実際にロックが欠けていた6行の近くを指すことがめったにない

呼び出し元にとって何が変わるのか

GetBitmapGetObjectBitmapGetThumbnail、そしてRenderPageのHDCオーバーロードは、その公開シグネチャを正確に以前のまま保つ。なぜなら、この修正は移行ではなく既存の呼び出しの周りに追加された内部的なロックだからだ。レンダーロックはTPdfインスタンスごとにスコープされており、プロセス全体にわたるグローバルなものではないことを覚えておく価値がある。そのため、2つのスレッドがそれぞれ別々にロードされた2つの文書をレンダリングする場合、それらは依然として完全に並行して実行される——ロックは、両方のスレッドがたまたま共有している1つの文書に対する操作だけを直列化する。もしあなたのロックがすでに健全で、それでもズームやスクロール時にレンダリングが遅く感じるなら、それは別の問題であり、PDFiumレンダーキャッシュとズームパフォーマンス戦略の記事で答えられている——正しさと速度はここでは別個の軸であり、この修正は前者にしか触れていない

6つのメソッドと1つの兄弟オーバーロードは、PDFiumPasが公開するPDFiumの表面積のごく一部にすぎないが、それらはデバッガの中でたまたま誰も走らせなかった負荷の下でしか誤動作しなかった部分だった。レンダーロック自体、そして今やそれがカバーするレンダリングエントリポイントの全体は、Delphi、C++Builder、Lazarus/FPC向けPDFiumコンポーネントの一部として出荷される