PDFiumPasのレンダーロックは、文書ごとのクリティカルセクションである——TPdf上のTRTLCriticalSectionフィールドに裏付けられたEnterRenderLockとLeaveRenderLock——これはPDFiumのラスタライザへのすべての呼び出しをラップし、実行中のレンダリングの足元でページがアンロードまたはリロードされないようにするためのものだ。TPdfとTPdfViewに均等に分かれた6つのメソッドが、PDFiumのビットマップとサムネイル抽出APIを直接呼び出し、そのロックを完全に迂回していた。このギャップはPDFiumPas v2.26.0で閉じられ、この6つすべてを、他のあらゆるレンダリングのエントリポイントがすでに使っていたのと同じロックのペアで包んだ
ここで扱うギャップは、このブログの他所で扱ったcdecl呼び出し規約の不一致と、同じPDFiumバインディング内のFPC Win64ポインタ幅の切り捨てを説明したABI強化パスとは異なる。以下はより狭くより機械的なものである:PDFiumのレンダリング経路に届く6つの呼び出しサイトのロックカバレッジのチェックリスト、それぞれがなぜ見逃されやすかったか、そしてロックの欠落から生じる競合状態がなぜこのコードベースの中で要求に応じて再現するのが最も難しい欠陥の一つなのか
レンダーロックが実際に保護するもの
PDFiumPasがレンダリングを直列化するのは、PDFiumのロード済みページが、あるスレッドから読み取られている間に別のスレッドが自由にそれを解放できるのは安全ではないからだ。TPdfはFRenderLockにTRTLCriticalSectionを保有しており、コンストラクタで初期化され、FRenderLockReadyフラグによって保護される。これにより、破棄後に到着した呼び出しは、削除されたクリティカルセクションに入るのではなく静かな無操作になる。EnterRenderLockとLeaveRenderLockだけが、そのセクションへの唯一の認められた出入り口である
procedure TPdf.EnterRenderLock;
begin
if FRenderLockReady then
EnterCriticalSection(FRenderLock);
end;
procedure TPdf.LeaveRenderLock;
begin
if FRenderLockReady then
LeaveCriticalSection(FRenderLock);
end;
TPdf.RenderPage、RenderTile、RenderPageProgressiveは、この監査が始まるずっと前からその規律に従っていた。それぞれがPDFiumを呼び出す前にロックを取得し、finallyブロックでそれを解放する。そのため、バックグラウンドの事前レンダリングと、同じTPdfインスタンス上のフォアグラウンドのUnloadPageは重なることができない。PDFiumPas v2.26.0が見つけたギャップはそれらの明白なエントリポイントにはなかった——それは、それぞれがピクセルをラスタライズするようPDFiumに求めなければ何も返せないにもかかわらず、レンダーというよりアクセサのように読める6つのメソッドの中に現れた
どの6つの呼び出しがレンダーロックを迂回していたのか
TPdf.GetObjectBitmap、TPdf.GetBitmap、TPdf.GetThumbnailがリストの半分を占め、TPdfView.GetObjectBitmap、TPdfView.GetBitmap、TPdfView.GetThumbnailが残り半分を占めた——同じ3つの操作が、同じ基礎となるページを公開する2つのコンポーネントクラスにまたがって複製されていた。この6つすべては最終的にFPDFImageObj_GetBitmapかFPDFPage_GetThumbnailAsBitmapのどちらかを呼び出し、そのどちらのPDFiumエントリポイントも、すでにレンダリングされた何かへの参照を返すのではなくその場でラスタライズを行う。この6つのメソッド名のどれもレンダーとは言っていない。これは、なぜそれらが最初にRenderPageやRenderTileと同じチェックリストに対して書かれなかったかについての妥当な説明である
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.EnterRenderLockとFPdf.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_GetBitmapとFPDFPage_GetThumbnailAsBitmapは、UnloadPageが走査の途中で自由に解放できるページオブジェクト構造を歩く。そのため実際に発火する競合は、必ずしも即座のアクセス違反を生むわけでもない。少し遅れて読み取られた構造は、ガラクタのピクセルを返すこともあれば、PDFページには一切触れなかった関数の中で、数回無関係な割り当てが行われた後になって初めてクラッシュするヒープメタデータを破損させることもある。これが、この種のバグが複数のリリースサイクルにわたってコードベースの中で生き延びられる正直な理由である:失敗時点でのスタックトレースは、実際にロックが欠けていた6行の近くを指すことがめったにない
呼び出し元にとって何が変わるのか
GetBitmap、GetObjectBitmap、GetThumbnail、そしてRenderPageのHDCオーバーロードは、その公開シグネチャを正確に以前のまま保つ。なぜなら、この修正は移行ではなく既存の呼び出しの周りに追加された内部的なロックだからだ。レンダーロックはTPdfインスタンスごとにスコープされており、プロセス全体にわたるグローバルなものではないことを覚えておく価値がある。そのため、2つのスレッドがそれぞれ別々にロードされた2つの文書をレンダリングする場合、それらは依然として完全に並行して実行される——ロックは、両方のスレッドがたまたま共有している1つの文書に対する操作だけを直列化する。もしあなたのロックがすでに健全で、それでもズームやスクロール時にレンダリングが遅く感じるなら、それは別の問題であり、PDFiumレンダーキャッシュとズームパフォーマンス戦略の記事で答えられている——正しさと速度はここでは別個の軸であり、この修正は前者にしか触れていない
6つのメソッドと1つの兄弟オーバーロードは、PDFiumPasが公開するPDFiumの表面積のごく一部にすぎないが、それらはデバッガの中でたまたま誰も走らせなかった負荷の下でしか誤動作しなかった部分だった。レンダーロック自体、そして今やそれがカバーするレンダリングエントリポイントの全体は、Delphi、C++Builder、Lazarus/FPC向けPDFiumコンポーネントの一部として出荷される