Техническа статия

Освобождаване на PDF обектна графа точно веднъж: HotPDF

HotPDF Delphi Component освобождава всеки PDF обект, който документът притежава, когато документът се затваря или презарежда: THotPDF.CloseIndirectObjects обхожда обектния регистър, събира всеки притежателен ръб в pointer set, откача всички тези ръбове и едва тогава освобождава всеки уникален възел и всеки stream payload точно веднъж. Именно този трифазен ред позволява споделени деца, цикли на притежание, дублирани регистрации и wrapper/body alias-и да падат всички без double free и без да оставя нещо след себе си. Преди v2.752.4 същата рутина правеше нещо много по-просто и много по-лошо: освобождаваше lazy file stream източниците, викаше Clear на списъка IndirectObjects, освобождаваше контейнера списък и оставяше всеки истински PDF обект за изхода на процеса. Коментарът в кода беше честен за това. Освобождаването на обектите поотделно причиняваше access violations, така че „безопасният подход" беше изобщо да не ги освобождава. Тази статия е за защо индивидуалният подход наистина крашваше и как изглежда teardown, който работи, на език с ръчно управление на паметта

Защо не можете просто да направите Free на всеки регистриран обект?

Защото деструкторите на обектните класове не са съгласни кой какво притежава, а регистърът съдържа записи на няколко нива на една и съща верига на притежание. Обхождането на списъка и викането на Free върху всеки запис следователно освобождава част от паметта два пъти и част никога, в зависимост от това кои класове случайно седят един до друг

Три асиметрии в HPDFObjs.pas и HPDFDoc.pas създават проблема. THPDFDictionaryObject.Destroy обхожда Items и освобождава стойност само когато IsIndirect е False, с предположението, че indirect децата принадлежат на регистъра и ще бъдат освободени там. THPDFArrayObject.Destroy не прави такова разграничение и освобождава всеки елемент, който държи. А THPDFIndirectObject.Destroy, wrapper-ът, който носи номер на обект, освобождава тялото си InternalObject. Сега вземете регистър, който държи indirect речник, масив, който изброява същия речник в един от слотовете си, и wrapper, чието тяло също е регистрирано като отделен корен — точно това произвежда парсерът върху истински файлове. Освободите ли масива първо, речникът е мъртъв, преди регистърът да го е достигнал. Освободите ли wrapper-а и тялото, в който и да е ред, второто извикване пуска деструктор върху висящ пойнтер. Освободите ли само речника, всяко indirect дете, което е прескачал, остава алокирано завинаги. Никое подреждане на регистъра не оправя това, защото регистърът е плосък списък, а релацията на притежание е графа, и разсъждението върху графата е единственият изход

Защо освобождаването на всеки запис от HotPDF регистъра крашваше: THPDFDictionaryObject.Destroy прескача indirect деца, докато THPDFArrayObject.Destroy освобождава всичко, което държи, а THPDFIndirectObject.Destroy освобождава тялото си InternalObject, така че с wrapper, масив и споделен речник в един плосък IndirectObjects списък част от паметта умира два пъти, а част никога
Деструкторите не са съгласни кой какво притежава, а регистърът държи записи на няколко нива на една и съща верига на притежание, така че никое подреждане на плосък списък не може да превърне наивен Free на всеки обект в коректен teardown

Какво се брои за притежателен ръб в обектната графа на PDF?

Притежателен ръб е пойнтер, чиято цел източникът е длъжен да унищожи; референция е всичко останало, а teardown-ът трябва да следва първия вид и да игнорира втория. В HotPDF това дава точно четири вида ръбове: Items на THPDFDictionaryObject, Items на THPDFArrayObject, InternalObject зад един THPDFIndirectObject, и двете половини на THPDFStreamObject — неговият Dictionary и неговият Stream payload. Видовете референции са също толкова важни, защото следването на една превръща обхождането на графата в безкраен цикъл или use-after-free. Един THPDFLink държи номер на обект и поколение, което е начинът, по който ISO 32000-1 §7.3.10 дефинира indirect справка: име на обект, който живее другаде, не самият обект. Resolve-ването на номера през регистъра дава възел, който друг ръб вече притежава, затова CloseIndirectObjects изобщо не дереференсва линкове. Обратният пойнтер FParent, който речниците и масивите пазят, е същата история в другата посока; родителят вече притежава детето, та следването на пойнтера нагоре само би посетило отново възел, през който обхождането вече е минало. И двете се оставят на мира, а коментарът в кода го казва с един ред: линковете и parent пойнтерите са референции, не притежателни ръбове

Притежателни ръбове срещу референции в обектната графа на HotPDF: DictionaryObject Items, ArrayObject Items, IndirectObject InternalObject и двете половини на StreamObject се следват и откачат, докато номерът на обекта на THPDFLink и обратният пойнтер FParent са имена на обекти, които живеят другаде, затова CloseIndirectObjects никога не ги дереференсва
Притежателен ръб е пойнтер, чиято цел източникът трябва да унищожи; следването на референция вместо това би превърнало breadth-first обхождането в безкраен цикъл или use-after-free, затова линкове и parent пойнтери се оставят на мира

Как работи трифазният teardown?

Фаза едно е breadth-first събиране. Рутината сеедна worklist с всеки запис на IndirectObjects, после за всеки възел добавя целите на притежателните му ръбове, прескачайки всичко, вече видяно. Seen-set-ът е open-addressing масив от сурови пойнтери, хеширани с HPDFFastCacheHashInt64 по стойността на пойнтера, с linear probing и удвояващ се GrowSeen, когато се напълни до половина. Нищо в тази структура не алокира на възел, което има значение, когато документ носи няколкостотин хиляди обекта. Stream payload-ите отиват в отделен списък Streams, защото са TStream наследници, а не THPDFObject възли, и се освобождават на собствен им проход

Трифазният teardown на CloseIndirectObjects в HotPDF: breadth-first събиране сеедни worklist-а от IndirectObjects и следва само притежателни ръбове през open-addressing seen-set, хеширан с HPDFFastCacheHashInt64, фаза две откача всеки ръб с MarkAsFreed и присвояване на nil, а фаза три освобождава всеки възел и stream payload точно веднъж
Отрязването на ръбовете, преди някой деструктор да се е пуснал, е това, което прави съществуващите деструктори безопасни за преизползване: всеки после не намира какво да рекурсира, така че споделени деца, цикли и wrapper-body alias-и падат всички без 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;

// Фаза едно: сеедни с регистъра, после следвай само притежателни ръбове
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;

Фаза две е частта, която прави деструкторите безопасни за пускане: всеки притежателен ръб се занулява, преди някой деструктор да се изпълни. Wrapper получава MarkAsFreed, който изчиства FInternalObject и вдига флага, който неговият деструктор проверява първи. Stream обект получава Dictionary и Stream зададени на nil. Всеки речников елемент получава изчистено Item^.Value, а всеки масивен слот се презаписва с nil. След този проход графата няма останали ръбове, така че когато фаза три вика Free върху всеки възел в Nodes и после върху всеки payload в Streams, всеки деструктор не намира какво да рекурсира и унищожава само себе си

// Фаза две: откачи всеки притежателен ръб, преди да освободиш нещо
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);

Вижте какво купува разделянето. Речник, споделен от два stream обекта, се събира веднъж, откача и от двата и се освобождава веднъж. Цикъл, в който масив изброява собствения си родителски речник, терминира, защото seen-set-ът отказва второто посещение. Wrapper и тялото му, регистрирани и двата като корени, са два различни пойнтера в множеството, така че и двата се освобождават, а деструкторът на wrapper-а вече не опитва да освободи тялото, защото MarkAsFreed вече е отнел този ръб. Един-единствен TMemoryStream, зададен като payload на два stream обекта, седи в Streams точно веднъж. Нито един от тези случаи не се нуждае от специална обработка, което е знакът, че моделът е верен

Как разграничавате теч от задържане от страна на алокатора?

Като проверите дали броячът на живи алокации на memory manager-а се движи с работния товар, а не само неговият резервиран обем. Delphi memory manager държи освободени големи блокове за преизползване, така че процес, който стои на 400 MiB след затваряне на документ, не непременно тече; процес, чиято бройка на живи блокове се качва с по един на страница на изпълнение, тече. Сондата, която задвижва тази поправка, беше нарочно малка: един THotPDF writer, произвеждащ една страница, после три читатели, зареждащи я. След като и четирите бяха освободени, heap докладът показа точно четири живи алокации по 512 KiB — по една за инстанция, това е content stream payload-ът, който всяка притежаваше и никога не освободи. Мащабирането направи същия модел неоспорим. Две изпълнения на parallel render pipeline-а преместиха цифрата за алокирани големи блокове от 384 MiB на 640 MiB, увеличение, пропорционално на броя страници, което задържане от алокатора не може да обясни. След пренаписването едностраничната диагностика докладва нула големи байта алокирани и нула резервирани, щом инстанциите ги нямаше. Ако ловите същия вид растеж във вашия процес, object dependency графата с задържани байтове ви казва кои обекти държат паметта, докато документът е отворен; тази статия е за освобождаването им, когато той се затвори

Праговете на паметта правят крехки регресионни тестове, затова изпратените тестове броят извиквания на деструктори. Fixture строи патологичната графа на ръка — споделен речник под два stream-а, масив, съдържащ и споделения речник, и собствения си корен, един payload, зададен и на двата stream-а, коренът, регистриран два пъти, и wrapper, чието тяло е регистрирано отделно — после освобождава документа и асертира едно унищожение на всеки уникален обект: един payload, два stream-а, два речника, един масив, един wrapper, един номер. При стария код и трите lifetime теста докладваха нула унищожения, което е най-преката възможна формулировка на това какво означава „остави го за изхода на процеса"

Какво трябва да се случи, преди графата да падне?

Всяка фонова работа, която заема обекти от графата, трябва първо да спре, а всеки cache, който държи display list-ове или битмапи, компилирани от тези обекти, трябва да бъде изпуснат, иначе worker нишка или кеширана справка чете освободена памет. CloseIndirectObjects затова започва с CancelLoadedPagePrefetch, после инвалидира rendered page cache-а, преди да пипне регистъра. Пътят за презареждане в LoadFromFile и LoadFromStream и деструкторът на компонента и двата минават през него, така че същият ред важе дали сменяте документ, или освобождавате инстанцията; правилата за преизползване на един THotPDF през документи разчитат на тази гаранция. Два детайла в това начало излязоха наяве само от пускането на тестовете. Първо, деструкторът вече е освободил frequency sketch-овете зад render и display list cache-овете към момента, в който затваря графата, така че инвалидацията е гейтната върху тези полета да са non-nil, вместо да се вика безусловно. Второ, InvalidateRenderedPageCache е рутината, която палит OnLoadedDocumentModified с индекс на страница -1, а извикващ, който презарежда файл, не бива да получава нотификация за редакция от вътрешния teardown на стария документ. Handler-ът се запазва, занулява около извикването и възвръща в finally, а регресията за презареждане асертира бройка нотификации нула след втория LoadFromStream. Поправка на паметта, която тихо сменя event контракт, е регресия с по-добро PR, затова получава собствен асершън. Ако пускате parallel render pipeline върху документ и после го презареждате, стъпката cancel е тази, която пази worker pool-а от надпревара с teardown-а

Преизползване на шаблона във вашия Delphi код

Техниката не е специфична за PDF. Всеки Delphi обектен модел, в който деструкторите притежават деца непоследователно, в който едно и също дете може да бъде достигнато от няколко родителя, или в който съжителстват обратни и сочещи напред пойнтери, ще крашне или ще тече под наивен Free на всеки обект. Поправката винаги е с една и съща форма: решете кои pointer полета са притежателни и кои са референции, съберете затварянето на притежателните ръбове през pointer set, толериращ повторни посещения, отрежете всеки ръб, после унищожете плоския списък. Стъпката на отрязване е тази, която хората прескачат, и е тази, която прави съществуващите деструктори безопасни за преизползване, вместо да налага пренаписването на всеки клас в модела. Границите обаче си струва да се изтъкнат ясно. Pointer set-ът ползва адреса на обекта като идентичност, така че обект, който вече е освободен и чийто адрес е преизползван от свежа алокация, би бил неотличим; подреждането гарантира, че никой деструктор не се пуска по време на събирането, което изключва този случай. Обхождането вижда само четирите вида ръбове, които познава, така че нов клас, който притежава дете през поле, което обхождането не инспектира, ще тече това дете, докато обхождането не бъде научено за него. И тъй като линковете се resolve-ват през регистъра, а не се следват, обект, сочен само от линк и никога не регистриран, изобщо не е достижим от този teardown; в HotPDF парсерът гарантира регистрация, но ръчно построена графа трябва да спазва същото правило

Всичко това е вътре в компонента, така че видимият ефект за едно приложение е просто това, че затварянето или презареждането на документ връща паметта му, без промяна в API-я. HotPDF е нативна VCL PDF библиотека за Delphi и C++Builder с пълен source; API справката и trial билдът са на страницата на HotPDF Delphi PDF компонента