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 acabar como una lista plana de todos los objetos más un grafo de punteros colgando de ella. Recorra esa lista llamando a Free en cada entrada y la ejecución 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. Sustituya el bucle por List.Clear y el crash desaparece, pero el proceso ahora crece más o menos un documento cada vez que usted recarga. Ambos síntomas tienen una causa: la lista es plana y la propiedad es un grafo. El teardown que funciona decide qué punteros poseen sus destinos, recoge ese cierre una vez, corta cada arista propietaria y solo entonces libera cada nodo, exactamente una vez

Por qué liberar cada objeto registrado provoca un crash

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

Tres destructores en HPDFObjs.pas y HPDFDoc.pas montan esta situación. 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 guarda, indirecto o no. THPDFIndirectObject, el wrapper usado para objetos sacados de object streams (ISO 32000-1 §7.5.7), libera su cuerpo InternalObject. Imagine ahora un registro que guarda un diccionario indirecto, un array que lista ese mismo diccionario en una posición, y un wrapper cuyo cuerpo está también registrado como raíz propia. Libere primero el array y el diccionario ya no existe cuando el registro llegue a él. Libere el wrapper y después el cuerpo, y la segunda llamada ejecuta un destructor sobre memoria que ya no pertenece a nadie. Libere solo el diccionario y cada hijo indirecto que se saltó permanece asignado hasta la salida del proceso

Reordenar el registro no puede rescatar esto. Sea cual sea el orden que 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 posee la entrada 40

Por qué liberar cada entrada del registro de HotPDF petaba: THPDFDictionaryObject.Destroy se salta los hijos indirectos, mientras THPDFArrayObject.Destroy libera todo lo que guarda y THPDFIndirectObject.Destroy libera su cuerpo InternalObject, así que con un wrapper, un array y un diccionario compartido en una misma lista plana IndirectObjects hay memoria que muere dos veces y memoria que no muere nunca
Los destructores no se ponen de acuerdo sobre quién posee qué, y el registro guarda entradas en varios niveles de la misma cadena de propiedad, así que ningún orden de una lista plana convierte un Free ingenuo por objeto en un teardown correcto

Aristas propietarias y referencias en un grafo de objetos PDF

Una arista propietaria es un puntero cuyo destino el origen tiene que destruir. Todo lo demás es una referencia, y el teardown sigue solo la primera clase. En HotPDF hay exactamente cuatro clases de arista propietaria: 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 bucle infinito o una lectura de memoria liberada. Un THPDFLink guarda 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 almacenado en otro lugar del archivo. Resolver ese número a través del registro aterriza en un nodo que otra arista ya posee, así que el teardown nunca desreferencia enlaces. El back-pointer FParent que diccionarios y arrays guardan corre en la dirección contraria. El padre ya posee al hijo, y subir por ese puntero solo volvería a visitar un nodo que el recorrido ya pasó. Ambas clases quedan intactas durante todo el teardown

Aristas propietarias frente a referencias en el grafo de objetos de HotPDF: los Items de DictionaryObject, los Items de ArrayObject, el InternalObject de IndirectObject y las dos mitades de un StreamObject se siguen y se desconectan, mientras que el número de objeto de un THPDFLink y el back-pointer FParent son nombres de objetos que viven en otro sitio, y CloseIndirectObjects nunca los desreferencia
Una arista propietaria es un puntero cuyo destino el origen tiene que destruir; seguir una referencia en su lugar convertiría el recorrido en anchura en un bucle infinito o un use-after-free, así que los links y los punteros al padre se dejan en paz

Recoger, desconectar, liberar: el teardown en tres pasadas

HotPDF libera los objetos de un documento en tres pasadas dentro del método protegido THotPDF.CloseIndirectObjects, y el orden de esas pasadas es todo el truco. La pasada uno recoge. Una worklist empieza con cada entrada del registro y, para cada nodo, el recorrido añade los destinos de sus aristas propietarias, saltándose lo que ya haya visto. El conjunto de vistos es un array de direccionamiento abierto de punteros crudos con sondeo lineal que se dobla al llenarse hasta la mitad, así que nada se asigna por nodo, lo que empieza a importar cuando un documento lleva cientos de miles de objetos. Los payloads de stream van a una segunda lista porque son descendientes de TStream y no nodos THPDFObject

El teardown en tres fases de CloseIndirectObjects en HotPDF: una recogida en anchura siembra la worklist desde IndirectObjects y sigue solo las aristas propietarias a través de un conjunto de vistos de direccionamiento abierto hasheado con HPDFFastCacheHashInt64, la fase dos desconecta cada arista con MarkAsFreed y asignación a nil, y la fase tres libera cada nodo y cada payload de stream exactamente una vez
Cortar las aristas antes de que corra ningún destructor es lo que hace seguro reutilizar los destructores existentes: cada uno se encuentra entonces sin nada en lo que recursar, así que hijos compartidos, ciclos y alias wrapper-cuerpo caen todos sin un double free

Los dos listados que siguen son extractos simplificados de la implementación interna del componente, mostrados para que usted vea las pasadas en los tipos de objeto de la propia biblioteca. El código de aplicación nunca llama a nada de esto; LoadFromFile, LoadFromStream y el destructor lo ejecutan por usted. En el extracto el hash de direcciones queda reducido a un shift y una máscara, y se omite el paso que dobla la tabla del conjunto de vistos 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 dobla y rehashea la tabla al llenarse hasta la mitad)
  Slot := Integer(NativeUInt(Value) shr 4) and (Length(Seen) - 1);
  while Seen[Slot] <> nil do
  begin
    if Seen[Slot] = Pointer(Value) then Exit;   // ya recogido
    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;

// Pasada uno: siembre el registro, después siga solo aristas propietarias
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 nunca se siguen
  Inc(I);
end;

La pasada dos corta. Toda arista propietaria se pone a nil antes de que un solo destructor corra. Un wrapper recibe MarkAsFreed, que limpia InternalObject y activa el flag IsFreed que su destructor comprueba primero. A un objeto de stream se le asignan nil a Dictionary y Stream, a cada item de diccionario se le limpia su Value, y cada posición de array se sobrescribe con nil. La pasada tres limpia el registro, libera cada nodo en Nodes y después cada payload en Streams

// Extracto simplificado del teardown interno, continuación; no es una API invocable
// Pasada dos: desconecte cada arista propietaria 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;

// Pasada 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 quedarse exactamente como están. Sin aristas, cada destructor no encuentra nada en lo que recursar y se destruye solo a sí mismo. El otro camino al mismo resultado es enseñarle el registro a cada destructor, de modo que un diccionario, un array y un wrapper pregunten cada uno si algún otro camino liberará a sus hijos. Eso reparte conocimiento del grafo entre cuatro clases y cada clase que se añada después, y una sola respuesta equivocada en cualquier sitio trae de vuelta el double free. Mantener el modelo de propiedad entero en una única rutina de teardown es el arreglo más seguro

Pause los casos incómodos por aquí y ninguno necesita una rama especial. Un diccionario compartido por dos objetos de stream se recoge una vez, se desconecta de ambos y se libera una vez. Un ciclo en el que un array lista su propio diccionario padre termina porque el conjunto de vistos 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 en paz porque MarkAsFreed ya quitó esa arista. Un TMemoryStream asignado como payload de dos objetos de stream está en Streams exactamente una vez

Qué tiene que pararse antes de que el grafo caiga

Todo lo que tome objetos prestados del grafo tiene que pararse 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 teardown 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 él, así que el orden es idéntico sea que usted reemplace un documento o libere el componente, y las reglas para reutilizar un THotPDF entre documentos se apoyan en eso

El contrato de notificación 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 teardown el handler se guarda, se pone a nil alrededor de esa llamada y se restaura en un bloque finally. Una recarga sigue produciendo una llamada con PageIndex = -1, disparada por la propia carga 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 liberadas cuando el grafo se cierra, así que ahí el teardown 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           // método de su propio visor
  else
    RepaintPage(PageIndex);   // método de su propio visor
end;

procedure TViewerForm.OpenDocument(const FileName: string);
begin
  // LoadFromFile cancela un prefetch en marcha y libera los objetos del documento
  // anterior antes de parsear; PdfModified ve una 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é a punto de 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 distingues una fuga de la retención del allocator?

Observe el conteo de asignaciones vivas del memory manager a lo largo de cargas repetidas, e ignore el working set del proceso. El memory manager de Delphi mantiene la memoria liberada en pool para reutilizarla, así que un proceso que se mantiene estable en una huella alta tras cerrar un documento no ha necesariamente filtrado nada. Un proceso cuyo conteo de bloques grandes vivos sube en cada recarga, sí. El bucle de abajo carga el mismo archivo veinte veces en una instancia e imprime las cifras de bloques grandes tras 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 asientan tras la primera pasada son retención o estado ordinario por documento. Números que suben 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 mayores, contenido de página e imágenes incrustadas, porque esas son las asignaciones lo bastante grandes para caer en las cifras de bloques grandes. Use un archivo con una docena de páginas o más para que el paso sea fácil de ver contra el ruido. Un crecimiento proporcional al número de páginas es algo que la retención del allocator no puede explicar. Para saber qué objetos retienen memoria mientras un documento está abierto, el grafo de dependencias de objetos con bytes retenidos responde ese lado de la pregunta

Reutilizar el patrón en su propio modelo de objetos Delphi

Nada de esto es específico de PDF. Cualquier modelo de objetos Delphi donde los destructores poseen hijos de forma inconsistente, donde un hijo es alcanzable desde varios padres, o donde back-pointers se sientan junto a punteros hacia delante crasheará o filtrará bajo un Free por objeto. El arreglo tiene la misma forma siempre: clasifique cada campo puntero como propietario o referencia, recoja el cierre de aristas propietarias a través de un conjunto de punteros que tolere revisitas, corte cada arista, destruya la lista plana. El paso de corte es el que la gente se salta. Sáltelo y cada destructor acaba necesitando conocer a todos los demás

El patrón viene con fronteras que conviene 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 recogida, así que ninguna dirección puede liberarse y entregarse a una asignación nueva a mitad de recorrido. El recorrido solo ve las clases de arista para las que se escribió; una clase nueva que posea un hijo a través de otro campo tiene que añadirse tanto a la pasada de recogida como a la de desconexión. Y como los enlaces se resuelven en lugar de seguirse, un objeto referenciado solo por un enlace tiene que estar en el registro para ser alcanzado. 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. Construya a mano el grafo desagradable: un diccionario compartido por dos streams, un array que guarda ese diccionario y su propio padre, un payload asignado a ambos streams, una raíz registrada dos veces, y un wrapper cuyo cuerpo numérico está también registrado. Desmóntelo y espere exactamente una destrucción por objeto único — un payload, dos streams, dos diccionarios, un array, un wrapper, un número. En sus propias clases basta un contador de clase incrementado en cada destructor (un hook de instrumentación que usted añade), y un teardown 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 biblioteca PDF VCL nativa para Delphi y C++Builder que se distribuye con fuente completa, así que el teardown descrito aquí se puede leer línea a 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