Artigo Técnico

Liberar um grafo de objetos PDF uma única vez no Delphi

Um modelo de objetos Delphi que espelha um arquivo PDF parseado normalmente acaba como uma lista plana de todo objeto mais um grafo de ponteiros pendurado nela. Percorra essa lista chamando Free em cada entrada e a execução morre no meio com um access violation dentro de um destructor, num objeto que um Free anterior já liberou através de outro pai. Troque o loop por List.Clear e o crash vai embora, mas o processo agora cresce cerca de um documento a cada reload. Os dois sintomas têm uma causa: a lista é plana e ownership é um grafo. A desmontagem que funciona decide quais ponteiros são donos dos alvos deles, coleta esse fecho uma vez, corta toda aresta de posse e só então libera cada nó, exatamente uma vez

Por que liberar todo objeto registrado dá crash?

No HotPDF a lista plana é o registro de objetos do documento, e as classes que ele guarda divergem sobre quem destrói o quê. Cada destructor é razoável nos próprios termos dele. Junte-os sobre um registro que contém entradas de vários níveis da mesma cadeia de posse, e um Free por entrada libera alguma memória duas vezes e alguma memória nunca

Três destructors em HPDFObjs.pas e HPDFDoc.pas montam esse cenário. THPDFDictionaryObject.Destroy percorre os Items dele e libera um valor só quando IsIndirect é False, presumindo que filhos indiretos pertencem ao registro. THPDFArrayObject.Destroy libera todo item que segura, indireto ou não. THPDFIndirectObject, o wrapper usado para objetos puxados de object streams (ISO 32000-1 §7.5.7), libera o corpo InternalObject dele. Agora imagine um registro segurando um dicionário indireto, um array que lista o mesmo dicionário num slot, e um wrapper cujo corpo também está registrado como raiz própria. Libere o array primeiro e o dicionário já era antes de o registro alcançá-lo. Libere o wrapper e depois o corpo, e a segunda chamada roda um destructor em memória que não pertence mais a ninguém. Libere só o dicionário e todo filho indireto que ele pulou fica alocado até o fim do processo

Reordenar o registro não salva isso. Qualquer ordem que você escolher, algum par de entradas cai no lado errado de uma aresta de posse, porque uma lista não tem como dizer que a entrada 12 é dona da entrada 40

Por que liberar ingenuamente toda entrada do registro do HotPDF dá crash: THPDFDictionaryObject.Destroy pula filhos indiretos enquanto THPDFArrayObject.Destroy libera tudo que segura e THPDFIndirectObject.Destroy libera o corpo InternalObject, então com um wrapper, um array e um dicionário compartilhado numa única lista plana IndirectObjects alguma memória morre duas vezes e alguma nunca
Os destructors divergem sobre quem é dono do quê, e o registro guarda entradas em vários níveis da mesma cadeia de posse, então nenhuma ordenação de uma lista plana transforma um Free ingênuo por objeto numa desmontagem correta

Arestas de posse e referências num grafo de objetos PDF

Uma aresta de posse é um ponteiro cujo alvo a origem é responsável por destruir. Todo o resto é referência, e a desmontagem segue só o primeiro tipo. No HotPDF há exatamente quatro tipos de aresta de posse: os Items de um THPDFDictionaryObject, os Items de um THPDFArrayObject, o InternalObject atrás de um THPDFIndirectObject, e os dois campos de um THPDFStreamObject — o Dictionary dele e o payload Stream

Referências merecem o mesmo cuidado, porque seguir uma transforma uma caminhada de grafo num loop infinito ou numa leitura de memória liberada. Um THPDFLink guarda um número de objeto e geração, que é exatamente a referência indireta da ISO 32000-1 §7.3.10: um nome para um objeto armazenado em outro lugar do arquivo. Resolver esse número pelo registro cai num nó que outra aresta já possui, então a desmontagem nunca desreferencia links. O back-pointer FParent que dicionários e arrays guardam corre na direção oposta. O pai já é dono do filho, e subir esse ponteiro só revisitaria um nó que a caminhada já passou. Ambos os tipos ficam intocados pela desmontagem inteira

Arestas de propriedade versus referências no grafo de objetos do HotPDF: os Items de DictionaryObject, os Items de ArrayObject, o InternalObject de IndirectObject e as duas metades de um StreamObject são seguidos e desligados, enquanto o número de objeto de um THPDFLink e o back-pointer FParent são nomes para objetos que vivem em outro lugar, então o CloseIndirectObjects nunca os desreferencia
Uma aresta de propriedade é um ponteiro cujo alvo a origem precisa destruir; seguir uma referência em vez disso transformaria a caminhada em largura num loop infinito ou num use-after-free, então links e ponteiros de pai ficam em paz

Coletar, desconectar, liberar: a desmontagem em três passadas

O HotPDF libera os objetos de um documento em três passadas dentro do método protegido THotPDF.CloseIndirectObjects, e a ordem dessas passadas é todo o truque. A passada um coleta. Uma worklist começa com toda entrada do registro, e para cada nó a caminhada anexa os alvos das arestas de posse dele, pulando qualquer coisa que já tenha visto. O seen-set é um array de ponteiros crus com open addressing e linear probing que dobra quando enche metade, então nada é alocado por nó, o que começa a importar quando um documento carrega centenas de milhares de objetos. Payloads de stream vão para uma segunda lista porque são descendentes de TStream e não nós THPDFObject

O teardown em três fases do CloseIndirectObjects no HotPDF: uma coleta em largura semeia a worklist a partir de IndirectObjects e segue apenas arestas de propriedade por um seen-set de endereçamento aberto com hash HPDFFastCacheHashInt64, a fase dois desliga cada aresta com MarkAsFreed e atribuição de nil, e a fase três libera cada nó e cada payload de stream exatamente uma vez
Cortar as arestas antes de qualquer destructor rodar é o que torna os destructors existentes seguros de reutilizar: cada um deles então não encontra nada em que recursar, então filhos compartilhados, ciclos e aliases wrapper-corpo descem todos sem double free

As duas listagens a seguir são trechos simplificados da implementação interna do componente, mostrados para você ver as passadas nos tipos de objeto da própria biblioteca. Código de aplicação nunca chama nada disso; LoadFromFile, LoadFromStream e o destructor rodam para você. No trecho, o hash de endereço está reduzido a um shift e uma máscara, e o passo que dobra a tabela do seen-set quando ela enche ficou de fora

// Simplified excerpt of the internal teardown, not a callable API
procedure Collect(Value: TObject; Payload: boolean);
var
  Slot: Integer;
begin
  if Value = nil then Exit;
  // (the real routine doubles and rehashes the table once it is half full)
  Slot := Integer(NativeUInt(Value) shr 4) and (Length(Seen) - 1);
  while Seen[Slot] <> nil do
  begin
    if Seen[Slot] = Pointer(Value) then Exit;   // já coletado
    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;

// Passada um: semeie com o registro, depois siga só arestas de posse
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);
  // valores THPDFLink e ponteiros FParent nunca são seguidos
  Inc(I);
end;

A passada dois corta. Toda aresta de posse vira nil antes de um único destructor rodar. Um wrapper recebe MarkAsFreed, que limpa o InternalObject e arma a flag IsFreed que o destructor dele confere primeiro. Um stream object tem Dictionary e Stream atribuídos nil, cada item de dicionário tem o Value limpo, e cada slot de array é sobrescrito com nil. A passada três limpa o registro, libera todo nó em Nodes e depois todo payload em Streams

// Simplified excerpt of the internal teardown, continued; not a callable API
// Passada dois: desconecte toda aresta de posse antes de liberar qualquer coisa
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;

// Passada três: cada nó e payload único é liberado exatamente uma 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);

O corte é o que deixa os destructors existentes ficarem exatamente como estão. Sem arestas restantes, cada destructor não encontra nada para onde recursar e destrói só a si mesmo. O outro caminho para o mesmo resultado seria ensinar a todo destructor sobre o registro, para que um dicionário, um array e um wrapper perguntem cada um se algum outro caminho vai liberar os filhos deles. Isso espalha conhecimento de grafo por quatro classes e por toda classe adicionada depois, e uma resposta errada em qualquer lugar traz o double free de volta. Manter o modelo de posse inteiro numa única rotina de desmontagem é o arranjo mais seguro

Rode os casos espinhosos por ela e nenhum precisa de um ramo especial. Um dicionário compartilhado por dois stream objects é coletado uma vez, desconectado dos dois e liberado uma vez. Um ciclo em que um array lista o próprio dicionário pai dele termina porque o seen-set recusa a segunda visita. Um wrapper e o corpo dele registrados como duas raízes são dois ponteiros distintos no conjunto, então ambos são liberados, e o destructor do wrapper deixa o corpo em paz porque MarkAsFreed já removeu essa aresta. Um único TMemoryStream atribuído como payload de dois stream objects fica em Streams exatamente uma vez

O que precisa parar antes de o grafo cair?

Qualquer coisa que pegue objetos emprestados do grafo precisa parar primeiro. O HotPDF pode renderizar páginas numa worker thread via PrefetchLoadedPages, e ele mantém em cache bitmaps e display lists compilados a partir dos objetos do documento; uma worker ou uma entrada de cache que sobrevivesse ao grafo leria memória liberada. A desmontagem por isso abre com CancelLoadedPagePrefetch e invalida o cache de páginas renderizadas antes de tocar no registro. LoadFromFile, LoadFromStream e o destructor de THotPDF passam todos por ele, então a ordem é idêntica se você trocar de documento ou liberar o componente, e as regras para reutilizar um THotPDF entre documentos se apoiam nisso

O contrato de notificação importa tanto quanto. InvalidateRenderedPageCache é o método que dispara OnLoadedDocumentModified com PageIndex = -1, e um viewer que recarrega um arquivo não tem por que receber uma notificação de edição da limpeza interna do documento velho. Dentro da desmontagem o handler é salvo, posto nil em volta daquela chamada e restaurado num bloco finally. Um reload ainda produz uma chamada PageIndex = -1, disparada pela própria carga quando ela reseta o cache para o documento novo, e essa é a que um viewer deve repintar. No destructor os caches já foram descartados quando o grafo fecha, então a desmontagem pula a invalidação ali 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 viewer seu
  else
    RepaintPage(PageIndex);   // método de viewer seu
end;

procedure TViewerForm.OpenDocument(const FileName: string);
begin
  // LoadFromFile cancela um prefetch em curso e libera os objetos do
  // documento anterior antes de parsear; PdfModified vê uma chamada -1
  FPdf.LoadFromFile(FileName);
  FPdf.PrefetchLoadedPages('1-10', 150);
end;

Nenhuma chamada explícita a CancelLoadedPagePrefetch é precisa em OpenDocument, porque a carga a faz. Chamar você mesmo ainda é o movimento certo quando está prestes a modificar o documento carregado enquanto um prefetch pode estar rodando; o comentário da declaração em HPDFDoc.pas pede exatamente isso. Para renderização em pool de workers em escala maior, o artigo sobre o pipeline de render paralelo cobre como o trabalho em segundo plano é estrangulado

Como distinguir um vazamento da retenção do allocator?

Observe a contagem de alocações vivas do memory manager através de cargas repetidas, e ignore o working set do processo. O memory manager do Delphi mantém memória liberada em pool para reuso, então um processo que se mantém estável num footprint alto depois de fechar um documento não necessariamente vazou nada. Um processo cuja contagem de large blocks vivas sobe a cada reload vazou. O loop abaixo carrega o mesmo arquivo vinte vezes numa única instância e imprime os números de large blocks depois de cada passada

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.

Leia a saída como uma forma. Números que se estabilizam depois da primeira passada são retenção ou estado ordinário por documento. Números que sobem pelo mesmo passo a cada passada são um leak, e num PDF o passo costuma ser o tamanho dos payloads de stream maiores, conteúdo de página e imagens embutidas, já que essas são as alocações grandes o bastante para cair nos números de large blocks. Use um arquivo com uma dúzia ou mais de páginas para o passo ficar fácil de ver contra o ruído. Crescimento proporcional à contagem de páginas é algo que retenção de alocador não explica. Para descobrir quais objetos seguram memória enquanto um documento está aberto, o grafo de dependência de objetos com bytes retidos responde esse lado da pergunta

Reusando o padrão no seu próprio modelo de objetos Delphi

Nada aqui é específico de PDF. Qualquer modelo de objetos Delphi em que destructors têm filhos de forma inconsistente, em que um filho é alcançável por vários pais, ou em que back-pointers ficam ao lado de ponteiros para a frente vai dar crash ou vazar sob um Free por objeto. O conserto tem a mesma forma toda vez: classifique cada campo ponteiro como posse ou referência, colete o fecho das arestas de posse através de um conjunto de ponteiros que tolera revisitas, corte toda aresta, destrua a lista plana. O passo de cortar é o que as pessoas pulam. Pule-o e todo destructor acaba precisando conhecer todos os outros

O padrão vem com fronteiras que valem anotar antes de copiar. O conjunto de ponteiros usa o endereço do objeto como identidade, o que só é sólido porque nenhum destructor roda durante a coleta, então nenhum endereço pode ser liberado e entregue a uma alocação nova no meio da caminhada. A caminhada só vê os tipos de aresta para os quais foi escrita; uma classe nova que possui um filho através de outro campo precisa entrar tanto na passada de coleta quanto na de corte. E como links são resolvidos em vez de seguidos, um objeto referenciado só por um link precisa estar no registro para ser alcançado. O parser do HotPDF registra todo objeto que lê, e um grafo construído à mão no seu código precisa manter a mesma regra

Teste contando chamadas de destructor em vez de bytes. Construa o grafo maldoso à mão: um dicionário compartilhado por dois streams, um array segurando tanto esse dicionário quanto o próprio pai dele, um payload atribuído aos dois streams, uma raiz registrada duas vezes, e um wrapper cujo corpo numérico também é registrado. Desmonte e espere exatamente uma destruição por objeto único — um payload, dois streams, dois dicionários, um array, um wrapper, um número. Nas suas próprias classes um contador de nível de classe incrementado em cada destructor basta para isso (um hook de instrumentação que você adiciona), e uma desmontagem que só limpa a lista reporta zero

No HotPDF tudo isso fica atrás de LoadFromFile, LoadFromStream e do destructor, então código de aplicação só vê o resultado: fechar ou recarregar um documento devolve a memória dele. O HotPDF é uma biblioteca VCL PDF nativa para Delphi e C++Builder que vem com fonte completa, então a desmontagem descrita aqui pode ser lida linha a linha em HPDFDoc.pas; a referência de API e um build de teste estão na página do HotPDF PDF component for Delphi