HotPDF Delphi Component frigör varje PDF-objekt ett dokument äger när dokumentet stängs eller laddas om: THotPDF.CloseIndirectObjects går genom objektregistret, samlar varje ägarkant i en pekarmängd, kopplar loss alla de kanterna, och frigör först därefter varje unik nod och varje strömpayload exakt en gång. Den trefasordningen är vad som gör att delade barn, ägandcykler, dubbelregistreringar och wrapper/kropp-alias alla kan monteras ned utan dubbel free och utan att lämna något kvar. Före v2.752.4 gjorde samma rutin något mycket enklare och mycket sämre: den släppte de lata filströmskällorna, anropade Clear på listan IndirectObjects, frigjorde listbehållaren, och lämnade varje faktiskt PDF-objekt till processavslutningen att återta. Kommentaren i den koden var ärlig om det också. Att frigöra objekten individuellt orsakade access violations, så den "säkra metoden" var att inte frigöra dem alls. Det här inlägget handlar om varför den individuella metoden verkligen kraschade, och hur en nedmontering som fungerar ser ut i ett språk med manuell minneshantering
Varför kan du inte bara Free:a varje registrerat objekt?
Därför att objektklassernas destruktorer är oense om vem som äger vad, och registret innehåller poster på flera nivåer av samma ägandekedja. Att gå genom listan och anropa Free på varje post frigör därför en del minne två gånger och en del aldrig, beroende på vilka klasser som råkar ligga intill varandra
Tre asymmetrier i HPDFObjs.pas och HPDFDoc.pas skapar problemet. THPDFDictionaryObject.Destroy går genom sina Items och frigör ett värde bara när IsIndirect är False, i antagandet att indirekta barn tillhör registret och kommer att frigöras där. THPDFArrayObject.Destroy gör ingen sådan åtskillnad och frigör varje item den håller. Och THPDFIndirectObject.Destroy, wrappern som bär ett objektnummer, frigör sin InternalObject-kropp. Tänk dig nu ett register som håller en indirekt ordbok, en array som listar samma ordbok i en av sina platser, och en wrapper vars kropp också är registrerad som en separat rot, vilket är precis vad parsern producerar på riktiga filer. Frigör arrayen först och ordboken är borta innan registret når den. Frigör wrappern och kroppen, i vilken ordning som helst, och det andra anropet kör en destruktor på en dinglande pekare. Frigör ordboken ensam och varje indirekt barn den hoppade över förblir allokerat för alltid. Ingen ordning i registret löser detta, eftersom registret är en platt lista och äganderelationen är en graf, och att resonera om grafen är den enda vägen ut
Vad räknas som en ägarkant i en PDF-objektgraf?
En ägarkant är en pekare vars mål källan ansvarar för att förstöra; en referens är allt annat, och nedmonteringen måste följa det första slaget och ignorera det andra. I HotPDF ger det exakt fyra kanttyper: Items i en THPDFDictionaryObject, Items i en THPDFArrayObject, InternalObject bakom en THPDFIndirectObject, samt båda halvorna av en THPDFStreamObject, dess Dictionary och dess Stream-payload. Referenstyperna spelar lika stor roll, för att följa en sådan förvandlar en grafgenomgång till en oändlig loop eller en use-after-free. En THPDFLink håller ett objektnummer och en generation, vilket är hur ISO 32000-1 §7.3.10 definierar en indirekt referens: ett namn för ett objekt som lever någon annanstans, inte objektet självt. Att lösa upp det numret genom registret ger en nod som någon annan kant redan äger, så CloseIndirectObjects derefererar aldrig länkar alls. FParent-bakåtpekaren som ordböcker och arrayer håller är samma historia i andra riktningen; föräldern äger redan barnet, så att följa pekaren uppåt skulle bara återbesöka en nod som genomgången redan varit i. Båda lämnas i fred, och kommentaren i källkoden säger det på en rad: länkar och förälderpekare är referenser, inte ägarkanter
Hur fungerar nedmonteringen i tre faser?
Fas ett är en bredden-först-insamling. Rutinen såddar en arbetslista med varje post i IndirectObjects, och lägger för varje nod till målen för nodens ägarkanter, med hopp över allt som redan setts. Mängden av sedda noder är en array med öppen adressering av råa pekare som hashas med HPDFFastCacheHashInt64 över pekarvärdet, med linjär probning och en fördubblade GrowSeen när den når halvfull. Ingenting i den strukturen allokerar per nod, vilket spelar roll när ett dokument bär några hundra tusen objekt. Strömpayloads hamnar i en separat Streams-lista eftersom de är TStream-ättlingar snarare än THPDFObject-noder och frigörs i sin egen omgång
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; // redan insamlad
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;
// Fas ett: så med registret, följ sedan bara ägarkanter
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;
Fas två är den del som gör destruktorerna säkra att köra: varje ägarkant sätts till nil innan någon destruktor körs. En wrapper får MarkAsFreed, som rensar FInternalObject och sätter flaggan dess destruktor kontrollerar först. Ett strömobjekt får Dictionary och Stream tilldelade nil. Varje ordbokspost får Item^.Value rensad och varje arrayplats skrivs över med nil. Efter den här omgången har grafen inga kanter kvar, så när fas tre anropar Free på varje nod i Nodes och sedan varje payload i Streams, hittar varje destruktor inget att rekamera in i och förstör bara sig själv
// Fas två: koppla loss varje ägarkant innan något frigörs
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;
// Fas tre: varje unik nod och payload frigörs exakt en gång
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);
Titta på vad delningen ger. En ordbok som delas av två strömobjekt samlas in en gång, kopplas loss från båda, och frigörs en gång. En cykel där en array listar sin egen förälderordbok terminerar eftersom mängden av sedda vägrar det andra besöket. En wrapper och dess kropp som båda är registrerade som rötter är två distinkta pekare i mängden, så båda frigörs, och wrapperns destruktor försöker inte längre frigöra kroppen eftersom MarkAsFreed redan har tagit bort den kanten. En enda TMemoryStream som är tilldelad som payload åt två strömobjekt ligger i Streams exakt en gång. Inget av de fallen behöver särskild hantering, vilket är tecknet på att modellen är rätt
Hur skiljer du en läcka från allokatorns kvarhållning?
Genom att kontrollera om minneshanterarens antal levande allokeringar rör sig med arbetsbelastningen, inte bara dess reserverade fotavtryck. En Delphi-minneshanterare behåller frigjorda stora block för återanvändning, så en process som står kvar på 400 MiB efter att ha stängt ett dokument har inte nödvändigtvis läckt; en process vars antal levande block klättrar med ett per sida och körning har. Probet som drev den här fixen var medvetet litet: en THotPDF-skrivare som producerade en enda sida, sedan tre läsare som laddade den. Efter att alla fyra hade frigjorts visade heaprapporten exakt fyra levande allokeringar på 512 KiB, en per instans, vilket är den content stream-payload var och en ägde och aldrig släppte. Att skala upp gjorde samma mönster omisskännligt. Att köra den parallella renderingspipelinen två gånger flyttade siffran för allokerade stora block från 384 MiB till 640 MiB, en ökning proportionell mot sidantalet som allokatorns kvarhållning inte kan förklara. Efter omskrivningen rapporterade den ensidiga diagnostiken noll allokerade stora byte och noll reserverade när instanserna var borta. Om du jagar samma sorts tillväxt i din egen process berättar objektberoendegrafen med kvarhållna byte vilka objekt som håller minnet medan dokumentet är öppet; det här inlägget handlar om hur de frigörs när det stängs
Minneströsklar gör bräckliga regressionstester, så de levererade testerna räknar destruktoranrop i stället. En fixture bygger den patologiska grafen för hand, med en delad ordbok under två strömmar, en array som innehåller både den delade ordboken och sin egen rot, en payload tilldelad båda strömmarna, roten registrerad två gånger, och en wrapper vars kropp är separat registrerad, frigör sedan dokumentet och hävdar en förstöring per unikt objekt: en payload, två strömmar, två ordböcker, en array, en wrapper, ett nummer. Under den gamla koden rapporterade alla tre livstidstesterna noll förstöringar, vilket är det mest direkta möjliga uttalandet om vad "lämna det till processavslutningen" betyder
Vad måste hända innan grafen rivs?
Allt bakgrundsarbete som lånar objekt från grafen måste sluta först, och varje cache som håller displaylistor eller bitmappar kompilerade från de objekten måste släppas, annars läser en arbetstråd eller en cachad referens frigjort minne. CloseIndirectObjects inleder därför med CancelLoadedPagePrefetch och ogiltigförklarar sedan cachen för renderade sidor innan den rör registret. Omladdningsvägen i LoadFromFile och LoadFromStream samt komponentdestruktorn går båda genom den, så samma ordning gäller vare sig du ersätter ett dokument eller gör dig av med instansen; reglerna för att återanvända en THotPDF över flera dokument lutar sig mot den garantin. Två detaljer i den inledningen dök bara upp när testerna kördes. Först: destruktorn har redan gjort sig av med frekvensskisserna bakom renderings- och display list-cacherna när den stänger grafen, så ogiltigförklaringen är skyddad på att de fälten är icke-nil i stället för att anropas villkorslöst. Sedan: InvalidateRenderedPageCache är rutinen som avfyrar OnLoadedDocumentModified med sidindex -1, och en anropare som laddar om en fil bör inte få en redigeringsnotifiering för det gamla dokumentets interna nedmontering. Hanteraren sparas, sätts till nil runt anropet och återställs i en finally, och omladdningsregressionen hävdar ett notifieringsantal på noll efter den andra LoadFromStream. En minnesfix som tyst ändrar ett händelsekontrakt är en regression med bättre PR, så den får sitt eget hävdande. Om du kör den parallella renderingspipelinen mot ett dokument och sedan laddar om det, är avbrytningssteget vad som hindrar arbetspoolen från att tävla med nedmonteringen
Att återanvända mönstret i din egen Delphi-kod
Tekniken är inte specifik för PDF. Varje Delphi-objektmodell där destruktorer äger barn inkonsekvent, där samma barn kan nås från flera föräldrar, eller där bakåtpekare och framåtpekare samexisterar, kommer att krascha eller läcka under ett naivt Free per objekt. Fixen har alltid samma form: bestäm vilka pekarfält som är ägande och vilka som är referenser, samla sluten av ägarkanter genom en pekarmängd som tolererar återbesök, kapa varje kant, förstör sedan den platta listan. Kapningssteget är det folk hoppar över, och det är det som gör de befintliga destruktorerna säkra att återanvända i stället för att tvinga fram en omskrivning av varje klass i modellen. Gränserna är värda att uttala rakt ut. Pekarmängden använder objektadressen som identitet, så ett objekt som redan har frigjorts och vars adress har återanvänts av en ny allokering skulle vara omöjligt att skilja; ordningen garanterar att ingen destruktor körs under insamlingen, vilket är vad som utesluter det. Genomgången ser bara de fyra kanttyper den känner till, så en ny klass som äger ett barn genom ett fält genomgången inte inspekterar läcker det barnet tills genomgången får lära sig om det. Och eftersom länkar löses upp genom registret i stället för att följas, är ett objekt som bara refereras av en länk och aldrig registrerades inte nåbart för den här nedmonteringen alls; i HotPDF garanterar parsern registrering, men en handbyggd graf måste respektera samma regel
Allt detta ligger inuti komponenten, så den synliga effekten för en applikation är helt enkelt att stängning eller omladdning av ett dokument lämnar tillbaka dess minne, utan API-ändring. HotPDF är ett nativt VCL-PDF-bibliotek för Delphi och C++Builder med fullständig källkod; API-referensen och ett provbygge finns på sidan för HotPDF Delphi PDF-komponenten