Articolo tecnico

HotPDF: object graph PDF liberato una volta sola in Delphi

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

Perché liberare alla cieca ogni voce del registro HotPDF va in crash: THPDFDictionaryObject.Destroy salta i figli indiretti mentre THPDFArrayObject.Destroy libera tutto ciò che contiene e THPDFIndirectObject.Destroy libera il suo corpo InternalObject, così con un wrapper, un array e un dizionario condiviso in un'unica lista piatta IndirectObjects della memoria muore due volte e della memoria mai
I distruttori sono in disaccordo su chi possiede cosa, e il registro contiene voci a parecchi livelli della stessa catena di proprietà, quindi nessun ordinamento di una lista piatta può trasformare un Free per oggetto ingenuo in uno smontaggio corretto

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

Archi possessori contro riferimenti nel grafo di oggetti HotPDF: gli Items di DictionaryObject, gli Items di ArrayObject, l'InternalObject di IndirectObject e entrambe le metà di uno StreamObject vengono seguiti e staccati, mentre il numero di oggetto di una THPDFLink e il back-pointer FParent sono nomi per oggetti che vivono altrove, così CloseIndirectObjects non li dereferenzia mai
Un arco possessore è un puntatore il cui target la sorgente deve distruggere; seguire invece un riferimento trasformerebbe la visita in ampiezza in un ciclo infinito o in un use-after-free, quindi collegamenti e puntatori al genitore vengono lasciati in pace

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

Lo smontaggio in tre fasi di CloseIndirectObjects in HotPDF: una raccolta in ampiezza semina la worklist da IndirectObjects e segue solo gli archi possessori attraverso un insieme dei visti a indirizzamento aperto hashato con HPDFFastCacheHashInt64, la fase due stacca ogni arco con MarkAsFreed e assegnazione nil, e la fase tre libera ogni nodo e payload di stream esattamente una volta
Tagliare gli archi prima che giri qualunque distruttore è ciò che rende i distruttori esistenti sicuri da riusare: ognuno trova allora niente in cui ricorrere, così figli condivisi, cicli e alias wrapper-corpo cadono tutti senza un doppio free

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