HotPDF Delphi Component 會在文件關閉或重新載入時,釋放該文件擁有的每一個 PDF 物件:THotPDF.CloseIndirectObjects 走訪物件註冊表,把每一條擁有邊收集進一個指標集合,把所有這些邊切斷,然後才把每個唯一節點與每份串流 payload 精準釋放一次。正是這個三階段順序,讓共用的子物件、擁有關係的環、重複註冊,以及 wrapper 與 body 的別名,全都能收掉,既不重複釋放也不留下任何東西。在 v2.752.4 之前,同一個常式做的事簡單得多、也糟得多:它釋放延遲的檔案串流來源、對 IndirectObjects 清單呼叫 Clear、釋放清單容器,然後把每一個真正的 PDF 物件都留給行程結束去回收。那段程式碼裡的註解對此也很誠實。逐一釋放物件會導致存取違規,所以「安全的做法」就是完全不釋放。本文要談的是為什麼逐一釋放真的會當掉,以及在一個手動記憶體管理的語言裡,一個行得通的拆除長什麼樣子
為什麼不能對每個註冊物件直接 Free
因為這些物件類別的解構函式對「誰擁有什麼」的看法彼此不一致,而註冊表裡的項目落在同一條擁有鏈的好幾個層級上。所以逐一走過清單、對每個項目呼叫 Free,會讓某些記憶體被釋放兩次、某些永遠不被釋放,取決於哪些類別剛好排在一起
HPDFObjs.pas 與 HPDFDoc.pas 裡有三個不對稱造成了這個問題。THPDFDictionaryObject.Destroy 走過它的 Items,只在 IsIndirect 為 False 時釋放某個值,前提假設是間接子物件屬於註冊表、會在那裡被釋放。THPDFArrayObject.Destroy 不做這種區分,它擁有的每一項都釋放。而 THPDFIndirectObject.Destroy,也就是那個帶著物件編號的 wrapper,會釋放它的 InternalObject body。現在設想一個註冊表裡有一個間接字典、一個在其中一個槽位列出同一個字典的陣列,以及一個 body 也被另外註冊為根的 wrapper,這正是剖析器在真實檔案上產出的樣子。先釋放陣列,字典就會在註冊表走到它之前消失。釋放 wrapper 與 body,不管順序如何,第二次呼叫就是在一個懸空指標上執行解構函式。只釋放字典,它略過的任何間接子物件就永遠留在配置裡。註冊表的任何排序都修不好這件事,因為註冊表是一份平坦的清單,而擁有關係是一張圖,唯一出路就是對那張圖做推理
在 PDF 物件圖裡,什麼算是一條擁有邊
擁有邊是指標,其目標由來源負責銷毀;參照則指其他任何東西,而拆除必須沿著前者走、無視後者。在 HotPDF 裡這正好給出四種邊:THPDFDictionaryObject 的 Items、THPDFArrayObject 的 Items、THPDFIndirectObject 背後的 InternalObject,以及 THPDFStreamObject 的兩半,它的 Dictionary 與它的 Stream payload。參照的種類同樣要緊,因為沿著它走會把一次圖走訪變成無窮迴圈或 use-after-free。THPDFLink 帶著物件編號與 generation,而 ISO 32000-1 §7.3.10 就是這樣定義間接參照的:它是住在別處的物件的名字,不是物件本身。透過註冊表把那個編號解析出來,得到的是某條其他邊早就擁有的節點,所以 CloseIndirectObjects 完全不去解參照連結。字典與陣列保留的 FParent 反向指標,則是同一件事的另一個方向;父物件已經擁有子物件,所以沿著指標往上走只會重新拜訪走訪已經過去的節點。兩者都不去碰,而原始碼裡的註解用一行講完:連結與父指標是參照,不是擁有邊
三階段拆除怎麼運作
第一階段是廣度優先的收集。這個常式用 IndirectObjects 的每一個項目為工作清單播種,然後對每個節點附加它那些擁有邊的目標,跳過已經看過的。已見集合是一個開放定址的原始指標陣列,以 HPDFFastCacheHashInt64 對指標值取雜湊,採線性探測,並在裝到一半時用 GrowSeen 加倍。那個結構裡沒有任何東西是逐節點配置的,這在一份文件帶著幾十萬個物件時很重要。串流 payload 進到另一份 Streams 清單,因為它們是 TStream 後裔而不是 THPDFObject 節點,並且在屬於自己的那一輪被釋放
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;
// 第一階段:以註冊表播種,然後只沿著擁有邊走
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;
第二階段是讓解構函式可以安全執行的部分:在任何解構函式執行之前,每一條擁有邊都先設成 nil。wrapper 會拿到 MarkAsFreed,它清掉 FInternalObject 並設起解構函式會先檢查的那個旗標。串流物件把 Dictionary 與 Stream 指定為 nil。每個字典項目把 Item^.Value 清掉,每個陣列槽位則被覆寫成 nil。這一輪之後圖上不再有任何邊,所以當第三階段對 Nodes 裡每個節點、接著對 Streams 裡每份 payload 呼叫 Free 時,每個解構函式都找不到東西可遞迴,只銷毀它自己
// 第二階段:在釋放任何東西之前先切斷每一條擁有邊
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;
// 第三階段:每個唯一節點與 payload 各釋放恰好一次
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);
看看這個切分買到了什麼。一個被兩個串流物件共用的字典只被收集一次、從兩邊切斷、釋放一次。一個陣列列出自己父字典的環會終止,因為已見集合拒絕第二次拜訪。一個 wrapper 與它的 body 都被註冊為根時,它們是集合裡兩個不同的指標,所以都會被釋放,而 wrapper 的解構函式不再試著釋放 body,因為 MarkAsFreed 已經把那條邊拿掉了。一個被指定為兩個串流物件 payload 的 TMemoryStream,在 Streams 裡恰好出現一次。這些情況沒有一個需要特別處理,而這正是模型對了的表徵
如何分辨洩漏與配置器保留
靠檢查記憶體管理員的存活配置數是否隨工作量變動,而不是只看它保留的足跡。Delphi 的記憶體管理員會把釋放掉的大區塊留著重複使用,所以一個關掉文件之後停在 400 MiB 的行程未必洩漏了;一個存活區塊數每跑一輪、每頁就往上爬一格的行程才是。推動這個修法的探針刻意做得很小:一個 THotPDF writer 產生單一頁面,然後三個 reader 載入它。四個實例都釋放之後,heap 報告顯示恰好四個存活的 512 KiB 配置,每個實例一個,也就是每一個擁有卻從未釋放的內容串流 payload。把規模放大之後,同一個模式清楚得無可否認。把平行渲染管線跑兩次,大區塊配置量從 384 MiB 移到 640 MiB,這個增量與頁數成正比,不是配置器保留能解釋的。改寫之後,單頁診斷在實例消失後回報大區塊配置量為零、保留量也為零。如果您在自己行程裡追同一種成長,帶保留位元組的物件相依圖會告訴您文件開啟期間是哪些物件握著記憶體;本文談的是文件關閉時這些物件的釋放行為
記憶體門檻會讓回歸測試變得脆弱,所以出貨的測試改為計算解構函式呼叫次數。測試資料手工建出病態的圖:兩個串流底下有一個共用字典、一個同時含著該共用字典與自己根的陣列、一份同時指定給兩個串流的 payload、被註冊兩次的根,以及一個 body 被另外註冊的 wrapper;然後釋放文件並斷言每個唯一物件各被銷毀一次:一份 payload、兩個串流、兩個字典、一個陣列、一個 wrapper、一個數字。在舊程式碼下,三個生命週期測試全都回報零次銷毀,這是對「留給行程結束」最直接的陳述
在圖被收掉之前必須先發生什麼
任何從圖裡借用物件的背景工作都必須先停下來,而任何握著由那些物件編譯出來的顯示清單或點陣圖的快取,都必須先丟掉,否則工作執行緒或快取的參照就會讀到已釋放的記憶體。所以 CloseIndirectObjects 開頭就是 CancelLoadedPagePrefetch,接著在碰註冊表之前先讓渲染頁面快取失效。LoadFromFile 與 LoadFromStream 裡的重新載入路徑,以及元件解構函式,都經由它,所以不管您是在替換文件還是處置這個實例,同樣的順序都適用;讓同一個 THotPDF 跨文件重複使用的規則正是靠這份保證。那段開場裡有兩個細節只有在實際跑測試之後才浮出來。第一,解構函式在收掉圖的時候,早就已經處置掉渲染與顯示清單快取背後的頻率草圖,所以那段失效處理會加守衛檢查那些欄位非 nil,而不是無條件呼叫。第二,InvalidateRenderedPageCache 正是會以頁索引 -1 觸發 OnLoadedDocumentModified 的常式,而重新載入檔案的呼叫端不該收到關於舊文件內部拆除的編輯通知。處理器會被存起來、在呼叫前後設成 nil、並在 finally 裡還原,而重新載入的回歸測試斷言第二次 LoadFromStream 之後通知次數為零。一個悄悄改變事件契約的記憶體修法,是公關比較好的回歸缺陷,所以它有自己專屬的斷言。如果您對一份文件跑平行渲染管線然後重新載入它,取消那一步就是讓工作池不會與拆除賽跑的東西
在自己的 Delphi 程式碼裡重用這個模式
這套技巧並不專屬於 PDF。任何 Delphi 物件模型,只要解構函式對子物件的擁有關係不一致、同一個子物件可以從多個父物件到達,或反向指標與正向指標並存,在天真的逐一 Free 之下就會當掉或洩漏。修法的形狀永遠一樣:先決定哪些指標欄位是擁有的、哪些是參照,透過一個容許重複拜訪的指標集合收集擁有邊的閉包,切斷每一條邊,然後銷毀那份平坦清單。切斷那一步是大家最常跳過的,而它正是讓既有解構函式可以安全重用、而不必被迫重寫模型中每個類別的那一步。不過這些邊界值得直說。指標集合以物件位址當身分,所以一個已經被釋放、而位址又被新配置重用的物件將無法分辨;順序保證了收集期間沒有任何解構函式執行,而這正是排除那種情況的東西。走訪只看得到它知道的那四種邊,所以一個新類別若透過走訪不檢查的欄位擁有子物件,那個子物件就會洩漏,直到走訪也被教會認識它。而因為連結是透過註冊表解析而不是被沿著走,一個只被連結參照、從未被註冊的物件,這個拆除根本到不了;在 HotPDF 裡剖析器保證會註冊,但手工建出的圖必須遵守同樣的規則
這些全都藏在元件內部,所以對應用程式而言可見的效果就只是:關閉或重新載入文件會把記憶體還回來,API 沒有任何改變。HotPDF 是一套給 Delphi 與 C++Builder 的原生 VCL PDF 程式庫,附完整原始碼;API 參考與試用版在 HotPDF Delphi PDF 元件頁