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
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
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
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