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 дете, което е прескачал, остава алокирано завинаги. Никое подреждане на регистъра не оправя това, защото регистърът е плосък списък, а релацията на притежание е графа, и разсъждението върху графата е единственият изход
Какво се брои за притежателен ръб в обектната графа на 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 пойнтерите са референции, не притежателни ръбове
Как работи трифазният teardown?
Фаза едно е breadth-first събиране. Рутината сеедна worklist с всеки запис на IndirectObjects, после за всеки възел добавя целите на притежателните му ръбове, прескачайки всичко, вече видяно. Seen-set-ът е open-addressing масив от сурови пойнтери, хеширани с HPDFFastCacheHashInt64 по стойността на пойнтера, с linear probing и удвояващ се GrowSeen, когато се напълни до половина. Нищо в тази структура не алокира на възел, което има значение, когато документ носи няколкостотин хиляди обекта. Stream 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;
Фаза две е частта, която прави деструкторите безопасни за пускане: всеки притежателен ръб се занулява, преди някой деструктор да се изпълни. 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 компонента