Artículo técnico

Liberar el grafo de objetos PDF una sola vez en Delphi

Un modelo de objetos Delphi que refleja un archivo PDF parseado suele terminar como una lista plana de todos los objetos más un grafo de punteros colgando de ella. Recorra esa lista llamando Free en cada entrada y la corrida muere a mitad de camino con un access violation dentro de un destructor, sobre un objeto que un Free anterior ya liberó a través de otro padre. Reemplace el loop con List.Clear y el crash desaparece, pero el proceso ahora crece aproximadamente un documento cada vez que usted recarga. Los dos síntomas tienen una causa: la lista es plana y la propiedad es un grafo. El desarme que funciona decide qué punteros son dueños de sus objetivos, recolecta esa clausura una vez, corta cada arista de propiedad y después libera todo

¿Por qué crashea liberar cada objeto registrado?

En HotPDF la lista plana es el registro de objetos del documento, y las clases que contiene discrepan sobre quién destruye qué. Cada destructor es razonable en sus propios términos. Póngalos juntos sobre un registro que lleva entradas de varios niveles de la misma cadena de propiedad, y un Free por entrada libera algo de memoria dos veces y algo nunca

Tres destructores en HPDFObjs.pas y HPDFDoc.pas arman esta escena. THPDFDictionaryObject.Destroy recorre sus Items y libera un valor solo cuando IsIndirect es False, bajo el supuesto de que los hijos indirectos pertenecen al registro. THPDFArrayObject.Destroy libera cada item que lleva, indirecto o no. THPDFIndirectObject, el wrapper usado para objetos sacados de object streams (ISO 32000-1 §7.5.7), libera su cuerpo InternalObject. Ahora imagine un registro que lleva un diccionario indirecto, un arreglo que lista ese mismo diccionario en una casilla, y un wrapper cuyo cuerpo también está registrado como raíz propia

Reordenar el registro no puede rescatar esto. Sea cual sea el orden que usted elija, algún par de entradas cae en el lado equivocado de una arista de propiedad, porque una lista no tiene forma de decir que la entrada 12 es dueña de la entrada 40

Por qué crasheaba liberar cada entrada del registro de HotPDF: THPDFDictionaryObject.Destroy se salta los hijos indirectos mientras THPDFArrayObject.Destroy libera todo lo que lleva y THPDFIndirectObject.Destroy libera su cuerpo InternalObject, así que con un wrapper, un arreglo y un diccionario compartido en una misma lista plana IndirectObjects algo de memoria muere dos veces y algo nunca
Los destructores discrepan sobre quién es dueño de qué, y el registro lleva entradas en varios niveles de la misma cadena de propiedad, así que ningún ordenamiento de una lista plana puede convertir un Free por objeto en un desarme correcto

Aristas de propiedad y referencias en un grafo de objetos PDF

Una arista de propiedad es un puntero cuyo objetivo la fuente es responsable de destruir. Todo lo demás es una referencia, y el desarme sigue solo el primer tipo. En HotPDF hay exactamente cuatro tipos de arista de propiedad: los Items de un THPDFDictionaryObject, los Items de un THPDFArrayObject, el InternalObject detrás de un THPDFIndirectObject, y ambos campos de un THPDFStreamObject — su Dictionary y su payload Stream

Las referencias merecen el mismo cuidado, porque seguir una convierte un recorrido de grafo en un loop infinito o en una lectura de memoria liberada. Un THPDFLink lleva un número de objeto y una generación, que es exactamente la referencia indirecta de ISO 32000-1 §7.3.10: un nombre para un objeto guardado en otro lugar del archivo. Resolver ese número a través del registro aterriza en un nodo que otra arista ya es dueña, así que el desarme jamás desreferencia enlaces. El puntero hacia atrás FParent que llevan diccionarios y arreglos corre en la dirección opuesta. El padre ya es dueño del hijo, y subir por ese puntero solo revisaría un nodo que el recorrido ya pasó. Ambos tipos quedan intactos durante toda la fase de recolección

Aristas de propiedad contra referencias en el grafo de objetos de HotPDF: los Items de un DictionaryObject, los Items de un ArrayObject, el InternalObject de un IndirectObject y ambas mitades de un StreamObject se siguen y se desenganchan, mientras que el número de objeto de un THPDFLink y el puntero hacia atrás FParent son nombres de objetos que viven en otro lado, así que CloseIndirectObjects jamás los desreferencia
Una arista de propiedad es un puntero cuyo destino la fuente debe destruir; seguir una referencia en su lugar convertiría el recorrido en anchura en un loop infinito o en un use-after-free, así que los enlaces y los punteros a padre quedan intactos durante toda la fase de recolección

Recolectar, desenganchar, liberar: el desarme en tres fases

HotPDF libera los objetos de un documento en tres fases dentro del método protegido THotPDF.CloseIndirectObjects, y el orden de esas fases es todo el truco. La fase uno recolecta. Una worklist arranca con cada entrada del registro, y por cada nodo el recorrido agrega los objetivos de sus aristas de propiedad, saltándose todo lo que ya vio. El seen-set es un arreglo de punteros crudos con direccionamiento abierto y sondeo lineal que se duplica cuando llega a la mitad de su capacidad, así que nada se asigna por nodo, algo que empieza a importar cuando un documento carga cientos de miles de objetos. Los payloads de streams van a una segunda lista porque son descendientes de TStream y no de THPDFObject

El teardown de tres fases de CloseIndirectObjects en HotPDF: una recolección en anchura siembra la worklist desde IndirectObjects y sigue solo aristas de propiedad a través de un seen-set direccionado abierto hasheado con HPDFFastCacheHashInt64, la fase dos desengancha cada arista con MarkAsFreed y asignación nil, y la fase tres libera cada nodo y payload de stream exactamente una vez
Cortar las aristas antes de que corra cualquier destructor es lo que hace seguros de reutilizar a los destructores existentes: cada uno encuentra entonces nada en lo que recursar, así que los hijos compartidos, los ciclos y los alias de cuerpos de wrapper se resuelven con una sola regla

Los dos listados que siguen son extractos simplificados de la implementación interna del componente, mostrados para que usted vea las fases en los tipos de objeto de la propia biblioteca. El código de aplicación jamás llama a nada de esto; LoadFromFile, LoadFromStream y el destructor lo corren por usted. En el extracto el hash de direcciones queda reducido a un shift y una máscara, y se omite el paso que duplica la tabla del seen-set cuando se llena

// Extracto simplificado del teardown interno, no es una API invocable
procedure Collect(Value: TObject; Payload: boolean);
var
  Slot: Integer;
begin
  if Value = nil then Exit;
  // (la rutina real duplica y rehashea la tabla cuando llega a la mitad de su capacidad)
  Slot := Integer(NativeUInt(Value) shr 4) and (Length(Seen) - 1);
  while Seen[Slot] <> nil do
  begin
    if Seen[Slot] = Pointer(Value) then Exit;   // ya recolectado
    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: sembrar con el registro y después seguir solo aristas de propiedad
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);
  // Los valores THPDFLink y los punteros FParent jamás se siguen
  Inc(I);
end;

La fase dos corta. Cada arista de propiedad se pone en nil antes de que corra un solo destructor. Un wrapper recibe MarkAsFreed, que limpia InternalObject y activa el flag IsFreed que su destructor verifica primero. A un stream object se le asigna nil en Dictionary y Stream, a cada item de diccionario se le limpia su Value, y cada casilla de arreglo se sobreescribe con nil. La fase tres limpia el registro, libera cada nodo de Nodes y después cada payload de Streams

// Extracto simplificado del teardown interno, continuación; no es una API invocable
// Fase dos: desenganchar cada arista de propiedad antes de liberar nada
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 tres: cada nodo y payload único se libera exactamente una vez
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);

El corte es lo que deja a los destructores existentes exactamente como están. Sin aristas, cada destructor no encuentra nada en lo que recursar y destruye solo su propio cuerpo. El otro camino al mismo resultado es enseñarle a cada destructor sobre el registro, para que un diccionario, un arreglo y un wrapper pregunten cada uno si algún otro camino liberará a sus hijos. Eso esparce el conocimiento del grafo por cuatro clases y por cada clase que se agregue después, y una sola respuesta equivocada en cualquier lado trae el double free de vuelta. Mantener el modelo de propiedad completo en una única rutina de desarme es el arreglo más seguro

Corra los casos incómodos por ahí y ninguno necesita una rama especial. Un diccionario compartido por dos stream objects se recolecta una vez, se desengancha de ambos y se libera una vez. Un ciclo en el que un arreglo lista a su propio diccionario padre termina porque el seen-set rechaza la segunda visita. Un wrapper y su cuerpo registrados como dos raíces son dos punteros distintos en el conjunto, así que ambos se liberan, y el destructor del wrapper deja el cuerpo tranquilo porque MarkAsFreed ya quitó esa arista. Un TMemoryStream asignado como payload de dos stream objects aparece en Streams exactamente una vez

¿Qué tiene que pasar antes de que el grafo se desarme?

Todo lo que tome prestados objetos del grafo tiene que detenerse primero. HotPDF puede renderizar páginas en un worker thread a través de PrefetchLoadedPages, y cachea bitmaps y display lists compilados desde los objetos del documento; un worker o una entrada de caché que sobreviviera al grafo leería memoria liberada. El desarme por eso abre con CancelLoadedPagePrefetch e invalida la caché de páginas renderizadas antes de tocar el registro. LoadFromFile, LoadFromStream y el destructor de THotPDF pasan todos por ahí, así que el orden es idéntico sea que usted reemplace un documento o libere el componente, y las reglas para reutilizar un THotPDF a través de documentos se apoyan en eso

El contrato de notificaciones importa igual. InvalidateRenderedPageCache es el método que dispara OnLoadedDocumentModified con PageIndex = -1, y un visor que recarga un archivo no tiene por qué recibir una notificación de edición por la limpieza interna del documento viejo. Dentro del desarme el handler se guarda, se pone en nil alrededor de esa llamada y se restaura en un bloque finally. Una recarga igual produce una llamada con PageIndex = -1, disparada por la carga en sí cuando resetea la caché para el documento nuevo, y esa es la que un visor debería usar para repintar. En el destructor las cachés ya están eliminadas para cuando el grafo se cierra, así que ahí el desarme se salta la invalidación por completo

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           // su propio método del visor
  else
    RepaintPage(PageIndex);   // su propio método del visor
end;

procedure TViewerForm.OpenDocument(const FileName: string);
begin
  // LoadFromFile cancela un prefetch en curso y libera los objetos del
  // documento anterior antes de parsear; PdfModified ve una sola llamada con -1
  FPdf.LoadFromFile(FileName);
  FPdf.PrefetchLoadedPages('1-10', 150);
end;

No hace falta ninguna llamada explícita a CancelLoadedPagePrefetch en OpenDocument, porque la carga la hace. Llamarla usted sigue siendo el movimiento correcto cuando está por modificar el documento cargado mientras un prefetch pueda estar corriendo; el comentario de declaración en HPDFDoc.pas pide exactamente eso. Para renderizado con pool de workers a mayor escala, el artículo sobre el pipeline de render paralelo cubre cómo se regula el trabajo en segundo plano

¿Cómo se distingue una fuga de la retención del allocator?

Vigile el contador de asignaciones vivas del memory manager a lo largo de cargas repetidas, e ignore el working set del proceso. El memory manager de Delphi conserva la memoria liberada en un pool para reutilizarla, así que un proceso que se mantiene estable en una huella alta después de cerrar un documento no necesariamente fugó nada. Un proceso cuyo conteo de large blocks vivos sube en cada recarga sí. El loop de abajo carga el mismo archivo veinte veces sobre una instancia e imprime las cifras de large blocks después de cada pasada

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.

Lea la salida como una forma. Números que se estabilizan después de la primera pasada son retención o estado ordinario por documento. Números que suben por el mismo paso en cada pasada son una fuga, y en un PDF el paso suele ser el tamaño de los payloads de stream más grandes, contenido de página e imágenes incrustadas, ya que esas son las asignaciones lo bastante grandes para caer en las cifras de large blocks. Use un archivo de una docena de páginas o más para que el paso se vea fácil contra el ruido. Un crecimiento proporcional al conteo de páginas es algo que la retención del allocator no puede explicar. Para averiguar qué objetos retienen memoria mientras un documento está abierto, el grafo de dependencias de objetos con bytes retenidos lo desglosa por clase

Reutilizar el patrón en su propio código Delphi

Nada de esto es específico de PDF. Cualquier modelo de objetos Delphi donde los destructores son dueños de los hijos de forma inconsistente, donde un hijo es alcanzable desde varios padres, o donde los punteros hacia atrás conviven con los hacia adelante, va a crashear o fugar bajo un Free por objeto. La solución tiene la misma forma todas las veces: clasifique cada campo puntero como propiedad o referencia, recolecte la clausura de las aristas de propiedad a través de un conjunto de punteros que tolere revisiones, corte cada arista, destruya la lista plana. El paso de cortar es el que la gente se salta. Sáltelo y cada destructor termina necesitando saber del resto

El patrón viene con fronteras que vale la pena anotar antes de copiarlo. El conjunto de punteros usa la dirección del objeto como identidad, lo que solo es sólido porque ningún destructor corre durante la recolección, así que ninguna dirección puede liberarse y entregarse a una asignación nueva a mitad del recorrido. El recorrido solo ve los tipos de arista para los que se escribió; una clase nueva que sea dueña de un hijo a través de otro campo tiene que agregarse tanto a la fase de recolección como a la de desenganche. Y como los enlaces se resuelven en lugar de seguirse, un objeto referenciado solo por un enlace debe estar en el registro para poder alcanzarse. El parser de HotPDF registra cada objeto que lee, y un grafo construido a mano en su propio código tiene que mantener la misma regla

Pruébelo contando llamadas a destructores en lugar de bytes. Arme a mano el grafo feo: un diccionario compartido por dos streams, un arreglo que lleva tanto ese diccionario como su propio padre, un payload asignado a ambos streams, una raíz registrada dos veces, y un wrapper cuyo cuerpo numérico también está registrado. Desármelo y espere exactamente una destrucción por objeto único — un payload, dos streams, dos diccionarios, un arreglo, un wrapper, un número. En sus propias clases basta un contador a nivel de clase incrementado en cada destructor (un hook de instrumentación que usted agrega), y un desarme que solo limpia la lista reporta cero

En HotPDF todo esto queda detrás de LoadFromFile, LoadFromStream y el destructor, así que el código de aplicación solo ve el resultado: cerrar o recargar un documento devuelve su memoria. HotPDF es una librería PDF VCL nativa para Delphi y C++Builder que se distribuye con código fuente completo, así que el desarme descrito acá se puede leer línea por línea en HPDFDoc.pas; la referencia de API y una build de prueba están en la página del componente PDF HotPDF para Delphi