HotPDF Delphi Component frigiver hvert PDF-objekt, et dokument ejer, når dokumentet lukkes eller genindlæses: THotPDF.CloseIndirectObjects gennemløber objektregistreringen, samler hver ejerskabskant i et pointer-sæt, løsner alle de kanter og frigør først derefter hver unikke knude og hver stream-payload præcis én gang. Den tre-fasede rækkefølge er det, der lader delte børn, ejerskabs-cykler, duplikerede registreringer og wrapper/body-aliaser alle komme ned uden en double free og uden at efterlade noget. Før v2.752.4 gjorde samme rutine noget langt enklere og langt værre: den releasede de lazy file stream-kilder, kaldte Clear på IndirectObjects-listen, frigjorde listecontaineren og lod hvert faktisk PDF-objekt til procesexit at indsamle. Kommentaren i den kode var ærlig om det, også. At frigøre objekterne individuelt forårsagede access violations, så den "sikre tilgang" var slet ikke at frigøre dem. Dette indlæg handler om, hvorfor den individuelle tilgang virkelig brød sammen, og hvordan en nedrivning, der virker, ser ud i et sprog med manuel memory-håndtering
Hvorfor kan du ikke bare Free'e hvert registreret objekt?
Fordi objektklassernes destruktore er uenige om, hvem der ejer hvad, og registreringen indeholder entries på flere niveauer af den samme ejerskabskæde. At gennemløbe listen og kalde Free på hver entry frigør derfor noget hukommelse to gange og noget aldrig, afhængigt af hvilke klasser der tilfældigvis sidder ved siden af hinanden
Tre asymmetrier i HPDFObjs.pas og HPDFDoc.pas skaber problemet. THPDFDictionaryObject.Destroy gennemløber sine Items og frigør en værdi kun, når IsIndirect er False, under antagelsen om, at indirekte børn tilhører registreringen og frigøres dér. THPDFArrayObject.Destroy laver ingen sådan skelnen og frigør hvert element, den holder. Og THPDFIndirectObject.Destroy, wrapperen, der bærer et objektnummer, frigør sin InternalObject-krop. Betragt nu en registrering, der holder en indirekte dictionary, et array, der lister den samme dictionary i én af sine pladser, og en wrapper, hvis krop også er registreret som en separat rod, hvilket er præcis, hvad parseren producerer på rigtige filer. Frigør arrayet først, og dictionaryen er væk, før registreringen når frem til den. Frigør wrapperen og kroppen, i vilkårlig rækkefølge, og det andet kald kører en destruktor på en dangling pointer. Frigør dictionaryen alene, og ethvert indirekte barn, den sprang over, forbliver allokeret for evigt. Ingen rækkefølge af registreringen fikser dette, for registreringen er en flad liste, og ejerskabsrelationen er en graf, og at ræsonnere om grafen er den eneste udvej
Hvad tæller som en ejerskabskant i en PDF-objektgraf?
En ejerskabskant er en pointer, hvis target kilden er ansvarlig for at ødelægge; en reference er alt andet, og nedrivningen skal følge den første slags og ignorere den anden. I HotPDF giver det præcis fire kant-typer: Items af en THPDFDictionaryObject, Items af en THPDFArrayObject, InternalObject bag en THPDFIndirectObject og begge halvdele af en THPDFStreamObject, dens Dictionary og dens Stream-payload. Reference-typerne betyder lige så meget, for at følge én forvandler en graf-gennemløbning til en uendelig løkke eller en use-after-free. En THPDFLink holder et objektnummer og en generation, hvilket er, hvordan ISO 32000-1 §7.3.10 definerer en indirekte reference: et navn for et objekt, der bor et andet sted, ikke objektet selv. At resolve det nummer gennem registreringen giver en knude, som en anden kant allerede ejer, så CloseIndirectObjects derefererer aldrig links. FParent-tilbagepointeren, som dictionaries og arrays holder, er samme historie i den anden retning; forælderen ejer allerede barnet, så at følge pointeren opad ville kun genbesøge en knude, gennemløbet har været forbi. Begge efterlades i fred, og kommentaren i kilden siger det på én linje: links og parent-pointere er referencer, ikke ejerskabskanter
Hvordan virker den tre-fasede nedrivning?
Fase én er en breadth-first-indsamling. Rutinen såer en worklist med hver entry af IndirectObjects og føjer til hver knude targetene af den knudes ejerskabskanter til og springer alt allerede set over. Seen-sættet er et open-addressing-array af rå pointere hashet med HPDFFastCacheHashInt64 over pointerværdien, med lineær probing og en fordoblende GrowSeen, når det når halvfullt. Intet i den struktur allokerer pr. knude, hvilket betyder noget, når et dokument bærer et par hundrede tusind objekter. Stream-payloads ryger i en separat Streams-liste, fordi de er TStream-efterkommere snarere end THPDFObject-knuder og frigøres i deres eget gennemløb
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; // allerede indsamlet
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;
// Fase én: så med registreringen, følg derefter kun ejerskabskanter
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;
Fase to er den del, der gør destruktorene sikre at køre: hver ejerskabskant sættes til nil, før nogen destruktor eksekverer. En wrapper får MarkAsFreed, som rydder FInternalObject og sætter flaget, dens destruktor tjekker først. Et stream-objekt får Dictionary og Stream tildelt nil. Hvert dictionary-item får Item^.Value ryddet, og hver array-plads overskrives med nil. Efter dette gennemløb har grafen ingen kanter tilbage, så når fase tre kalder Free på hver knude i Nodes og derefter hver payload i Streams, finder hver destruktor intet at rekurrere ind i og ødelægger kun sig selv
// Fase to: løsn hver ejerskabskant, før noget frigøres
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;
// Fase tre: hver unikke knude og payload frigøres præcis én gang
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);
Se, hvad opdelingen køber. En dictionary delt af to stream-objekter indsamles én gang, løsnes fra begge og frigøres én gang. En cyklus, hvor et array lister sin egen parent-dictionary, terminerer, fordi seen-sættet nægter det andet besøg. En wrapper og dens krop, begge registreret som rødder, er to distinkte pointere i sættet, så begge frigøres, og wrapperens destruktor forsøger ikke længere at frigøre kroppen, fordi MarkAsFreed allerede tog den kant væk. En enkelt TMemoryStream tildelt som payload af to stream-objekter ligger i Streams præcis én gang. Ingen af de tilfælde behøver specialhåndtering, hvilket er tegnet på, at modellen er rigtig
Hvordan skelner du en leak fra allocator-retention?
Ved at tjekke, om memory managerens live allocation-tælling bevæger sig med workloadet, ikke bare dets reserverede footprint. En Delphi memory manager beholder frigjorte store blokke til genbrug, så en proces, der står ved 400 MiB efter at have lukket et dokument, har ikke nødvendigvis leaket; en proces, hvis live block-tælling klatrer med én pr. side pr. gennemløb, har. Proben, der drev dette fix, var bevidst lille: én THotPDF-writer, der producerer en enkelt side, derefter tre readers, der loader den. Efter alle fire var frigjort, viste heap-rapporten præcis fire live 512 KiB-allokeringer, én pr. instans, hvilket er den content stream-payload, hver ejede og aldrig releasede. At skalere op gjorde samme mønster umisendligt. At køre den parallelle render-pipeline to gange flyttede large-block allocated-tallet fra 384 MiB til 640 MiB, en stigning proportional med sideantallet, som allocator-retention ikke kan forklare. Efter rewrite'en rapporterede single-page-diagnostikken nul store bytes allokeret og nul reserveret, så snart instanserne var væk. Jager du samme slags vækst i din egen proces, fortæller objekt-afhængighedsgrafen med retained bytes dig, hvilke objekter holder hukommelsen, mens dokumentet er åbent; dette indlæg handler om deres release-adfærd, når det lukker
Hukommelsestærskler laver skrøbelige regressionstests, så de shippede tests tæller destruktorkald i stedet. En fixture bygger den patologiske graf i hånden, med en delt dictionary under to streams, et array, der indeholder både den delte dictionary og sin egen rod, én payload tildelt begge streams, roden registreret to gange og en wrapper, hvis krop er separat registreret, og frigør derefter dokumentet og assert'er én destruktion pr. unikke objekt: én payload, to streams, to dictionaries, ét array, én wrapper, ét tal. Under den gamle kode rapporterede alle tre lifetime-tests nul destruktioner, hvilket er den mest direkte mulige udtalelse af, hvad "lad det stå til procesexit" betyder
Hvad skal ske, før grafen kommer ned?
Ethvert baggrundsarbejde, der låner objekter fra grafen, skal stoppe først, og enhver cache, der holder display lists eller bitmaps kompileret fra de objekter, skal smides, ellers læser en worker-tråd eller en cached reference frigjort hukommelse. CloseIndirectObjects åbner derfor med CancelLoadedPagePrefetch og ugyldiggør derefter den renderede side-cache, før den rører registreringen. Genindlæsningsvejen i LoadFromFile og LoadFromStream og komponentdestruktoren ruter begge gennem den, så samme rækkefølge gælder, uanset om du erstatter et dokument eller skiller instansen af; reglerne for at genbruge én THotPDF på tværs af dokumenter læner op ad den garanti. To detaljer i det forspil kom kun frem ved at køre testene. Først har destruktoren allerede skilt sig af med frequency sketches bag render- og display list-cacherne, i det øjeblik den lukker grafen, så ugyldiggørelsen er bevogtet på, at de felter er ikke-nil, i stedet for at blive kaldt betingelsesløst. Dernæst er InvalidateRenderedPageCache rutinen, der affyrer OnLoadedDocumentModified med et sideindeks på -1, og en caller, der genindlæser en fil, bør ikke modtage en redigeringsnotifikation for det gamle dokuments interne nedrivning. Handleren gemmes, sættes til nil omkring kaldet og gendannes i en finally, og reload-regressionen assert'er en notifikationstælling på nul efter den anden LoadFromStream. Et hukommelsesfix, der i stilhed ændrer en event-kontrakt, er en regression med bedre PR, så det får sin egen assertion. Kører du den parallelle render-pipeline mod et dokument og genindlæser det derefter, er annulleringstrinnet det, der holder worker-poolen fra at race nedrivningen
At genbruge mønsteret i din egen Delphi-kode
Teknikken er ikke specifik for PDF. Enhver Delphi-objektmodel, hvor destruktore ejer børn inkonsistent, hvor samme barn kan nås fra flere forældre, eller hvor tilbagepointere og fremadpointere eksisterer side om side, vil crashe eller leake under en naiv per-objekt-Free. Fixet er altid samme form: beslut, hvilke pointer-felter der er ejerskab, og hvilke der er referencer, saml lukningen af ejerskabskanter gennem et pointer-sæt, der tolererer genbesøg, skær hver kant, og ødelæg derefter den flade liste. Skæretrinnet er det, folk springer over, og det er det, der gør de eksisterende destruktore sikre at genbruge i stedet for at tvinge en omskrivning af hver klasse i modellen. Grænserne er værd at sige klart, dog. Pointer-sættet bruger objektadressen som identitet, så et objekt, der allerede er frigjort, og hvis adresse er genbrugt af en frisk allokering, ville være uadskillelig; rækkefølgen garanterer, at ingen destruktor kører under indsamlingen, hvilket er det, der udelukker det. Gennemløbet ser kun de fire kant-typer, det kender, så en ny klasse, der ejer et barn gennem et felt, gennemløbet ikke inspicerer, vil leake det barn, indtil gennemløbet læres det. Og fordi links resolveres gennem registreringen snarere end følges, er et objekt, der kun refereres af et link og aldrig blev registreret, slet ikke nåeligt for denne nedrivning; i HotPDF garanterer parseren registrering, men en håndbygget graf må respektere samme regel
Alt dette er inde i komponenten, så den synlige effekt for en applikation er simpelthen, at lukning eller genindlæsning af et dokument returnerer dets hukommelse, uden API-ændring. HotPDF er et native VCL PDF-bibliotek til Delphi og C++Builder med fuld kildekode; API-referencen og en trial-build ligger på HotPDF Delphi PDF-komponentsiden