技術記事

DelphiでPDFオブジェクトグラフをちょうど1回解放する:HotPDF

HotPDF Delphi Componentは、文書が閉じられたり再読み込みされたりするときに、その文書が所有するすべてのPDFオブジェクトを解放します。THotPDF.CloseIndirectObjectsはオブジェクトレジストリをたどり、所有エッジをそれぞれポインタ集合に集め、それらのエッジをすべて切り離し、その後ではじめて、一意な各ノードと各ストリームペイロードをちょうど1回ずつ解放します。この3段階の順序があるからこそ、共有された子、所有関係の循環、重複登録、ラッパーと本体のエイリアスが、二重解放も取りこぼしもなく片付きます。v2.752.4より前、同じルーチンはもっと単純で、もっとひどいことをしていました。遅延ファイルストリームのソースを解放し、IndirectObjectsリストに対してClearを呼び、リストのコンテナを解放し、実際のPDFオブジェクトはすべてプロセス終了時の回収に任せていました。そのコードのコメントも、それを正直に認めていました。オブジェクトを個別に解放するとアクセス違反が起きるので、「安全な方法」は一切解放しないことだ、と。本記事は、なぜ個別解放が本当にクラッシュしたのか、そして手動メモリ管理の言語でまともに動く後始末とはどんな形なのかを扱います

登録されたオブジェクトをただFreeすればよいというわけにいかない理由

オブジェクトクラスのデストラクタが、誰が何を所有するかについて意見を異にしており、レジストリには同じ所有チェーンの複数の階層のエントリが入っているからです。したがってリストをたどって各エントリにFreeを呼ぶと、どのクラスがたまたま隣り合っているか次第で、二重に解放されるメモリと、一度も解放されないメモリが生まれます

問題を生むのは、HPDFObjs.pasとHPDFDoc.pasにある3つの非対称性です。THPDFDictionaryObject.DestroyはItemsをたどり、IsIndirectがFalseのときだけ値を解放します。間接の子はレジストリに属し、そこで解放されるという前提です。THPDFArrayObject.Destroyはそんな区別をせず、保持しているすべての項目を解放します。そして、オブジェクト番号を持つラッパーであるTHPDFIndirectObject.Destroyは、そのInternalObject本体を解放します。ここで、間接辞書と、その同じ辞書をスロットの1つに列挙している配列と、本体が別のルートとしても登録されているラッパーを保持するレジストリを考えてみてください。実ファイルに対してパーサが作るのはまさにこの形です。先に配列を解放すると、レジストリがたどり着く前に辞書が消えます。ラッパーと本体をどちらの順で解放しても、2回目の呼び出しはぶら下がりポインタに対してデストラクタを走らせます。辞書だけを解放すると、そこで飛ばされた間接の子は永久に確保されたままです。レジストリの順序をどう変えてもこれは直りません。レジストリはフラットなリストであり、所有関係はグラフだからです。そしてグラフとして考えることだけが出口です

HotPDFのレジストリエントリをすべて解放するとクラッシュした理由。THPDFDictionaryObject.Destroyは間接の子を飛ばし、THPDFArrayObject.Destroyは保持するすべてを解放し、THPDFIndirectObject.DestroyはInternalObject本体を解放します。そのためラッパー、配列、共有辞書が1つのフラットなIndirectObjectsリストにあると、二重に死ぬメモリと一度も死なないメモリが生まれます
デストラクタは誰が何を所有するかで意見が食い違い、レジストリは同じ所有チェーンの複数階層のエントリを抱えています。ですからフラットなリストの順序をどう並べても、素朴なオブジェクト単位のFreeを正しい後始末にはできません

PDFのオブジェクトグラフで所有エッジと数えられるものは何か

所有エッジとは、その指し先を始点側が破棄する責任を持つポインタです。参照はそれ以外のすべてであり、後始末は前者をたどり、後者は無視しなければなりません。HotPDFではこれがちょうど4種類のエッジになります。THPDFDictionaryObjectのItems、THPDFArrayObjectのItems、THPDFIndirectObjectの後ろにあるInternalObject、そしてTHPDFStreamObjectの両半分、つまりそのDictionaryとStreamペイロードです。参照の種類も同じくらい重要です。参照をたどると、グラフの走査は無限ループか解放後使用に変わってしまいます。THPDFLinkはオブジェクト番号と世代を保持します。これがISO 32000-1 §7.3.10の間接参照の定義です。別の場所に住むオブジェクトの名前であり、オブジェクトそのものではありません。その番号をレジストリを通して解決すると、すでに他のエッジが所有しているノードが出てきます。ですからCloseIndirectObjectsはリンクを一切デリファレンスしません。辞書と配列が保持するFParentの逆ポインタも、向きが逆なだけで同じ話です。親はすでに子を所有しているので、上向きにポインタをたどっても、走査がすでに通ったノードを再訪するだけです。どちらも手を付けません。ソースのコメントも1行でそう述べています。リンクと親ポインタは参照であり、所有エッジではありません

HotPDFのオブジェクトグラフにおける所有エッジと参照の違い。DictionaryObjectのItems、ArrayObjectのItems、IndirectObjectのInternalObject、StreamObjectの両半分はたどられて切り離されますが、THPDFLinkのオブジェクト番号とFParentの逆ポインタは別の場所に住むオブジェクトの名前であり、CloseIndirectObjectsはそれらをデリファレンスしません
所有エッジとは、その指し先を始点側が破棄しなければならないポインタです。代わりに参照をたどると、幅優先の走査は無限ループか解放後使用になってしまいます。だからリンクと親ポインタは手を付けません

3段階の後始末はどう動くのか

第1段階は幅優先の収集です。ルーチンはIndirectObjectsのすべてのエントリでワークリストを種付けし、各ノードについて、そのノードの所有エッジの指し先を追加し、すでに見たものは飛ばします。既視集合は生ポインタのオープンアドレス配列で、ポインタ値に対してHPDFFastCacheHashInt64でハッシュし、線形探査を使い、半分埋まったところでGrowSeenが倍に拡張します。この構造はノードごとに何も確保しません。文書が数十万のオブジェクトを抱えるときには、これが効いてきます。ストリームペイロードは別のStreamsリストに入ります。THPDFObjectノードではなくTStreamの派生クラスであり、専用のパスで解放されるからです

HotPDFのCloseIndirectObjectsの3段階の後始末。幅優先の収集がIndirectObjectsからワークリストを種付けし、HPDFFastCacheHashInt64でハッシュするオープンアドレスの既視集合を通して所有エッジだけをたどります。第2段階はMarkAsFreedとnil代入ですべてのエッジを切り離し、第3段階が各ノードとストリームペイロードをちょうど1回解放します
どのデストラクタが走るよりも前にエッジを切ることが、既存のデストラクタを安全に再利用できるようにします。それぞれが再帰する先を何も見つけなくなるので、共有された子、循環、ラッパーと本体のエイリアスが二重解放なしに片付きます
procedure Collect(Value: TObject; Payload: boolean);
var
  Slot: Integer;
begin
  if Value = nil then Exit;
  if (SeenCount + 1) * 2 >= Length(Seen) then GrowSeen;
  Slot := PointerSlot(Pointer(Value), Length(Seen));
  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);
  Inc(I);
end;

第2段階がデストラクタを安全に走らせる部分です。どのデストラクタが実行されるよりも前に、すべての所有エッジがnilに設定されます。ラッパーにはMarkAsFreedが入り、これがFInternalObjectをクリアし、デストラクタが最初に確認するフラグを立てます。ストリームオブジェクトはDictionaryとStreamにnilを代入されます。各辞書項目はItem^.Valueがクリアされ、各配列スロットはnilで上書きされます。このパスの後、グラフにはエッジが1本も残っていません。ですから第3段階がNodesのすべてのノード、続いてStreamsのすべてのペイロードにFreeを呼ぶと、各デストラクタは再帰する先を何も見つけず、自分自身だけを破棄します

// 第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);

この分割が何をもたらすかを見てください。2つのストリームオブジェクトで共有される辞書は1回収集され、両方から切り離され、1回解放されます。配列が自分の親辞書を列挙している循環は、既視集合が2回目の訪問を拒否するので終了します。ラッパーと本体がどちらもルートとして登録されている場合、集合の中では2つの別個のポインタなので、両方が解放され、ラッパーのデストラクタはもはや本体を解放しようとしません。MarkAsFreedがすでにそのエッジを取り去っているからです。2つのストリームオブジェクトのペイロードとして代入された単一のTMemoryStreamは、Streamsの中にちょうど1つだけ入ります。これらのケースはいずれも特別扱いを必要としません。これがモデルが正しいというしるしです

リークとアロケータの保持をどう見分けるのか

メモリマネージャの予約フットプリントだけでなく、生きた確保数が作業量に応じて動くかどうかで見分けます。Delphiのメモリマネージャは解放された大きなブロックを再利用のために取っておくので、文書を閉じた後に400 MiBのままのプロセスがリークしているとはかぎりません。1回の実行でページごとに生きたブロック数が1つずつ増えていくプロセスは、リークしています。この修正を突き動かしたプローブは意図的に小さなものでした。THotPDFのライター1つが1ページを生成し、次に3つのリーダーがそれを読み込みます。4つすべてを解放した後のヒープレポートには、512 KiBの生きた確保がちょうど4つ、インスタンスごとに1つありました。それぞれが所有し、一度も解放しなかったコンテンツストリームのペイロードです。規模を大きくすると、同じパターンが紛れもなく現れました。並列レンダリングパイプラインを2回走らせると、大きなブロックの確保量が384 MiBから640 MiBへ移りました。ページ数に比例した増加であり、アロケータの保持では説明できません。書き直した後は、インスタンスが消えた時点で、1ページの診断が大きなブロックの確保量ゼロ、予約量ゼロを報告しました。自分のプロセスで同じ種類の増加を追いかけているなら、保持バイト付きのオブジェクト依存グラフが、文書が開いている間にどのオブジェクトがメモリを握っているかを教えてくれます。本記事が扱うのは、文書を閉じるときのそれらの解放の挙動です

メモリのしきい値は壊れやすいリグレッションテストになるので、出荷しているテストは代わりにデストラクタの呼び出し回数を数えます。フィクスチャは病的なグラフを手で組み立てます。2つのストリームの下にある共有辞書、共有辞書と自分自身のルートの両方を含む配列、両方のストリームに代入された1つのペイロード、2回登録されたルート、そして本体が別途登録されたラッパーです。そのうえで文書を解放し、一意なオブジェクトごとに破棄が1回であることをアサートします。ペイロード1つ、ストリーム2つ、辞書2つ、配列1つ、ラッパー1つ、番号1つです。古いコードでは、3つの寿命テストすべてが破棄回数ゼロを報告しました。「プロセス終了時に任せる」が何を意味するかを、これ以上なく直接示す結果です

グラフを片付ける前に何が起きなければならないか

グラフからオブジェクトを借りているバックグラウンド処理は先に止めなければならず、それらのオブジェクトからコンパイルされたディスプレイリストやビットマップを保持するキャッシュは捨てなければなりません。さもないと、ワーカースレッドやキャッシュされた参照が解放済みメモリを読みます。そのためCloseIndirectObjectsはCancelLoadedPagePrefetchで始まり、レジストリに触れる前にレンダリング済みページキャッシュを無効化します。LoadFromFileとLoadFromStreamの再読み込み経路、そしてコンポーネントのデストラクタはどちらもここを通ります。ですから文書を差し替える場合でもインスタンスを破棄する場合でも同じ順序が当てはまります。1つのTHotPDFを複数の文書で使い回すためのルールは、この保証に乗っています。この前置きのうち2つの細部は、テストを走らせてはじめて見えてきました。1つ目は、デストラクタがグラフを閉じる時点で、すでにレンダリングとディスプレイリストのキャッシュの背後にある頻度スケッチを破棄していることです。ですから無効化は無条件に呼ぶのではなく、それらのフィールドが非nilであることを条件にガードします。2つ目は、InvalidateRenderedPageCacheがページインデックス-1でOnLoadedDocumentModifiedを発火させるルーチンであることです。ファイルを再読み込みする呼び出し側は、古い文書の内部的な後始末について編集通知を受け取るべきではありません。ハンドラは退避し、呼び出しの前後でnilにし、finallyで復元します。そして再読み込みのリグレッションは、2回目のLoadFromStreamの後に通知回数がゼロであることをアサートします。メモリの修正がイベントの契約を黙って変えるのは、宣伝のうまいリグレッションです。だからそれにも専用のアサーションを付けます。並列レンダリングパイプラインを文書に対して走らせ、その後に再読み込みするなら、ワーカープールが後始末と競合しないようにするのがこのキャンセルの段階です

このパターンを自分のDelphiコードで使う

この手法はPDFに固有のものではありません。デストラクタが子の所有の仕方を一貫させていない、同じ子に複数の親から到達できる、あるいは逆ポインタと順ポインタが共存しているDelphiのオブジェクトモデルは、素朴なオブジェクト単位のFreeではクラッシュするかリークします。修正はいつも同じ形です。どのポインタフィールドが所有でどれが参照かを決め、再訪を許容するポインタ集合を通して所有エッジの閉包を集め、すべてのエッジを切り、それからフラットなリストを破棄します。飛ばされがちなのが切る段階であり、モデル内のすべてのクラスを書き直す代わりに、既存のデストラクタを安全に再利用できるようにするのもこの段階です。とはいえ、境界は率直に述べておく価値があります。ポインタ集合はオブジェクトのアドレスを同一性として使うので、すでに解放され、そのアドレスが新しい確保に再利用されたオブジェクトは区別できません。収集の間にデストラクタが一切走らないという順序が、それを排除しています。走査が見るのは、それが知っている4種類のエッジだけです。ですから走査が調べないフィールドを通して子を所有する新しいクラスは、走査がそのことを教わるまで、その子をリークします。またリンクはたどられるのではなくレジストリを通して解決されるので、リンクからのみ参照され一度も登録されていないオブジェクトには、この後始末はまったく到達できません。HotPDFではパーサが登録を保証していますが、手で組んだグラフは同じルールを守らなければなりません

これらはすべてコンポーネントの内部にあるので、アプリケーションから見える効果は、文書を閉じたり再読み込みしたりするとそのメモリが返ってくるというだけであり、APIの変更はありません。HotPDFはDelphiとC++Builder向けのネイティブVCL PDFライブラリで、完全なソース付きです。APIリファレンスと体験版はHotPDF Delphi PDFコンポーネントのページにあります