บทความเทคนิค

ปลดปล่อย object graph ของ PDF ครั้งเดียวบน Delphi: HotPDF

HotPDF Delphi Component ปลดปล่อย PDF object ทุกตัวที่เอกสารเป็นเจ้าของเมื่อเอกสารถูกปิดหรือโหลดใหม่: THotPDF.CloseIndirectObjects เดินไปตาม registry ของ object เก็บ owning edge แต่ละเส้นลงใน pointer set ตัด edge เหล่านั้นทั้งหมดออก และเพิ่งจากนั้นจึง free node ที่ไม่ซ้ำกันแต่ละตัวกับ payload ของ stream แต่ละก้อนพอดีครั้งเดียว ลำดับสามเฟสนี้คือสิ่งที่ทำให้ child ที่ใช้ร่วมกัน, ownership cycle, การลงทะเบียนซ้ำ และ alias ระหว่าง wrapper กับ body ลงมาจนหมดได้โดยไม่มี double free และไม่เหลืออะไรค้างไว้ ก่อน v2.752.4 routine เดียวกันทำอะไรที่ง่ายกว่ามากและแย่กว่ามาก: มันปลดปล่อย lazy file stream source เรียก Clear บน list IndirectObjects free container ของ list แล้วทิ้ง PDF object จริงทุกตัวให้ตอนโปรเซสจบไปเก็บคืน คอมเมนต์ในโค้ดตอนนั้นก็ซื่อสัตย์เรื่องนี้ด้วย การ free object ทีละตัวทำให้เกิด access violation วิธีที่ปลอดภัยจึงเป็นการไม่ free มันเลย บทความนี้ว่าด้วยว่าทำไมวิธีทีละตัวถึง crash จริง และ teardown ที่ใช้ได้จริงหน้าตาเป็นอย่างไรในภาษาที่จัดการหน่วยความจำเอง

ทำไม free object ทุกตัวที่ลงทะเบียนไว้ไม่ได้

เพราะ destructor ของคลาส object เหล่านั้นไม่ตรงกันเรื่องใครเป็นเจ้าของอะไร และ registry มี entry อยู่หลายระดับของห่วงโซ่ความเป็นเจ้าของเดียวกัน การเดินตาม list แล้วเรียก Free กับทุก entry จึง free หน่วยความจำบางก้อนสองครั้งและบางก้อนไม่เคยเลย ขึ้นกับว่าคลาสไหนบังเอิญอยู่ติดกัน

ความไม่สมมาตรสามอย่างใน HPDFObjs.pas กับ HPDFDoc.pas สร้างปัญหานี้ขึ้นมา THPDFDictionaryObject.Destroy เดินตาม Items ของมันและ free ค่าก็ต่อเมื่อ IsIndirect เป็น False โดยสมมติว่า child ที่เป็น indirect เป็นของ registry และจะถูก free ที่นั่น THPDFArrayObject.Destroy ไม่แยกแบบนั้นและ free ทุก item ที่มันถืออยู่ และ THPDFIndirectObject.Destroy ซึ่งเป็น wrapper ที่พาหมายเลข object ก็ free body InternalObject ของมัน ทีนี้ลองนึกถึง registry ที่ถือ indirect dictionary ตัวหนึ่ง, array ที่ลิสต์ dictionary ตัวนั้นในช่องหนึ่งของมัน และ wrapper ที่ body ของมันถูกลงทะเบียนเป็น root แยกอีกตัว ซึ่งก็คือสิ่งที่ parser ผลิตออกมาบนไฟล์จริงพอดี free array ก่อน dictionary ก็หายไปก่อนที่ registry จะไปถึงมัน free wrapper กับ body ไม่ว่าจะลำดับไหน การเรียกครั้งที่สองก็รัน destructor บน pointer ที่ dangling แล้ว free dictionary ตัวเดียว child ที่เป็น indirect ซึ่งมันข้ามไปก็ค้างอยู่ตลอดไป ไม่มีลำดับของ registry ไหนแก้เรื่องนี้ได้ เพราะ registry เป็น list แบน แต่ความสัมพันธ์ความเป็นเจ้าของเป็น graph และการคิดบน graph คือทางออกเดียว

แผนภาพว่าทำไมการ free ทุก entry ใน registry ของ HotPDF ถึง crash: THPDFDictionaryObject.Destroy ข้าม child ที่เป็น indirect ขณะที่ THPDFArrayObject.Destroy free ทุกอย่างที่ถืออยู่ และ THPDFIndirectObject.Destroy free body InternalObject ของมัน ดังนั้นเมื่อมี wrapper, array และ dictionary ที่ใช้ร่วมกันอยู่ใน list IndirectObjects แบนเดียวกัน หน่วยความจำบางก้อนจึงตายสองครั้งและบางก้อนไม่เคยตาย
destructor ไม่ตรงกันเรื่องใครเป็นเจ้าของอะไร และ registry มี entry อยู่หลายระดับของห่วงโซ่ความเป็นเจ้าของเดียวกัน ไม่มีลำดับของ list แบนใดเปลี่ยนการ free ทีละตัวแบบ naive ให้กลายเป็นการรื้อที่ถูกต้องได้

อะไรนับเป็น owning edge ใน object graph ของ PDF

owning edge คือ pointer ที่ target ของมันเป็นความรับผิดชอบในการทำลายของ source ส่วน reference คืออย่างอื่นทั้งหมด และ teardown ต้องเดินตามแบบแรกและมองข้ามแบบที่สอง ใน HotPDF นั่นให้ edge สี่แบบพอดี: Items ของ THPDFDictionaryObject, Items ของ THPDFArrayObject, InternalObject ที่อยู่หลัง THPDFIndirectObject และทั้งสองครึ่งของ THPDFStreamObject คือ Dictionary กับ payload Stream ของมัน ชนิดที่เป็น reference สำคัญพอ ๆ กัน เพราะการเดินตามมันจะเปลี่ยนการเดิน graph ให้เป็น loop ไม่รู้จบหรือ use-after-free THPDFLink ถือหมายเลข object กับ generation ซึ่งเป็นวิธีที่ ISO 32000-1 §7.3.10 นิยาม indirect reference: ชื่อสำหรับ object ที่อยู่ที่อื่น ไม่ใช่ตัว object เอง การ resolve หมายเลขนั้นผ่าน registry ให้ node ซึ่งมี edge อื่นเป็นเจ้าของอยู่แล้ว CloseIndirectObjects จึงไม่ dereference link เลย FParent back-pointer ที่ dictionary กับ array เก็บไว้เป็นเรื่องเดียวกันในทิศกลับกัน: parent เป็นเจ้าของ child อยู่แล้ว การเดินตาม pointer ขึ้นไปจึงแค่กลับไปเยี่ยม node ที่การเดินผ่านมาแล้ว ทั้งสองอย่างถูกปล่อยไว้ และคอมเมนต์ใน source ก็บอกไว้บรรทัดเดียว: link กับ parent pointer เป็น reference ไม่ใช่ ownership edge

แผนภาพ owning edge กับ reference ใน object graph ของ HotPDF: Items ของ DictionaryObject, Items ของ ArrayObject, InternalObject ของ IndirectObject และสองครึ่งของ StreamObject ถูกเดินตามและตัดออก ส่วนหมายเลข object ของ THPDFLink กับ back-pointer FParent เป็นชื่อสำหรับ object ที่อยู่ที่อื่น CloseIndirectObjects จึงไม่ dereference มันเลย
owning edge คือ pointer ที่ target ของมันต้องถูกทำลายโดย source การเดินตาม reference แทนจะเปลี่ยนการเดินแบบ breadth-first ให้เป็น loop ไม่รู้จบหรือ use-after-free link กับ parent pointer จึงถูกปล่อยไว้

teardown สามเฟสทำงานอย่างไร

เฟสแรกคือการเก็บแบบ breadth-first routine เริ่ม worklist ด้วยทุก entry ของ IndirectObjects จากนั้นสำหรับแต่ละ node มันต่อ target ของ owning edge ของ node นั้นเข้าไป โดยข้ามอะไรที่เห็นแล้ว seen-set เป็น array แบบ open addressing ของ raw pointer ที่ hash ด้วย HPDFFastCacheHashInt64 จากค่า pointer พร้อม linear probing และ GrowSeen ที่ขยายเป็นสองเท่าเมื่อเต็มครึ่งหนึ่ง ไม่มีอะไรในโครงสร้างนั้น allocate ต่อ node ซึ่งสำคัญเมื่อเอกสารพา object หลายแสนตัว payload ของ stream ไปอยู่ใน list Streams แยกต่างหาก เพราะมันเป็น descendant ของ TStream ไม่ใช่ node THPDFObject และถูก free ในรอบของตัวเอง

แผนภาพ teardown สามเฟสของ CloseIndirectObjects ใน HotPDF: การเก็บแบบ breadth-first เริ่ม worklist จาก IndirectObjects และเดินตามเฉพาะ owning edge ผ่าน seen-set แบบ open addressing ที่ hash ด้วย HPDFFastCacheHashInt64, เฟสสองตัดทุก edge ด้วย MarkAsFreed และการ assign nil และเฟสสาม free node กับ payload ของ stream แต่ละตัวพอดีครั้งเดียว
การตัด edge ก่อน destructor ตัวใดจะรันคือสิ่งที่ทำให้ destructor ที่มีอยู่ปลอดภัยที่จะใช้ต่อ แต่ละตัวจึงไม่เจออะไรให้ไล่ต่อ child ที่ใช้ร่วมกัน cycle และ alias ระหว่าง wrapper กับ body จึงลงมาจนหมดโดยไม่มี double free
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;

// เฟสแรก: เริ่มจาก registry แล้วเดินตามเฉพาะ owning edge
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;

เฟสที่สองคือส่วนที่ทำให้ destructor ปลอดภัยที่จะรัน: owning edge ทุกเส้นถูกตั้งเป็น nil ก่อน destructor ตัวใดจะทำงาน wrapper ได้ MarkAsFreed ซึ่งล้าง FInternalObject และตั้งธงที่ destructor ของมันตรวจก่อน stream object มี Dictionary กับ Stream ถูก assign เป็น nil dictionary item แต่ละตัวมี Item^.Value ถูกล้าง และช่องใน array แต่ละช่องถูกเขียนทับด้วย nil หลังรอบนี้ graph ไม่เหลือ edge แล้ว เมื่อเฟสที่สามเรียก Free กับทุก node ใน Nodes แล้วตามด้วยทุก payload ใน Streams destructor แต่ละตัวจึงไม่เจออะไรให้ไล่ต่อและทำลายแค่ตัวเอง

// เฟสสอง: ตัด owning edge ทุกเส้นก่อน 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;

// เฟสสาม: node กับ payload ที่ไม่ซ้ำกันแต่ละตัวถูก free พอดีครั้งเดียว
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);

ดูว่าการแยกแบบนี้ซื้ออะไรให้ dictionary ที่ใช้ร่วมกันโดย stream สองตัวถูกเก็บครั้งเดียว ตัดออกจากทั้งสอง และ free ครั้งเดียว cycle ที่ array ลิสต์ parent dictionary ของตัวเองจบลงเพราะ seen-set ปฏิเสธการเยี่ยมครั้งที่สอง wrapper กับ body ที่ลงทะเบียนเป็น root ทั้งคู่เป็น pointer คนละตัวใน set จึงถูก free ทั้งคู่ และ destructor ของ wrapper ก็ไม่พยายาม free body อีกเพราะ MarkAsFreed เอาขอบนั้นไปแล้ว TMemoryStream ตัวเดียวที่ถูก assign เป็น payload ของ stream สองตัวอยู่ใน Streams ตัวเดียวพอดี ไม่มีเคสไหนในนี้ที่ต้องจัดการเป็นพิเศษ ซึ่งเป็นสัญญาณว่าโมเดลถูก

แยก leak ออกจาก allocator ที่เก็บหน่วยความจำไว้ได้อย่างไร

ด้วยการดูว่าจำนวน allocation ที่ยังมีชีวิตของ memory manager ขยับตาม workload ไหม ไม่ใช่ดูแค่ footprint ที่จองไว้ Delphi memory manager เก็บ block ใหญ่ที่ free แล้วไว้ใช้ซ้ำ โปรเซสที่ค้างอยู่ที่ 400 MiB หลังปิดเอกสารจึงไม่ได้ leak เสมอไป ส่วนโปรเซสที่จำนวน block ที่มีชีวิตเพิ่มขึ้นหน้าละหนึ่งต่อรอบนั้น leak แน่ ตัว probe ที่ทำให้เกิด fix นี้เล็กโดยตั้งใจ: writer THotPDF หนึ่งตัวผลิตหนึ่งหน้า แล้ว reader สามตัวโหลดมัน หลัง free ทั้งสี่ตัว รายงาน heap แสดง allocation ขนาด 512 KiB ที่ยังมีชีวิตพอดีสี่ก้อน ตัวละหนึ่ง ซึ่งก็คือ payload ของ content stream ที่แต่ละตัวเป็นเจ้าของและไม่เคยปลดปล่อย ขยายขนาดขึ้นก็เห็นรูปแบบเดิมชัด ๆ การรัน parallel render pipeline สองรอบเลื่อนตัวเลข large block ที่ allocate ไปจาก 384 MiB เป็น 640 MiB ซึ่งเป็นการเพิ่มตามจำนวนหน้าแบบที่ allocator retention อธิบายไม่ได้ หลังเขียนใหม่ diagnostic แบบหน้าเดียวรายงาน large byte ที่ allocate เป็นศูนย์และ reserved เป็นศูนย์เมื่อ instance หมดไป ถ้าคุณกำลังตามหาการเติบโตแบบเดียวกันในโปรเซสของคุณ object dependency graph พร้อม retained byte บอกได้ว่า object ไหนถือหน่วยความจำอยู่ตอนที่เอกสารเปิดอยู่ ส่วนบทความนี้ว่าด้วย release behavior ของพวกมันตอนที่เอกสารถูกปิด

เกณฑ์หน่วยความจำทำให้ test regression เปราะง่าย test ที่ ship จึงนับจำนวนการเรียก destructor แทน fixture ตัวหนึ่งสร้าง graph ที่ผิดปกติขึ้นมาด้วยมือ โดยมี dictionary ที่ใช้ร่วมกันใต้ stream สองตัว, array ที่บรรจุทั้ง dictionary ที่ใช้ร่วมกันและ root ของตัวเอง, payload หนึ่งก้อนที่ assign ให้ stream ทั้งสอง, root ที่ลงทะเบียนสองครั้ง และ wrapper ที่ body ถูกลงทะเบียนแยก จากนั้น free เอกสารและ assert ว่ามีการทำลายหนึ่งครั้งต่อ object ที่ไม่ซ้ำกัน: payload หนึ่ง, stream สอง, dictionary สอง, array หนึ่ง, wrapper หนึ่ง, หมายเลขหนึ่ง ภายใต้โค้ดเก่า test อายุการใช้งานทั้งสามรายงานการทำลายเป็นศูนย์ ซึ่งเป็นข้อความที่ตรงที่สุดเท่าที่จะเป็นไปได้ของความหมายของคำว่าทิ้งไว้ให้ตอนโปรเซสจบไปเก็บ

ต้องเกิดอะไรขึ้นก่อน graph จะถูกรื้อ

งานเบื้องหลังใดก็ตามที่ยืม object จาก graph ต้องหยุดก่อน และ cache ใดก็ตามที่ถือ display list หรือบิตแมปที่คอมไพล์มาจาก object เหล่านั้นต้องถูกทิ้ง ไม่งั้น worker thread หรือ reference ที่ cache ไว้จะอ่านหน่วยความจำที่ถูก free ไปแล้ว CloseIndirectObjects จึงเริ่มด้วย CancelLoadedPagePrefetch แล้วทำให้ cache ของหน้าที่ render ใช้ไม่ได้ก่อนแตะ registry เส้นทางโหลดใหม่ใน LoadFromFile และ LoadFromStream กับ destructor ของ component ต่างก็ผ่านมัน ลำดับเดียวกันจึงใช้ทั้งตอนคุณเปลี่ยนเอกสารและตอนทิ้ง instance กฎการนำ THotPDF ตัวเดียวกลับมาใช้กับหลายเอกสาร ก็พึ่งหลักประกันข้อนี้ มีสองรายละเอียดในส่วนนำนั้นที่โผล่ขึ้นมาจากการรัน test เท่านั้น อย่างแรก destructor ได้ทิ้ง frequency sketch ที่อยู่หลัง cache ของ render และ display list ไปแล้วตอนที่มันปิด graph การทำให้ cache ใช้ไม่ได้จึงถูก guard ด้วยการตรวจว่าฟิลด์เหล่านั้นไม่เป็น nil แทนที่จะเรียกแบบไม่มีเงื่อนไข อย่างที่สอง InvalidateRenderedPageCache เป็น routine ที่ยิง OnLoadedDocumentModified ด้วย index ของหน้าเป็น -1 และ caller ที่โหลดไฟล์ใหม่ไม่ควรได้รับการแจ้งเตือนการแก้ไขสำหรับการรื้อภายในของเอกสารเก่า handler จึงถูกเซฟไว้ ตั้งเป็น nil รอบการเรียก และคืนค่าใน finally และ regression ของการโหลดใหม่ assert ว่าจำนวนการแจ้งเตือนเป็นศูนย์หลัง LoadFromStream ครั้งที่สอง memory fix ที่เปลี่ยนสัญญาของ event เงียบ ๆ คือ regression ที่ PR ดูดีกว่า จึงได้ assertion ของตัวเอง ถ้าคุณรัน parallel render pipeline กับเอกสารแล้วโหลดมันใหม่ ขั้นตอนยกเลิกคือสิ่งที่กันไม่ให้ worker pool แข่งกับการรื้อ

นำรูปแบบนี้ไปใช้ในโค้ด Delphi ของคุณเอง

เทคนิคนี้ไม่ได้เฉพาะกับ PDF object model ของ Delphi แบบใดก็ตามที่ destructor เป็นเจ้าของ child ไม่สม่ำเสมอ ที่ child ตัวเดียวกันเข้าถึงได้จาก parent หลายตัว หรือที่มีทั้ง back-pointer กับ forward pointer อยู่ด้วยกัน จะ crash หรือ leak ภายใต้การ Free ทีละตัวแบบ naive fix มีรูปร่างเดียวกันเสมอ: ตัดสินว่า pointer field ไหนเป็นเจ้าของและไหนเป็น reference เก็บ closure ของ owning edge ผ่าน pointer set ที่ทนการเยี่ยมซ้ำ ตัดทุก edge แล้วค่อยทำลาย list แบน ขั้นตอนการตัดคือขั้นที่คนข้าม และเป็นขั้นที่ทำให้ destructor ที่มีอยู่ปลอดภัยที่จะใช้ต่อแทนที่จะบังคับให้เขียนคลาสทุกตัวในโมเดลใหม่ แต่ก็ควรพูดขอบเขตให้ชัด pointer set ใช้ address ของ object เป็น identity object ที่ถูก free ไปแล้วและ address ถูกนำกลับมาใช้โดย allocation ใหม่จึงแยกไม่ออก ลำดับการทำงานรับประกันว่าไม่มี destructor รันระหว่างการเก็บ ซึ่งเป็นสิ่งที่กันเคสนั้น การเดินเห็นแค่ edge สี่แบบที่มันรู้จัก คลาสใหม่ที่ถือ child ผ่านฟิลด์ที่การเดินไม่ได้ตรวจจะ leak child นั้นไว้จนกว่าจะสอนการเดินเรื่องนั้น และเพราะ link ถูก resolve ผ่าน registry แทนที่จะเดินตาม object ที่ถูกอ้างด้วย link เท่านั้นและไม่เคยถูกลงทะเบียนจะเอื้อมไม่ถึง teardown นี้เลย ใน HotPDF parser รับประกันการลงทะเบียนไว้ แต่ graph ที่สร้างด้วยมือต้องเคารพกฎเดียวกัน

ทั้งหมดนี้อยู่ภายใน component ผลที่ผู้ใช้เห็นสำหรับแอปพลิเคชันจึงเป็นแค่ว่าการปิดหรือโหลดเอกสารใหม่คืนหน่วยความจำของมัน โดยไม่มีการเปลี่ยน API HotPDF เป็น library PDF แบบ VCL native สำหรับ Delphi และ C++Builder พร้อม source เต็ม เอกสารอ้างอิง API และบิลด์ทดลองอยู่ที่หน้า HotPDF Delphi PDF component