パース済み PDF を写す Delphi オブジェクトモデルは、たいてい、すべてのオブジェクトの平らなリストと、そこからぶら下がるポインタグラフに落ち着きます。そのリストを歩いて各エントリに Free を呼ぶと、実行は途中で、デストラクタの中のアクセス違反で死にます。先の Free が別の親を通してすでに解放したオブジェクトの上で。ループを List.Clear に替えるとクラッシュは消えますが、プロセスは再読み込みのたびに、およそ文書1つぶん育ちます。どちらの症状も原因は1つです。リストは平らで、所有はグラフだということ。うまくいく後始末は、どのポインタが指し先を所有するかを決め、その閉包を1回集め、すべての所有エッジを切り、それから各ノードを、正確に1回ずつ解放します
登録済みオブジェクトをFreeするとクラッシュするのはなぜか
HotPDF では、平らなリストは文書のオブジェクトレジストリであり、それが保持するクラスは、誰が何を破棄するかについて意見が割れています。各デストラクタは、それ自身の条件ではもっともです。同じ所有チェーンの複数の階層のエントリを含むレジストリの上で組み合わせると、エントリごとの Free は、あるメモリを2回解放し、あるメモリを一度も解放しません
HPDFObjs.pas と HPDFDoc.pas の3つのデストラクタが、この状況を作ります。THPDFDictionaryObject.Destroy は Items を歩き、IsIndirect が False のときだけ値を解放します。間接の子はレジストリに属するという前提です。THPDFArrayObject.Destroy は、間接かどうかにかかわらず、保持するすべての項目を解放します。オブジェクトストリームから引き出されたオブジェクトのラッパーである THPDFIndirectObject(ISO 32000-1 §7.5.7)は、InternalObject の本体を解放します。ここで、間接辞書、その同じ辞書を1スロットに列挙する配列、そして本体が自分自身のルートとしても登録されているラッパーを保持するレジストリを思い浮かべてください。配列を先に解放すると、レジストリが届く前に辞書は消えています。ラッパーを解放し、それから本体を解放すると、2回目の呼び出しは、もはや誰のものでもないメモリの上でデストラクタを走らせます。辞書だけを解放すると、そこでスキップされた間接の子は、プロセスが終わるまで確保されたままです
レジストリの並べ替えでは、これは救えません。どの順序を選んでも、あるエントリの組が、所有エッジの間違った側に着地します。リストには、エントリ12がエントリ40を所有すると述べる手段がないからです
PDF オブジェクトグラフにおける所有エッジと参照
所有エッジとは、その指し先を、始点側が破棄する責任を負うポインタです。それ以外はすべて参照であり、後始末がたどるのは前者だけです。HotPDF の所有エッジは正確に4種類あります。THPDFDictionaryObject の Items、THPDFArrayObject の Items、THPDFIndirectObject の背後の InternalObject、そして THPDFStreamObject の両方のフィールド。その Dictionary と Stream ペイロードです
参照にも同じだけの配慮が要ります。参照をたどると、グラフのウォークは無限ループになるか、解放済みメモリの読み取りになります。THPDFLink はオブジェクト番号と世代を保持します。これは ISO 32000-1 §7.3.10 の間接参照そのもので、ファイルのどこか別の場所に格納されたオブジェクトの名前です。その番号をレジストリを通して解決すると、別のエッジがすでに所有しているノードに着地します。だから後始末はリンクを一切デリファレンスしません。辞書や配列が保持する FParent の逆ポインタは、逆向きに走ります。親はすでに子を所有しており、そのポインタを上ると、ウォークが通り過ぎたノードを再訪するだけです。両方の種類は、後始末の間、まるごと手つかずです
集める、切り離す、解放する:3パスの後始末
HotPDF は、プロテクトされた THotPDF.CloseIndirectObjects メソッドの内側で、文書のオブジェクトを3パスで解放します。そしてそのパスの順序こそが、トリックの全体です。パス1は収集です。ワークリストはすべてのレジストリエントリから始まり、各ノードについて、ウォークは所有エッジの指し先を追記し、すでに見たものはスキップします。既視集合は、生ポインタの線形探査によるオープンアドレス配列で、半分一杯になると倍になります。ノードごとに何も確保しないので、文書が数十万のオブジェクトを運ぶときに効いてきます。ストリームペイロードは別のリストに入ります。THPDFObject ノードではなく TStream の派生だからです
続く2つのリスティングは、コンポーネントの内部実装を簡略化した抜粋で、ライブラリ自身のオブジェクト型でパスが見えるようにしたものです。アプリケーションコードがこれを呼ぶことはありません。LoadFromFile、LoadFromStream、そしてデストラクタが、こちらの代わりに走らせます。抜粋では、アドレスハッシュはシフトとマスクに減らされ、既視集合のテーブルが満杯のときに倍にするステップは省かれています
// 内部後始末の簡略化抜粋。呼び出し可能な API ではない
procedure Collect(Value: TObject; Payload: boolean);
var
Slot: Integer;
begin
if Value = nil then Exit;
// (実際のルーチンは、テーブルが半分一杯になると倍にして再ハッシュする)
Slot := Integer(NativeUInt(Value) shr 4) and (Length(Seen) - 1);
while Seen[Slot] <> nil do
begin
if Seen[Slot] = Pointer(Value) then Exit; // すでに収集済み
Slot := (Slot + 1) and (Length(Seen) - 1);
end;
Seen[Slot] := Pointer(Value);
Inc(SeenCount);
if Payload then Streams.Add(Value) else Nodes.Add(Value);
end;
// パス1:レジストリを種にし、所有エッジだけをたどる
for I := 0 to IndirectObjects.Count - 1 do
Collect(TObject(IndirectObjects[I]), False);
I := 0;
while I < Nodes.Count do
begin
Obj := THPDFObject(Nodes[I]);
if Obj is THPDFIndirectObject then
Collect(THPDFIndirectObject(Obj).InternalObject, False)
else if Obj is THPDFStreamObject then
begin
Collect(THPDFStreamObject(Obj).Dictionary, False);
Collect(THPDFStreamObject(Obj).Stream, True);
end
else if Obj is THPDFDictionaryObject then
for J := 0 to THPDFDictionaryObject(Obj).Items.Count - 1 do
Collect(PHPDFDictionaryItem(THPDFDictionaryObject(Obj).Items[J])^.Value, False)
else if Obj is THPDFArrayObject then
for J := 0 to THPDFArrayObject(Obj).Items.Count - 1 do
Collect(TObject(THPDFArrayObject(Obj).Items[J]), False);
// THPDFLink の値と FParent のポインタは決してたどらない
Inc(I);
end;
パス2は切断です。1つのデストラクタが走る前に、すべての所有エッジが nil にされます。ラッパーは MarkAsFreed を受け取り、これが InternalObject をクリアし、デストラクタが最初に検査する IsFreed フラグを立てます。ストリームオブジェクトは Dictionary と Stream に nil が代入され、各辞書項目の Value はクリアされ、各配列スロットは nil で上書きされます。パス3がレジストリをクリアし、Nodes のすべてのノードを、それから Streams のすべてのペイロードを解放します
// 内部後始末の簡略化抜粋、続き。呼び出し可能な API ではない
// パス2:何かを解放する前に、すべての所有エッジを切り離す
for I := 0 to Nodes.Count - 1 do
begin
Obj := THPDFObject(Nodes[I]);
if Obj is THPDFIndirectObject then
THPDFIndirectObject(Obj).MarkAsFreed
else if Obj is THPDFStreamObject then
begin
THPDFStreamObject(Obj).Dictionary := nil;
THPDFStreamObject(Obj).Stream := nil;
end
else if Obj is THPDFDictionaryObject then
for J := 0 to THPDFDictionaryObject(Obj).Items.Count - 1 do
PHPDFDictionaryItem(THPDFDictionaryObject(Obj).Items[J])^.Value := nil
else if Obj is THPDFArrayObject then
for J := 0 to THPDFArrayObject(Obj).Items.Count - 1 do
THPDFArrayObject(Obj).Items[J] := nil;
end;
// パス3:一意な各ノードとペイロードを正確に1回解放する
IndirectObjects.Clear;
for I := 0 to Nodes.Count - 1 do TObject(Nodes[I]).Free;
for I := 0 to Streams.Count - 1 do TObject(Streams[I]).Free;
FreeAndNil(IndirectObjects);
切ることで、既存のデストラクタは、まったくそのままでいられます。エッジが残っていないので、各デストラクタは再帰する先を見つけられず、自分自身だけを破棄します。同じ結果への別の道は、すべてのデストラクタにレジストリを教えることです。辞書と配列とラッパーがそれぞれ、別の経路が子を解放するかどうか尋ね合うのです。これはグラフの知識を4つのクラスと、後から足されるすべてのクラスに広めます。そしてどこか1か所の間違った答えが、二重解放を持ち帰します。所有モデルの全体を1つの後始末ルーチンに保つほうが、安全な組み立てです
扱いにくいケースを通すと、どれも特別な分岐を必要としません。2つのストリームオブジェクトに共有される辞書は1回収集され、両方から切り離され、1回解放されます。配列が自分の親辞書を列挙する循環は、既視集合が2回目の訪問を拒否するので終わります。ラッパーと、ルートとして登録された本体は、集合の中では2つの別個のポインタなので、両方が解放され、ラッパーのデストラクタは本体を放置します。MarkAsFreed がすでにそのエッジを取り除いたからです。2つのストリームオブジェクトのペイロードとして代入された1つの TMemoryStream は、Streams に正確に1回だけ座ります
グラフを下ろす前に何を止めなければならないか
グラフからオブジェクトを借りるものすべては、先に止まらなければなりません。HotPDF は PrefetchLoadedPages でワーカースレッド上にページを描画でき、文書のオブジェクトからコンパイルされたビットマップとディスプレイリストをキャッシュします。グラフより長生きしたワーカーやキャッシュエントリは、解放済みメモリを読みます。だから後始末は、CancelLoadedPagePrefetch で開幕し、レジストリに触れる前に描画済みページキャッシュを無効化します。LoadFromFile、LoadFromStream、そして THotPDF のデストラクタはすべてここを通るので、文書を差し替えるときもコンポーネントを解放するときも、順序は同一です。1つの THotPDF を文書間で再利用する規則は、これに乗っています
通知の契約も同じくらい大事です。InvalidateRenderedPageCache は、PageIndex = -1 で OnLoadedDocumentModified を上げるメソッドです。ファイルを再読み込みするビューアが、旧文書の内部クリーンアップの編集通知を受け取る理由はありません。後始末の内側では、ハンドラは保存され、その呼び出しの周りで nil にされ、finally ブロックで復元されます。再読み込みはそれでも PageIndex = -1 の呼び出しを1回生みます。新しい文書のためにキャッシュをリセットするとき、ロード自身が上げるもので、ビューアが再描画すべきはこちらです。デストラクタでは、キャッシュはグラフが閉じる時点ですでに破棄されているので、後始末はそこでは無効化をまるごとスキップします
procedure TViewerForm.FormCreate(Sender: TObject);
begin
FPdf := THotPDF.Create(Self);
FPdf.OnLoadedDocumentModified := PdfModified;
end;
procedure TViewerForm.PdfModified(Sender: TObject; PageIndex: Integer);
begin
if PageIndex = -1 then
RepaintAllPages // 自分のビューアメソッド
else
RepaintPage(PageIndex); // 自分のビューアメソッド
end;
procedure TViewerForm.OpenDocument(const FileName: string);
begin
// LoadFromFile は実行中のプリフェッチを取り消し、パースの前に
// 前の文書のオブジェクトを解放する。PdfModified は -1 の呼び出しを1回見る
FPdf.LoadFromFile(FileName);
FPdf.PrefetchLoadedPages('1-10', 150);
end;
OpenDocument で明示的な CancelLoadedPagePrefetch 呼び出しは要りません。ロードがそれを行うからです。プリフェッチが走っているかもしれないうちに、ロード済み文書を変更しようとするときは、自分で呼ぶのがそれでも正しい動きです。HPDFDoc.pas の宣言コメントが、まさにそれを求めています。より大きな規模のワーカープール描画については、並列レンダリングパイプラインの記事が、バックグラウンド仕事の絞り方を扱っています
リークとアロケータの保持をどう見分けるのか
繰り返しロードにわたる生きた確保の数を、メモリマネージャのカウントで見て、プロセスのワーキングセットは無視してください。Delphi のメモリマネージャは、解放されたメモリを再利用のためにプールするので、文書を閉じた後に高いフットプリントで安定しているプロセスは、必ずしもリークしていません。再読み込みのたびに生きた大型ブロック数が登るプロセスは、リークしています。下のループは、1つのインスタンスで同じファイルを20回ロードし、各パスの後に大型ブロックの数値を印字します
procedure ReportLargeBlocks(const Tag: string);
var
State: TMemoryManagerState;
begin
GetMemoryManagerState(State);
Writeln(Tag, ': ', State.AllocatedLargeBlockCount, ' large blocks, ',
State.TotalAllocatedLargeBlockSize, ' bytes');
end;
var
Pdf: THotPDF;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
for I := 1 to 20 do
begin
Pdf.LoadFromFile('sample.pdf');
ReportLargeBlocks('pass ' + IntToStr(I));
end;
finally
Pdf.Free;
end;
ReportLargeBlocks('after Free');
end.
出力は形として読みます。最初のパスの後に落ち着く数字は、保持か、普通の文書ごとの状態です。パスごとに同じステップで登る数字はリークで、PDF ではそのステップはたいてい、大きいストリームペイロード、ページコンテンツ、埋め込み画像のサイズです。大型ブロックの数字に載るだけの大きさがあるのが、それらの確保だからです。ノイズに対してステップが見えやすいように、12ページ以上のファイルを使ってください。ページ数に比例した増加は、アロケータの保持では説明できません。文書が開いているあいだにどのオブジェクトがメモリを握っているかを知るには、保持バイト付きのオブジェクト依存グラフが、問いのその側に答えます
自分の Delphi オブジェクトモデルでパターンを再利用する
ここにあるものは PDF 固有ではありません。デストラクタが子の所有を一貫させていないオブジェクトモデル、1つの子が複数の親から届くモデル、逆ポインタが順ポインタの隣に座るモデルは、どれもオブジェクトごとの Free の下でクラッシュするかリークします。修正の形はいつも同じです。各ポインタフィールドを所有か参照かに分類し、再訪を許すポインタ集合を通して所有エッジの閉包を収集し、すべてのエッジを切り、平らなリストを破棄する。切り離しのステップが、人が飛ばしがちなものです。飛ばすと、すべてのデストラクタが、他のすべてについて知る必要ができあがります
パターンには、写す前に書き留めておく価値のある境界があります。ポインタ集合はオブジェクトのアドレスを同一性として使います。これは、収集の間にデストラクタが1つも走らないからこそ正しいのです。アドレスが解放されて、ウォークの途中で新しい確保に渡ることはありません。ウォークが見るのは、書かれた対象のエッジ種類だけです。別のフィールドを通して子を所有する新しいクラスは、収集と切断の両パスに足されなければなりません。そしてリンクはたどられず解決されるので、リンクからしか参照されないオブジェクトは、レジストリに入っていなければ届きません。HotPDF のパーサーは読んだすべてのオブジェクトを登録します。自分のコードで手組みしたグラフは、同じ規則を保たなければなりません
テストは、バイトではなくデストラクタの呼び出しを数えて行います。厄介なグラフを手で組み立てます。2つのストリームに共有される辞書、その辞書と自分の親の両方を保持する配列、両方のストリームに代入された1つのペイロード、2回登録されたルート、そして数値の本体まで登録されたラッパー。後始末を走らせ、一意なオブジェクトごとに正確に1回の破棄を期待します。ペイロード1つ、ストリーム2つ、辞書2つ、配列1つ、ラッパー1つ、番号1つです。自分のクラスでは、各デストラクタで上げるクラスレベルのカウンタ(自分で足す計測フック)がこれに足り、リストをクリアするだけの後始末はゼロを報告します
HotPDF では、このすべてが LoadFromFile、LoadFromStream、そしてデストラクタの背後に座るので、アプリケーションコードが見るのは結果だけです。文書を閉じる、あるいは再読み込みすると、メモリが返ってきます。HotPDF は完全なソース付きで出荷される、Delphi と C++Builder 向けのネイティブ VCL PDF ライブラリです。ここで述べた後始末は、HPDFDoc.pas の中を1行ずつ読めます。API リファレンスと試用ビルドはHotPDF PDF component for Delphi のページにあります