Articolo tecnico

HotPDF: object graph PDF liberato una volta sola in Delphi

HotPDF Delphi Component rilascia ogni oggetto PDF di proprietà di un documento quando quel documento si chiude o si ricarica: THotPDF.CloseIndirectObjects percorre il registry degli oggetti, raccoglie ogni arco di ownership in un pointer set, stacca tutti quegli archi, e solo allora libera ogni nodo unico e ogni payload di stream esattamente una volta. È quell'ordine in tre fasi a far scendere figli condivisi, cicli di ownership, registrazioni duplicate e alias wrapper/body senza double free e senza lasciare niente indietro. Prima della v2.752.4 la stessa routine faceva qualcosa di molto più semplice e molto peggio: rilasciava le sorgenti di stream file lazy, chiamava Clear sulla lista IndirectObjects, liberava il contenitore della lista, e lasciava ogni oggetto PDF vero all'exit del processo. Il commento in quel codice era anche onesto in merito. Liberare gli oggetti uno per uno causava access violation, quindi l'"approccio sicuro" era non liberarli affatto. Questo post parla di perché l'approccio individuale crashava davvero, e di come sia fatto un teardown che funziona in un linguaggio con gestione manuale della memoria

Perché non puoi semplicemente fare Free di ogni oggetto registrato?

Perché i distruttori delle classi di oggetto non sono d'accordo su chi possiede cosa, e il registry contiene entry a diversi livelli della stessa catena di ownership. Percorrere la lista e chiamare Free su ogni entry libera quindi un po' di memoria due volte e un po' mai, a seconda di quali classi si trovino per caso una accanto all'altra

Tre asimmetrie in HPDFObjs.pas e HPDFDoc.pas creano il problema. THPDFDictionaryObject.Destroy percorre i suoi Items e libera un valore solo quando IsIndirect è False, partendo dal presupposto che i figli indiretti appartengano al registry e vengano liberati lì. THPDFArrayObject.Destroy non fa questa distinzione e libera ogni item che contiene. E THPDFIndirectObject.Destroy, il wrapper che porta un numero di oggetto, libera il suo body InternalObject. Considera ora un registry che contiene un dizionario indiretto, un array che elenca quello stesso dizionario in uno dei suoi slot, e un wrapper il cui body è registrato anche come root separata, che è esattamente quello che il parser produce su file reali. Libera prima l'array e il dizionario è già sparito quando il registry lo raggiunge. Libera il wrapper e il body, in qualunque ordine, e la seconda chiamata esegue un distruttore su un puntatore penzolante. Libera il solo dizionario e qualunque figlio indiretto che ha saltato resta allocato per sempre. Nessun ordine del registry sistema questo, perché il registry è una lista piatta e la relazione di ownership è un grafo, e ragionare sul grafo è l'unica via d'uscita

Perché liberare ogni entry del registry HotPDF crashava: THPDFDictionaryObject.Destroy salta i figli indiretti mentre THPDFArrayObject.Destroy libera tutto ciò che contiene e THPDFIndirectObject.Destroy libera il suo body InternalObject, quindi con un wrapper, un array e un dizionario condiviso nella stessa lista piatta IndirectObjects un po' di memoria muore due volte e un po' mai
I distruttori non sono d'accordo su chi possiede cosa, e il registry contiene entry a diversi livelli della stessa catena di ownership, quindi nessun ordine di una lista piatta può trasformare un Free ingenuo per oggetto in un teardown corretto

Cosa conta come arco di ownership in un object graph PDF?

Un arco di ownership è un puntatore il cui target la sorgente è responsabile di distruggere; un riferimento è qualunque altra cosa, e il teardown deve seguire i primi e ignorare i secondi. In HotPDF questo dà esattamente quattro tipi di arco: gli Items di un THPDFDictionaryObject, gli Items di un THPDFArrayObject, l'InternalObject dietro un THPDFIndirectObject, ed entrambe le metà di un THPDFStreamObject, il suo Dictionary e il suo payload Stream. I tipi di riferimento contano altrettanto, perché seguirne uno trasforma una visita del grafo in un ciclo infinito o in un use-after-free. Un THPDFLink porta un numero di oggetto e una generazione, che è come ISO 32000-1 §7.3.10 definisce un riferimento indiretto: un nome per un oggetto che vive altrove, non l'oggetto stesso. Risolvere quel numero attraverso il registry produce un nodo che qualche altro arco già possiede, quindi CloseIndirectObjects non dereferenzia mai i link. Il back-pointer FParent che dizionari e array mantengono è la stessa storia nella direzione opposta; il parent possiede già il figlio, quindi seguire il puntatore verso l'alto rivedrebbe solo un nodo già visitato. Entrambi vengono lasciati stare, e il commento nel sorgente lo dice in una riga: link e parent pointer sono riferimenti, non archi di ownership

Archi di ownership contro riferimenti nell'object graph di HotPDF: DictionaryObject Items, ArrayObject Items, IndirectObject InternalObject ed entrambe le metà di uno StreamObject vengono seguiti e staccati, mentre il numero di oggetto di un THPDFLink e il back-pointer FParent sono nomi per oggetti che vivono altrove, quindi CloseIndirectObjects non li dereferenzia mai
Un arco di ownership è un puntatore il cui target la sorgente deve distruggere; seguire un riferimento invece trasformerebbe la visita in ampiezza in un ciclo infinito o in un use-after-free, quindi link e parent pointer vengono lasciati stare

Come funziona il teardown in tre fasi?

La fase uno è una raccolta in ampiezza. La routine inizializza una worklist con ogni entry di IndirectObjects, poi per ogni nodo accoda i target degli archi di ownership di quel nodo, saltando tutto ciò che ha già visto. Il seen-set è un array a open addressing di puntatori grezzi, con hash calcolato da HPDFFastCacheHashInt64 sul valore del puntatore, probing lineare e un GrowSeen che raddoppia quando arriva a metà pieno. Niente in quella struttura alloca per nodo, cosa che conta quando un documento porta qualche centinaio di migliaia di oggetti. I payload degli stream finiscono in una lista Streams separata perché sono discendenti di TStream e non nodi THPDFObject, e vengono liberati in una passata loro

Il teardown in tre fasi di CloseIndirectObjects in HotPDF: una raccolta in ampiezza inizializza la worklist da IndirectObjects e segue solo gli archi di ownership attraverso un seen-set a open addressing con hash HPDFFastCacheHashInt64, la fase due stacca ogni arco con MarkAsFreed e assegnazione a 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 sicuro riusare i distruttori esistenti: ognuno poi non trova nulla in cui ricorrere, quindi figli condivisi, cicli e alias wrapper-body scendono tutti senza double free
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;   // 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;

// Fase uno: si parte dal registry, poi si seguono solo gli archi di ownership
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;

La fase due è la parte che rende sicuro eseguire i distruttori: ogni arco di ownership viene messo a nil prima che parta qualunque distruttore. Un wrapper riceve MarkAsFreed, che azzera FInternalObject e imposta il flag che il suo distruttore controlla per primo. Un oggetto stream ha Dictionary e Stream assegnati a nil. Ogni item di dizionario ha Item^.Value azzerato e ogni slot di array viene sovrascritto con nil. Dopo questa passata nel grafo non resta nessun arco, quindi quando la fase tre chiama Free su ogni nodo in Nodes e poi su ogni payload in Streams, ogni distruttore non trova nulla in cui ricorrere e distrugge solo se stesso

// Fase due: stacca ogni arco di ownership 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;

// Fase 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);

Guarda cosa compra la separazione. 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 padre termina perché il seen-set rifiuta la seconda visita. Un wrapper e il suo body registrati entrambi come root sono due puntatori distinti nell'insieme, quindi vengono liberati entrambi, e il distruttore del wrapper non prova più a liberare il body perché MarkAsFreed ha già portato via quell'arco. Un singolo TMemoryStream assegnato come payload di due oggetti stream sta in Streams esattamente una volta. Nessuno di questi casi ha bisogno di una gestione speciale, ed è il segno che il modello è giusto

Come distingui una perdita dalla ritenzione dell'allocatore?

Controllando se il conteggio delle allocazioni vive del memory manager si muove con il carico di lavoro, non solo il suo footprint riservato. Un memory manager Delphi tiene in giro i blocchi grandi liberati per riusarli, quindi un processo che resta a 400 MiB dopo aver chiuso un documento non ha necessariamente perso memoria; un processo il cui conteggio di blocchi vivi sale di uno per pagina per esecuzione sì. La sonda che ha guidato questa correzione era volutamente piccola: un writer THotPDF che produce una singola pagina, poi tre reader che la caricano. Dopo che tutti e quattro erano stati liberati, il report dell'heap mostrava esattamente quattro allocazioni vive da 512 KiB, una per istanza, che è il payload del content stream che ognuna possedeva e non rilasciava mai. Aumentando la scala lo stesso schema diventava inequivocabile. Eseguire due volte la pipeline di rendering parallela spostava la cifra dei blocchi grandi allocati da 384 MiB a 640 MiB, un aumento proporzionale al numero di pagine che la ritenzione dell'allocatore non può spiegare. Dopo la riscrittura, la diagnostica su singola pagina riportava zero byte grandi allocati e zero riservati una volta sparite le istanze. Se stai cacciando lo stesso tipo di crescita nel tuo processo, l'object dependency graph con i byte trattenuti ti dice quali oggetti hanno la memoria mentre il documento è aperto; questo post parla del loro comportamento di rilascio quando il documento si chiude

Le soglie di memoria rendono fragili i test di regressione, quindi i test rilasciati contano le chiamate ai distruttori invece. Una fixture costruisce a mano il grafo patologico, con un dizionario condiviso sotto due stream, un array che contiene sia il dizionario condiviso sia la propria root, un payload assegnato a entrambi gli stream, la root registrata due volte, e un wrapper il cui body è registrato separatamente, poi libera il documento e verifica una distruzione per oggetto unico: un payload, due stream, due dizionari, un array, un wrapper, un numero. Con il vecchio codice tutti e tre i test di lifetime riportavano zero distruzioni, che è l'affermazione più diretta possibile di cosa significhi "lasciali all'exit del processo"

Cosa deve succedere prima che il grafo scenda?

Qualunque lavoro in background che prende in prestito oggetti dal grafo deve prima fermarsi, e qualunque cache che tiene display list o bitmap compilate da quegli oggetti va scartata, altrimenti un thread worker o un riferimento in cache legge memoria liberata. CloseIndirectObjects apre quindi con CancelLoadedPagePrefetch, poi invalida la cache delle pagine renderizzate prima di toccare il registry. Il percorso di reload in LoadFromFile e LoadFromStream e il distruttore del componente passano entrambi da lì, quindi lo stesso ordine vale sia che tu stia sostituendo un documento sia che tu stia smaltendo l'istanza; le regole per riusare un unico THotPDF su più documenti si appoggiano a quella garanzia. Due dettagli di quel preambolo sono venuti fuori solo eseguendo i test. Primo, il distruttore ha già smaltito gli sketch di frequenza dietro le cache di render e display list nel momento in cui chiude il grafo, quindi l'invalidazione è protetta dal fatto che quei campi siano non nil invece di essere chiamata incondizionatamente. Secondo, InvalidateRenderedPageCache è la routine che solleva OnLoadedDocumentModified con un page index di -1, e chi ricarica un file non dovrebbe ricevere una notifica di modifica per il teardown interno del vecchio documento. L'handler viene salvato, messo a nil intorno alla chiamata e ripristinato in un finally, e la regressione sul reload verifica un conteggio di notifiche pari a zero dopo il secondo LoadFromStream. Una correzione sulla memoria che cambia in silenzio un contratto sugli eventi è una regressione con una PR migliore, quindi si prende una sua asserzione. Se esegui la pipeline di rendering parallela su un documento e poi lo ricarichi, il passo di cancel è ciò che impedisce al pool di worker di fare gara con il teardown

Riusare il pattern nel tuo codice Delphi

La tecnica non è specifica del PDF. Qualunque modello a oggetti Delphi in cui i distruttori possiedono i figli in modo incoerente, in cui lo stesso figlio può essere raggiunto da più parent, o in cui back-pointer e forward pointer convivono, andrà in crash o perderà memoria con un Free ingenuo per oggetto. La correzione ha sempre la stessa forma: decidi quali campi puntatore sono di ownership e quali sono riferimenti, raccogli la chiusura degli archi di ownership attraverso un pointer set che tollera le rivisite, taglia ogni arco, poi distruggi la lista piatta. Il passo di taglio è quello che la gente salta, ed è quello che rende sicuro riusare i distruttori esistenti invece di forzare la riscrittura di ogni classe del modello. I confini però vale la pena enunciarli chiaramente. Il pointer set usa l'indirizzo dell'oggetto come identità, quindi un oggetto già liberato il cui indirizzo è stato riusato da una nuova allocazione sarebbe indistinguibile; l'ordinamento garantisce che nessun distruttore giri durante la raccolta, ed è questo che lo esclude. La visita vede solo i quattro tipi di arco che conosce, quindi una nuova classe che possiede un figlio tramite un campo che la visita non ispeziona perderà quel figlio finché la visita non verrà istruita in merito. E dato che i link vengono risolti attraverso il registry invece che seguiti, un oggetto referenziato solo da un link e mai registrato non è raggiungibile da questo teardown; in HotPDF il parser garantisce la registrazione, ma un grafo costruito a mano deve rispettare la stessa regola

Tutto questo sta dentro il componente, quindi l'effetto visibile per un'applicazione è semplicemente che chiudere o ricaricare un documento restituisce la sua memoria, senza cambi di API. HotPDF è una libreria PDF VCL nativa per Delphi e C++Builder con sorgente completo; il riferimento API e una build di prova sono nella pagina del componente HotPDF Delphi PDF