Artigo Técnico

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

O HotPDF Delphi Component libera todo objeto PDF que um documento possui quando esse documento fecha ou recarrega: o THotPDF.CloseIndirectObjects percorre o registro de objetos, coleta cada aresta de propriedade num conjunto de ponteiros, desliga todas essas arestas e só então libera cada nó único e cada payload de stream exatamente uma vez. É essa ordem em três fases que permite que filhos compartilhados, ciclos de propriedade, registros duplicados e aliases wrapper/corpo desçam todos sem double free e sem deixar nada para trás. Antes da v2.752.4 a mesma rotina fazia algo muito mais simples e muito pior: liberava as fontes lazy de stream de arquivo, chamava Clear na lista IndirectObjects, liberava o container da lista e deixava todo objeto PDF de verdade para o encerramento do processo recuperar. O comentário naquele código também era honesto quanto a isso. Liberar os objetos individualmente causava access violations, então a "abordagem segura" era não liberá-los de jeito nenhum. Este post é sobre por que a abordagem individual de fato travava, e como é um teardown que funciona numa linguagem com gerenciamento manual de memória

Por que você não pode simplesmente dar Free em todo objeto registrado?

Porque os destructors das classes de objeto discordam sobre quem é dono do quê, e o registro contém entradas em vários níveis da mesma cadeia de propriedade. Percorrer a lista e chamar Free em cada entrada portanto libera alguma memória duas vezes e outra nunca, dependendo de quais classes por acaso ficam lado a lado

Três assimetrias em HPDFObjs.pas e HPDFDoc.pas criam o problema. O THPDFDictionaryObject.Destroy percorre seus Items e só libera um valor quando IsIndirect é False, partindo do princípio de que filhos indiretos pertencem ao registro e serão liberados lá. O THPDFArrayObject.Destroy não faz distinção nenhuma e libera todo item que guarda. E o THPDFIndirectObject.Destroy, o wrapper que carrega um número de objeto, libera seu corpo InternalObject. Agora considere um registro que guarda um dicionário indireto, um array que lista esse mesmo dicionário numa das suas posições e um wrapper cujo corpo também está registrado como raiz separada — que é exatamente o que o parser produz em arquivos reais. Libere o array primeiro e o dicionário já era antes de o registro chegar nele. Libere o wrapper e o corpo, em qualquer ordem, e a segunda chamada roda um destructor sobre um ponteiro pendurado. Libere só o dicionário e todo filho indireto que ele pulou fica alocado para sempre. Nenhuma ordem do registro conserta isso, porque o registro é uma lista plana e a relação de propriedade é um grafo, e raciocinar sobre o grafo é a única saída

Por que dar Free em toda entrada do registro do HotPDF travava: THPDFDictionaryObject.Destroy pula filhos indiretos, enquanto THPDFArrayObject.Destroy libera tudo que guarda e THPDFIndirectObject.Destroy libera seu corpo InternalObject, então com um wrapper, um array e um dicionário compartilhado numa mesma lista plana IndirectObjects parte da memória morre duas vezes e parte nunca
Os destructors discordam sobre quem é dono do quê, e o registro guarda entradas em vários níveis da mesma cadeia de propriedade, então nenhuma ordem de uma lista plana transforma um Free ingênuo por objeto num teardown correto

O que conta como aresta de propriedade num grafo de objetos PDF?

Uma aresta de propriedade é um ponteiro cujo alvo a origem tem a responsabilidade de destruir; referência é qualquer outra coisa, e o teardown precisa seguir o primeiro tipo e ignorar o segundo. No HotPDF isso dá exatamente quatro tipos de aresta: os Items de um THPDFDictionaryObject, os Items de um THPDFArrayObject, o InternalObject por trás de um THPDFIndirectObject e as duas metades de um THPDFStreamObject, seu Dictionary e seu payload Stream. Os tipos de referência importam tanto quanto, porque seguir uma transforma a caminhada no grafo num loop infinito ou num use-after-free. Um THPDFLink guarda um número de objeto e uma geração, que é como a ISO 32000-1 §7.3.10 define uma referência indireta: um nome para um objeto que vive em outro lugar, não o objeto em si. Resolver esse número pelo registro devolve um nó que alguma outra aresta já possui, então o CloseIndirectObjects nunca desreferencia links. O back-pointer FParent que dicionários e arrays mantêm é a mesma história na direção oposta; o pai já possui o filho, então seguir o ponteiro para cima só revisitaria um nó pelo qual a caminhada já passou. Os dois ficam em paz, e o comentário no código-fonte diz isso numa linha: links e ponteiros de pai são referências, não arestas de propriedade

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

Como funciona o teardown em três fases?

A fase um é uma coleta em largura. A rotina semeia uma worklist com toda entrada de IndirectObjects e então, para cada nó, anexa os alvos das arestas de propriedade daquele nó, pulando o que já foi visto. O seen-set é um array de endereçamento aberto de ponteiros crus, com hash calculado por HPDFFastCacheHashInt64 sobre o valor do ponteiro, com sondagem linear e um GrowSeen que dobra o tamanho quando ele chega à metade. Nada nessa estrutura aloca por nó, o que importa quando um documento carrega algumas centenas de milhares de objetos. Payloads de stream vão para uma lista Streams separada, porque são descendentes de TStream e não nós THPDFObject, e são liberados numa passada própria

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

// Fase um: semear com o registro e seguir apenas as arestas de propriedade
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;

A fase dois é a parte que torna os destructors seguros de rodar: toda aresta de propriedade é posta em nil antes de qualquer destructor executar. Um wrapper recebe MarkAsFreed, que limpa FInternalObject e seta a flag que o destructor dele confere primeiro. Um stream object tem Dictionary e Stream atribuídos com nil. Cada item de dicionário tem Item^.Value limpo e cada posição de array é sobrescrita com nil. Depois dessa passada o grafo não tem mais arestas, então quando a fase três chama Free em cada nó de Nodes e depois em cada payload de Streams, cada destructor não encontra nada em que recursar e destrói apenas a si mesmo

// Fase dois: desligar toda aresta de propriedade 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;

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

Veja o que a divisão rende. Um dicionário compartilhado por dois stream objects é coletado uma vez, desligado dos dois e liberado uma vez. Um ciclo em que um array lista o próprio dicionário pai termina porque o seen-set recusa a segunda visita. Um wrapper e seu corpo, ambos registrados como raízes, são dois ponteiros distintos no conjunto, então os dois são liberados, e o destructor do wrapper não tenta mais liberar o corpo porque o MarkAsFreed já levou essa aresta embora. Um único TMemoryStream atribuído como payload de dois stream objects aparece em Streams exatamente uma vez. Nenhum desses casos precisa de tratamento especial, e é esse o sinal de que o modelo está certo

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

Checando se a contagem de alocações vivas do memory manager acompanha a carga de trabalho, e não apenas o seu footprint reservado. Um memory manager do Delphi mantém blocos grandes liberados por perto para reúso, então um processo que fica em 400 MiB depois de fechar um documento não vazou necessariamente; um processo cuja contagem de blocos vivos sobe um por página a cada execução vazou. A sonda que guiou essa correção era deliberadamente pequena: um writer THotPDF produzindo uma única página e então três readers carregando-a. Depois de os quatro serem liberados, o relatório de heap mostrava exatamente quatro alocações vivas de 512 KiB, uma por instância, que é o payload de content stream que cada uma possuía e nunca liberava. Aumentar a escala tornou o mesmo padrão inconfundível. Rodar o pipeline de render paralelo duas vezes levou o número de grandes blocos alocados de 384 MiB para 640 MiB, um aumento proporcional à contagem de páginas que a retenção do allocator não explica. Depois da reescrita, o diagnóstico de uma página reportava zero bytes grandes alocados e zero reservados assim que as instâncias sumiram. Se você está caçando o mesmo tipo de crescimento no seu próprio processo, o grafo de dependência de objetos com bytes retidos diz quais objetos seguram a memória enquanto o documento está aberto; este post é sobre o comportamento de liberação deles quando o documento fecha

Limites de memória produzem testes de regressão frágeis, então os testes entregues contam chamadas de destructor. Um fixture monta o grafo patológico à mão, com um dicionário compartilhado sob dois streams, um array contendo o dicionário compartilhado e a própria raiz dele, um payload atribuído aos dois streams, a raiz registrada duas vezes e um wrapper cujo corpo está registrado separadamente, e então libera o documento e verifica uma destruição por objeto único: um payload, dois streams, dois dicionários, um array, um wrapper, um número. No código antigo os três testes de tempo de vida reportavam zero destruições, que é a afirmação mais direta possível do que significa "deixar para o encerramento do processo"

O que precisa acontecer antes de o grafo descer?

Qualquer trabalho em background que tome objetos emprestados do grafo precisa parar primeiro, e todo cache que guarde display lists ou bitmaps compilados a partir desses objetos precisa ser descartado, senão uma thread de trabalho ou uma referência em cache lê memória liberada. O CloseIndirectObjects portanto abre com CancelLoadedPagePrefetch, depois invalida o cache de páginas renderizadas antes de tocar no registro. O caminho de reload em LoadFromFile e LoadFromStream e o destructor do componente passam os dois por ali, então a mesma ordem vale tanto se você está substituindo um documento quanto descartando a instância; as regras para reutilizar um mesmo THotPDF entre documentos se apoiam nessa garantia. Dois detalhes desse preâmbulo só apareceram ao rodar os testes. Primeiro, o destructor já descartou os frequency sketches por trás dos caches de render e de display list quando fecha o grafo, então a invalidação é guardada por esses campos serem não nil em vez de chamada incondicionalmente. Segundo, o InvalidateRenderedPageCache é a rotina que dispara OnLoadedDocumentModified com índice de página -1, e quem recarrega um arquivo não deveria receber notificação de edição pelo teardown interno do documento antigo. O handler é salvo, posto em nil em volta da chamada e restaurado num finally, e a regressão de reload verifica contagem de notificações zero depois do segundo LoadFromStream. Uma correção de memória que muda um contrato de evento em silêncio é uma regressão com PR melhor, então ela ganha asserção própria. Se você roda o pipeline de render paralelo contra um documento e depois o recarrega, o passo de cancelamento é o que impede o pool de workers de correr contra o teardown

Reutilizar o padrão no seu próprio código Delphi

A técnica não é específica de PDF. Qualquer modelo de objetos Delphi em que destructors possuem filhos de forma inconsistente, em que o mesmo filho pode ser alcançado a partir de vários pais, ou em que back-pointers e ponteiros para frente coexistem, vai travar ou vazar sob um Free ingênuo por objeto. A correção tem sempre a mesma forma: decida quais campos de ponteiro são de propriedade e quais são referências, colete o fechamento das arestas de propriedade por um conjunto de ponteiros que tolere revisitas, corte toda aresta e então destrua a lista plana. O passo de corte é o que as pessoas pulam, e é ele que torna os destructors existentes seguros de reutilizar em vez de forçar uma reescrita de toda classe do modelo. As fronteiras, porém, vale enunciar sem rodeios. O conjunto de ponteiros usa o endereço do objeto como identidade, então um objeto já liberado cujo endereço foi reusado por uma alocação nova seria indistinguível; a ordem garante que nenhum destructor roda durante a coleta, que é o que descarta esse caso. A caminhada só vê os quatro tipos de aresta que conhece, então uma classe nova que possua um filho por um campo que a caminhada não inspeciona vai vazar esse filho até que a caminhada aprenda sobre ele. E como os links são resolvidos pelo registro em vez de seguidos, um objeto referenciado apenas por um link e nunca registrado não é alcançável por este teardown de jeito nenhum; no HotPDF o parser garante o registro, mas um grafo montado à mão precisa respeitar a mesma regra

Tudo isso fica dentro do componente, então o efeito visível para uma aplicação é simplesmente que fechar ou recarregar um documento devolve a memória dele, sem nenhuma mudança de API. O HotPDF é uma biblioteca PDF VCL nativa para Delphi e C++Builder com código-fonte completo; a referência de API e uma build de avaliação estão na página do componente HotPDF Delphi PDF