Un modello a oggetti Delphi che rispecchia un file PDF analizzato di solito finisce come un'unica lista piatta di tutti gli oggetti più un grafo di puntatori che pende da essa. Percorrete quella lista chiamando Free su ogni voce e l'esecuzione muore a metà con una access violation dentro un distruttore, su un oggetto che un Free precedente aveva già rilasciato attraverso un genitore diverso. Sostituite il ciclo con List.Clear e il crash sparisce, ma il processo ora cresce di circa un documento ogni volta che ricaricate. Entrambi i sintomi hanno una sola causa: la lista è piatta e la proprietà è un grafo. Lo smontaggio che funziona decide quali puntatori possiedono i loro target, raccoglie quella chiusura una volta, taglia ogni arco possessore e solo allora libera ogni nodo, esattamente una volta
Perché liberare ogni oggetto registrato va in crash?
In HotPDF la lista piatta è il registro degli oggetti del documento, e le classi che contiene sono in disaccordo su chi distrugge cosa. Ogni distruttore è ragionevole per conto suo. Metteteli insieme sopra un registro che contiene voci da parecchi livelli della stessa catena di proprietà, e un Free per voce rilascia della memoria due volte e della memoria mai
Tre distruttori in HPDFObjs.pas e HPDFDoc.pas preparano la scena. THPDFDictionaryObject.Destroy percorre i suoi Items e libera un valore solo quando IsIndirect è False, assumendo che i figli indiretti appartengano al registro. THPDFArrayObject.Destroy libera ogni elemento che contiene, indiretto o no. THPDFIndirectObject, il wrapper usato per gli oggetti estratti dagli object stream (ISO 32000-1 §7.5.7), libera il suo corpo InternalObject. Ora immaginate un registro che contiene un dizionario indiretto, un array che elenca lo stesso dizionario in una casella, e un wrapper il cui corpo è registrato anche come radice di per sé. Liberando prima l'array, il dizionario è sparito prima che il registro lo raggiunga. Liberato il wrapper e poi il corpo, la seconda chiamata esegue un distruttore su memoria che non appartiene più a nessuno. Liberando solo il dizionario, ogni figlio indiretto che ha saltato resta allocato fino all'uscita dal processo
Riordinare il registro non può salvare la situazione. Qualunque ordine scegliate, qualche coppia di voci finisce sul lato sbagliato di un arco di proprietà, perché una lista non ha modo di dire che la voce 12 possiede la voce 40
Archi possessori e riferimenti in un grafo di oggetti PDF
Un arco possessore è un puntatore il cui target la sorgente è responsabile di distruggere. Tutto il resto è un riferimento, e lo smontaggio segue solo il primo tipo. In HotPDF ci sono esattamente quattro tipi di arco possessore: gli Items di un THPDFDictionaryObject, gli Items di un THPDFArrayObject, l'InternalObject dietro a un THPDFIndirectObject, ed entrambi i campi di un THPDFStreamObject — il suo Dictionary e il suo payload Stream
I riferimenti meritano la stessa cura, perché seguirne uno trasforma una visita del grafo in un ciclo infinito o in una lettura di memoria liberata. Una THPDFLink contiene un numero di oggetto e una generazione, che è esattamente il riferimento indiretto di ISO 32000-1 §7.3.10: un nome per un oggetto memorizzato da qualche altra parte nel file. Risolvere quel numero attraverso il registro atterra su un nodo che un altro arco possiede già, quindi lo smontaggio non dereferenzia mai i collegamenti. Il back-pointer FParent che dizionari e array conservano gira nella direzione opposta. Il genitore possiede già il figlio, e risalire quel puntatore servirebbe solo a rivisitare un nodo che la visita ha già passato. Entrambi i tipi restano intatti per tutto lo smontaggio
Raccogliere, staccare, liberare: lo smontaggio in tre passate
HotPDF rilascia gli oggetti di un documento in tre passate dentro il metodo protetto THotPDF.CloseIndirectObjects, e l'ordine di quelle passate è tutto il trucco. La passata uno raccoglie. Una worklist parte da ogni voce del registro, e per ogni nodo la visita accoda i target dei suoi archi possessori, saltando tutto ciò che ha già visto. L'insieme dei visti è un array a indirizzamento aperto di puntatori grezzi con probing lineare che raddoppia quando è mezzo pieno, così non viene allocato nulla per nodo, il che comincia a contare quando un documento porta con sé centinaia di migliaia di oggetti. I payload degli stream vanno in una seconda lista perché sono discendenti TStream e non nodi THPDFObject
I due listati che seguono sono estratti semplificati dell'implementazione interna del componente, mostrati così potete vedere le passate nei tipi di oggetto della libreria stessa. Il codice applicativo non chiama mai nulla di questo; LoadFromFile, LoadFromStream e il distruttore lo eseguono per voi. Nell'estratto l'hash dell'indirizzo è ridotto a uno shift e una maschera, e il passo che raddoppia la tabella dei visti quando si riempie è lasciato fuori
// Estratto semplificato dello smontaggio interno, non un'API chiamabile
procedure Collect(Value: TObject; Payload: boolean);
var
Slot: Integer;
begin
if Value = nil then Exit;
// (la routine vera raddoppia e rihasha la tabella quando è mezzo piena)
Slot := Integer(NativeUInt(Value) shr 4) and (Length(Seen) - 1);
while Seen[Slot] <> nil do
begin
if Seen[Slot] = Pointer(Value) then Exit; // già raccolto
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;
// Passata uno: semina con il registro, poi segui solo gli archi possessori
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);
// I valori THPDFLink e i puntatori FParent non vengono mai seguiti
Inc(I);
end;
La passata due taglia. Ogni arco possessore viene messo a nil prima che giri un solo distruttore. Un wrapper riceve MarkAsFreed, che azzera InternalObject e imposta il flag IsFreed che il suo distruttore controlla per primo. Un oggetto stream ha Dictionary e Stream assegnati a nil, ogni voce di dizionario ha il suo Value azzerato, e ogni casella di array viene sovrascritta con nil. La passata tre svuota il registro, libera ogni nodo in Nodes e poi ogni payload in Streams
// Estratto semplificato dello smontaggio interno, continua; non un'API chiamabile
// Passata due: stacca ogni arco possessore prima di liberare qualsiasi cosa
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;
// Passata tre: ogni nodo e payload unico viene liberato esattamente una volta
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);
Il taglio è ciò che permette ai distruttori esistenti di restare esattamente come sono. Senza archi, ogni distruttore non trova nulla in cui ricorrere e distrugge solo se stesso. L'altra strada verso lo stesso risultato è insegnare a ogni distruttore l'esistenza del registro, così che un dizionario, un array e un wrapper chiedano ciascuno se qualche altro percorso libererà i loro figli. Quello sparge la conoscenza del grafo su quattro classi e su ogni classe aggiunta in futuro, e una risposta sbagliata da qualsiasi parte riporta indietro il doppio free. Tenere l'intero modello di proprietà in un'unica routine di smontaggio è la sistemazione più sicura
Passateci i casi scomodi e nessuno di loro richiede un ramo speciale. Un dizionario condiviso da due oggetti stream viene raccolto una volta, staccato da entrambi e liberato una volta. Un ciclo in cui un array elenca il proprio dizionario genitore finisce perché l'insieme dei visti rifiuta la seconda visita. Un wrapper e il suo corpo registrati come due radici sono due puntatori distinti nell'insieme, così entrambi vengono liberati, e il distruttore del wrapper lascia in pace il corpo perché MarkAsFreed ha già rimosso quell'arco. Una TMemoryStream assegnata come payload di due oggetti stream sta in Streams esattamente una volta
Che cosa deve fermarsi prima che il grafo cada?
Qualsiasi cosa prenda in prestito oggetti dal grafo deve fermarsi prima. HotPDF può disegnare pagine su un thread di lavoro tramite PrefetchLoadedPages, e mette in cache bitmap e display list compilati dagli oggetti del documento; un worker o una voce di cache che sopravvivesse al grafo leggerebbe memoria liberata. Lo smontaggio quindi apre con CancelLoadedPagePrefetch e invalida la cache delle pagine disegnate prima di toccare il registro. LoadFromFile, LoadFromStream e il distruttore THotPDF passano tutti di lì, quindi l'ordine è identico che sostituiate un documento o liberiate il componente, e le regole per riusare un THotPDF attraverso più documenti si appoggiano su questo
Il contratto di notifica conta quanto il resto. InvalidateRenderedPageCache è il metodo che solleva OnLoadedDocumentModified con PageIndex = -1, e un viewer che ricarica un file non ha alcun motivo di ricevere una notifica di modifica per la pulizia interna del vecchio documento. Dentro lo smontaggio l'handler viene salvato, messo a nil attorno a quella chiamata e ripristinato in un blocco finally. Un ricaricamento produce comunque una chiamata con PageIndex = -1, sollevata dal caricamento stesso quando azzera la cache per il nuovo documento, ed è quella su cui un viewer dovrebbe ridisegnare. Nel distruttore le cache sono già smontate quando il grafo si chiude, quindi lì lo smontaggio salta del tutto l'invalidazione
procedure TViewerForm.FormCreate(Sender: TObject);
begin
FPdf := THotPDF.Create(Self);
FPdf.OnLoadedDocumentModified := PdfModified;
end;
procedure TViewerForm.PdfModified(Sender: TObject; PageIndex: Integer);
begin
if PageIndex = -1 then
RepaintAllPages // metodo del viewer vostro
else
RepaintPage(PageIndex); // metodo del viewer vostro
end;
procedure TViewerForm.OpenDocument(const FileName: string);
begin
// LoadFromFile annulla un prefetch in corso e rilascia gli oggetti del
// documento precedente prima del parsing; PdfModified vede una chiamata con -1
FPdf.LoadFromFile(FileName);
FPdf.PrefetchLoadedPages('1-10', 150);
end;
In OpenDocument non serve nessuna chiamata esplicita a CancelLoadedPagePrefetch, perché la esegue il caricamento. Chiamarla da voi resta la mossa giusta quando state per modificare il documento caricato mentre un prefetch potrebbe essere in corso; il commento di dichiarazione in HPDFDoc.pas lo chiede esattamente così. Per il rendering con pool di worker su scala più grande, l'articolo sulla pipeline di rendering parallelo copre come il lavoro in background viene limitato
Come si distingue un leak dalla ritenzione dell'allocatore?
Osservate il conteggio delle allocazioni vive del memory manager attraverso caricamenti ripetuti, e ignorate il working set del processo. Il memory manager di Delphi tiene la memoria liberata in pool per il riuso, quindi un processo che resta fermo su un footprint alto dopo la chiusura di un documento non ha necessariamente leakato nulla. Un processo il cui conteggio di grandi blocchi vivi sale a ogni ricaricamento sì. Il ciclo qui sotto carica lo stesso file venti volte su una istanza e stampa le cifre dei grandi blocchi dopo ogni passata
procedure ReportLargeBlocks(const Tag: string);
var
State: TMemoryManagerState;
begin
GetMemoryManagerState(State);
Writeln(Tag, ': ', State.AllocatedLargeBlockCount, ' large blocks, ',
State.TotalAllocatedLargeBlockSize, ' bytes');
end;
var
Pdf: THotPDF;
I: Integer;
begin
Pdf := THotPDF.Create(nil);
try
for I := 1 to 20 do
begin
Pdf.LoadFromFile('sample.pdf');
ReportLargeBlocks('pass ' + IntToStr(I));
end;
finally
Pdf.Free;
end;
ReportLargeBlocks('after Free');
end.
Leggete l'output come una forma. Numeri che si assestano dopo la prima passata sono ritenzione o stato ordinario per documento. Numeri che salgono dello stesso passo a ogni passata sono un leak, e per un PDF il passo è di solito la dimensione dei payload di stream più grandi, contenuti di pagina e immagini incorporate, dato che sono quelle le allocazioni abbastanza grosse da finire nelle cifre dei grandi blocchi. Usate un file con una dozzina di pagine o più, così il passo è facile da vedere contro il rumore. Una crescita proporzionale al numero di pagine è qualcosa che la ritenzione dell'allocatore non sa spiegare. Per scoprire quali oggetti trattengono memoria mentre un documento è aperto, il grafo di dipendenza degli oggetti con byte trattenuti risponde a quel lato della domanda
Riusare il pattern nel vostro modello a oggetti Delphi
Nulla di tutto questo è specifico del PDF. Qualunque modello a oggetti Delphi in cui i distruttori possiedono i figli in modo incoerente, in cui un figlio è raggiungibile da parecchi genitori, o in cui i back-pointer stanno accanto ai puntatori in avanti andrà in crash o leakerà sotto un Free per oggetto. La soluzione ha la stessa forma ogni volta: classificate ogni campo puntatore come possessore o riferimento, raccogliete la chiusura degli archi possessori attraverso un insieme di puntatori che tolleri le revisite, tagliate ogni arco, distruggete la lista piatta. Il passo del taglio è quello che la gente salta. Saltatelo e ogni distruttore finisce per dover conoscere ogni altro
Il pattern arriva con dei confini che vale la pena scrivere prima di copiarlo. L'insieme dei puntatori usa l'indirizzo dell'oggetto come identità, il che è valido solo perché nessun distruttore gira durante la raccolta, così nessun indirizzo può essere liberato e consegnato a una nuova allocazione a metà visita. La visita vede solo i tipi di arco per cui è stata scritta; una nuova classe che possiede un figlio attraverso qualche altro campo va aggiunta sia alla passata di raccolta sia a quella di taglio. E siccome i collegamenti vengono risolti invece che seguiti, un oggetto referenziato solo da un collegamento deve stare nel registro per essere raggiunto. Il parser HotPDF registra ogni oggetto che legge, e un grafo costruito a mano nel vostro codice deve mantenere la stessa regola
Provate contando le chiamate ai distruttori invece dei byte. Costruite a mano il grafo cattivo: un dizionario condiviso da due stream, un array che contiene sia quel dizionario sia il proprio genitore, un payload assegnato a entrambi gli stream, una radice registrata due volte, e un wrapper il cui corpo numerico è registrato anch'esso. Smontatelo e aspettatevi esattamente una distruzione per oggetto unico — un payload, due stream, due dizionari, un array, un wrapper, un numero. Nelle vostre classi basta un contatore a livello di classe incrementato in ogni distruttore (un hook di strumentazione che aggiungete voi), e uno smontaggio che si limita a svuotare la lista riferisce zero
In HotPDF tutto questo sta dietro LoadFromFile, LoadFromStream e il distruttore, quindi il codice applicativo vede solo il risultato: chiudere o ricaricare un documento restituisce la sua memoria. HotPDF è una libreria PDF VCL nativa per Delphi e C++Builder che viene spedita con i sorgenti completi, così lo smontaggio descritto qui può essere letto riga per riga in HPDFDoc.pas; il riferimento API e una build di prova sono sulla pagina dell'HotPDF PDF component for Delphi