PDFiumでのページのレンダリングは同期的です。ライブラリを呼び出すと、渡したビットマップにラスタライズされ、ピクセルが書き込まれたときに制御が戻ります。単一のズームレベルで1つの画面サイズのページの場合、これには数ミリ秒かかり、誰も気付きません。200ページのドキュメントの300 dpiでのエクスポート、またはすべてのページを一度にラスタライズしなければならないサムネイルストリップの場合、同じ呼び出しに数秒かかります。この呼び出しをメインスレッドから行うと、メッセージループが停止し、ウィンドウの再描画が停止し、Windowsはタイトルバー上に恐ろしい「応答なし」を描画します。作業自体は正しいです。それを実行した場所が間違っているのです
解決策は、長時間のレンダリングをバックグラウンドスレッドに移し、その結果をメインスレッドに持ち帰って、そこでビットマップをコントロールに渡すことです。PDFium自体はこれを止めることはありませんが、バインディングはハンドオフを安全にしなければなりません。なぜなら、「ワーカーで実行し、UIで応答する」ことに関するバグの表面は広く、エラーは断続的に発生するからです。PDFiumPasのFPdfAsyncユニットは、そのパターンに1つの正しい実装を提供するために存在し、長時間のレンダリングが実際にどのように振る舞うかに適合したキャンセルモデルを備えています
作業の形
レンダリングがフレームより長引くケースの大半を占めるのは、3つの操作です。バッチレンダリングはページ範囲をたどり、各ページを通常はディスクにラスタライズします。マルチページのエクスポートも同じことを行いますが、出力を1つのファイルに組み立てます。バックグラウンドのページレンダリングは、ユーザーがまだキャッシュにないページにジャンプしたときにビューアが行うものであり、ビットマップはスレッド外で生成され、準備ができ次第表示されます。これら3つはすべて同じ制約を共有しています。UIスレッドがホストできないほど長く実行され、UIスレッドが最終的に必要とする結果を生成し、そしてユーザーがそれらを放棄する可能性があります。ドキュメントを閉じる、ページをスクロールして通り過ぎる、または「キャンセル」を押すといった操作は、もはや必要としない出力をユーザーに待たせるのではなく、作業を停止させるべきです
最後の制約こそが、設計を形作るものです。キャンセルできないレンダリングとは、答えが重要でなくなった後もドキュメントを開いたままにし、CPUを燃やし続けるレンダリングのことです。そのため、ユニットは構成可能な2つのプリミティブを中心に構築されています。つまり、結果を持ち帰るFutureと、キャンセル要求を前に進めるトークンです
ファイア・アンド・フォーゲットのFuture
TPdfFuture<T>.Runは、ワーカー、応答(reply)、およびオプションのキャンセルトークンを受け取ります。これはバックグラウンドスレッドでワーカーを開始し、ワーカーが完了するとメインスレッドで応答を配信します。ジェネリックパラメータのTはレンダリングが生成するものであり、多くの場合、ビットマップハンドルやステータスレコードです。ワーカーはスレッド外で実行され、応答はVCLに触れても安全な場所で実行されます
class procedure TPdfFuture<T>.Run(
const AWorker: TPdfFutureWorker<T>;
const AReply: TPdfFutureReply<T>;
const AToken: IPdfCancellationToken = nil); static;
意図的に省略されているのは、いかなる種類のWaitも持たないことです。Futureが完了するまで呼び出し元をブロックするメソッドはありませんが、これは見落としではありません。メインスレッドから呼び出されたWaitは、UIをデッドロックさせる古典的な方法です。ワーカーは応答をSynchronizeを通して実行するためにメインスレッドを必要とし、メインスレッドはWaitの内部で停止しており、どちらの側も進むことができません。このプリミティブの提供を拒否することで、Futureは、これを自分で書こうとする人々を最もよく挫折させるパターンを排除します。純粋にブロックする必要があるコードは、プレーンなTThreadを使用し、その結果に責任を持つべきです。Futureは、バックグラウンドレンダリングの実際の姿である「ファイア・アンド・フォーゲット(撃ちっ放し)」のケースのためのものです
結果はTPdfFutureResult<T>にラップされます。これは、3つのうちのどれが起こったかを応答に伝えるレコードです。IsSuccessは、ワーカーが正常に返り、Valueがレンダリングを保持していることを意味します。IsCancelledは、トークンが発火し、ワーカーがキャンセルポイントで中断したことを意味します。IsFailureは、ワーカーが例外を発生させ、ErrorMessageがそのテキストを伝えていることを意味します。応答は、返されたビットマップが本物かどうかをセンチネル値から推測するのではなく、ステータスを一度検査して分岐します
応答の配信を変更したv1.61.0の競合(レース)
このユニットの最も教訓的な部分は、理解するのにしばらく時間がかかった1行の変更です。初期のバージョンを通じて、ワーカースレッドはTThread.Queueを使って応答を配信していました。Queueは応答をメインスレッドのキューにポストしてすぐに戻りますが、これはまさにファイア・アンド・フォーゲットのFutureが望んでいるもののように読めます。しかし、それは間違っていました。その理由は説明する価値があります。なぜなら、それはあなたが書こうと思うあらゆるテストを通過してしまう類のバグだからです
ワーカースレッドはFreeOnTerminate := Trueで作成されます。つまり、Executeが戻った瞬間にスレッドは自らを解体し、TThread.Destroyはクリーンアップの一環としてRemoveQueuedEvents(Self)を呼び出します。RemoveQueuedEventsは、消滅しつつあるスレッドをターゲットとする、キューに入れられたメソッドをすべてパージします。そのため、シーケンスは次のようになっていました。ワーカーが終了し、自身に対する応答をキューに入れ、Executeが戻り、スレッドが自らを破棄し、そしてメインスレッドがまだ実行していなかった応答をRemoveQueuedEventsが削除する。結果は単に消失していました。さらに悪いことに、メインスレッドがキューに入れられた応答を引き出し、スレッドが解放されているのとまったく同じ瞬間に実行を開始するという狭いウィンドウの中で、応答は半分破壊されたオブジェクトのフィールドに触れました。これは「use-after-free(解放後使用)」です
v1.61.0での修正は、Queueの代わりにSynchronizeで応答を配信することでした。Synchronizeは、メインスレッドが応答を完了するまでワーカースレッドをブロックします。応答が実行されている間、ワーカーはまだ生きており、その下から解放されるものは何もなく、スレッドは応答が配信されるまでExecuteから戻る(したがって自らを破棄し始める)ことはありません。配信は保証され、use-after-freeのウィンドウは閉じられます
procedure TPdfFutureThread<T>.Execute;
begin
FResult.Status := pfsSuccess;
FResult.ErrorMessage := '';
try
FToken.ThrowIfCancelled; // すでにキャンセルされているか?ワーカーをスキップする
FResult.Value := FWorker(FToken);
except
on E: EPdfOperationCancelled do
begin
FResult.Status := pfsCancelled;
FResult.ErrorMessage := E.Message;
end;
on E: Exception do
begin
FResult.Status := pfsFailure;
FResult.ErrorMessage := E.Message;
end;
end;
if Assigned(FReply) then
// QueueではなくSynchronize: このスレッドはFreeOnTerminateであるため、キューに入れられた応答は
// メインスレッドが実行する前にRemoveQueuedEventsによってドロップされる可能性がある。
Synchronize(DispatchReply);
end;
この一般的な教訓は、特定の修正を超えて長持ちします。ファイア・アンド・フォーゲットの非同期コールバックは、微妙に間違えやすい最も簡単な並行処理パターンです。なぜなら、ハッピーパスは最初の試行で機能し、バグはスレッドのティアダウン(解体)順序とキューの間の相互作用の中に潜んでいるからです。それはオンデマンドでは再現しません。メインスレッドが、ワーカーがたまたま自らを破棄し終える前にキューを排水できたかどうかに依存しており、これはスケジューラーが実行ごとに異なる決定を下すタイミングです。バインディング内で一度正しく作られたプリミティブは、バックグラウンドレンダリングを必要とするすべてのアプリケーションで同じコードを再導出するよりもはるかに価値があります
コールバックがメソッドポインタである理由
ワーカーと応答は匿名メソッドではありません。それらはprocedure of object型であるTPdfFutureWorker<T>およびTPdfFutureReply<T>であり、その選択はコンパイラマトリックスによって強制されています。PDFiumPasはDelphi XE5以降、およびDelphiモードのFree Pascal 3.2でコンパイルされますが、そのモードでのFPC 3.2は匿名メソッドをサポートしていません。ローカル変数をキャプチャする「プロシージャへの参照(reference-to-procedure)」コールバックは、Delphiではコンパイルできますが、FPCでは失敗するため、ユニットは両方のコンパイラが受け入れる最小公分母を使用します
実用的な帰結は、状態がどこに存在するのかということです。匿名メソッドはローカル変数に対してクロージャーを形成しますが、メソッドポインタはそうではありません。したがって、ワーカーが必要とする任意の状態(ページインデックス、ズーム、出力パス)や、応答が更新する必要がある任意の状態(ターゲットの画像コントロールや進捗ラベル)は、渡されるメソッドを持つオブジェクトにぶら下がっている必要があります。ビューアでは、そのオブジェクトは通常、フォームまたはそれが所有するレンダリングコントローラーです。これは渋々課された回避策ではありません。クロージャー内に隠すのではなく、その状態の所有権を明示的かつ受信側オブジェクト上で見えるように保ちます
ハードキルではなく、協調的なキャンセル
ここでのキャンセルは協調的(コーペラティブ)です。ワーカースレッドに手を伸ばしてそれを強制終了させるAPIはありません。なぜなら、レンダリングの途中でスレッドを終了させると、PDFiumがロックや部分的に書き込まれたビットマップを保持したままになり、強制終了後のプロセス状態は推論できるものではなくなるからです。代わりに、ワーカーには読み取り専用のトークンが渡され、それをチェックすることが期待されます。そして、レンダリングループは、停止がクリーンに行えるページ間やタイル間でそれをチェックするように書かれます
トークンはキャンセルを観察する3つの方法を提供します。IsCancelledは、自身でテストして決定したいループのための安価なブール値のポーリングです。ThrowIfCancelledは一般的なケースです。自然なキャンセルポイントでそれを呼び出すと、キャンセルが要求されていた場合、EPdfOperationCancelledを発生させ、ワーカーをFutureにまっすぐ巻き戻します。RegisterCallbackは、ソースがキャンセルされたときに一度だけ発火するワンショットの通知をアタッチします。これは、ワーカーがタイトなループに座っているのではなく、中断可能な何かにブロックされている場合に役立ちます
例外は、スレッドの境界が重要になる部分です。ワーカーがEPdfOperationCancelledを発生させると、Futureはそれをキャッチしてキャンセル状態に変換するため、応答は失敗ではなくIsCancelledを見ます。例外オブジェクト自体がメインスレッドにマーシャリングされることはありません。それはワーカースレッド上で生きて死にます。そのメッセージ文字列だけがErrorMessageにコピーされます。生きた例外オブジェクトをスレッド間でマーシャリングすることは、終了しつつあるスレッドが所有するメモリに手を伸ばすことを意味し、これはSynchronizeの修正が防ぐために存在するのと同じクラスの間違いです。ステータスコードと文字列は境界をきれいに越えますが、オブジェクトはそうではありません
2つのインターフェースによってワーカーが自身をキャンセルできないようにする
キャンセルは意図的に2つのインターフェースに分割されています。IPdfCancellationTokenSourceは書き込み側です。これにはCancelがあり、それを作成した所有者(通常はフォーム)がそれを保持し、ユーザーがボタンをクリックしたりフォームが閉じたりしたときにCancelを呼び出します。IPdfCancellationTokenは読み取り側です。これにはIsCancelled、ThrowIfCancelled、およびRegisterCallbackがあり、ワーカーが受け取るのはこれだけです。1つの具象オブジェクトが両方を実装しますが、ワーカーには常にトークンしか渡されないため、実行中の操作をキャンセルする方法はありません。この分割はAPIレベルのガードレールです。自身のトークンを通してCancelに到達できるワーカーは、混乱したコードの断片が自身をキャンセルすることを招きますが、型システムがその可能性を排除します
呼び出し元がレンダリングを望んでいるものの、キャンセルするつもりがまったくない場合の、これに合致する詳細があります。呼び出しごとに新しいソースを強制するのではなく、ユニットはPdfNoCancellationTokenを公開しています。これは、永続的に「キャンセルされていない」状態にあるシングルトントークンです。トークン引数がnilのままの場合、Runがそれを代入します。そのシングルトンは、最初の使用時に遅延(lazy)構築されるのではなく、ユニットの初期化中に先行(eager)構築されますが、その理由は再び並行性です。異なるワーカースレッドでの複数のRun呼び出しが、遅延作成されたシングルトンに一度に手を伸ばした場合、それらは構築において競合し、重複をリークしたり、半ば初期化されたインスタンスを短時間観察したりする可能性があります。いかなるワーカーも実行できるようになる前にそれを構築することで、競合は完全に排除されます
キャンセル可能なレンダリングの実行
実際には、ソースを作成し、それをフォームに保持し、ワーカーメソッドと応答メソッドとともにそのTokenをRunに渡し、キャンセルボタンをソースに配線します。ワーカーはレンダリング中にトークンをチェックし、応答は結果が戻った後にUIを更新します。コールバックはメソッドポインタであるため、ワーカーと応答はフォームのフィールドから必要なものを読み取ります
procedure TMainForm.StartRender;
begin
FCancelSource := TPdfCancellationTokenSource.New; // フォーム上に存在するフィールド
TPdfFuture<Boolean>.Run(RenderWorker, RenderReply, FCancelSource.Token);
end;
procedure TMainForm.CancelButtonClick(Sender: TObject);
begin
if Assigned(FCancelSource) then
FCancelSource.Cancel; // ワーカーは次のキャンセルポイントでこれを観察する
end;
// バックグラウンドスレッドで実行される。フォームからFPageRange / FOutputDirを読み取る。
function TMainForm.RenderWorker(const AToken: IPdfCancellationToken): Boolean;
var
PageIndex: Integer;
begin
for PageIndex := FFirstPage to FLastPage do
begin
AToken.ThrowIfCancelled; // ページ間のクリーンな停止
RenderOnePage(PageIndex); // 同期的なPDFiumのラスタライズ
end;
Result := True;
end;
// メインスレッドで実行される。ここではVCLに触れても安全。
procedure TMainForm.RenderReply(const AResult: TPdfFutureResult<Boolean>);
begin
if AResult.IsSuccess then
StatusLabel.Caption := 'Render complete'
else if AResult.IsCancelled then
StatusLabel.Caption := 'Cancelled'
else
StatusLabel.Caption := 'Failed: ' + AResult.ErrorMessage;
end;
3つの結果すべてが到達可能であるため、応答はそれら3つすべてを処理します。完了したレンダリングは成功を報告し、「キャンセル」を押したユーザーはキャンセルされた分岐を見ます。そして、書き込めなかったファイルや解析に失敗したページは、メッセージ付きの失敗として到着します。これらの分岐のどれもブロックすることはなく、ワーカースレッドに触れることもありません。ワーカーが生成したビットマップやステータスは、FutureがそれをUIを所有するスレッドに配信した後にのみ読み取られます
同じスレッドの規律は、ビューアの他の場所でも報われます。ズーム変更全体でレンダリングされたビットマップがどのように保持され再利用されるかは、レンダーキャッシュとズームパフォーマンスに関する私たちのメモでカバーされています。また、Delphiの下でPDFiumの境界を安全に保つというより広範な問題は、メモリ安全性のためのPDFium VCL ABIの強化にあります。ここで説明されている非同期インフラストラクチャは、このブログの他の場所で取り上げているレンダリング、テキスト、およびフォームのAPIとともに、DelphiおよびC++Builder向けのPDFium Componentの一部として提供されています