Artigo Técnico

Libertar o grafo de objetos PDF do HotPDF uma só vez

O HotPDF Delphi Component liberta todos os objetos PDF que um documento detém quando esse documento fecha ou recarrega: o THotPDF.CloseIndirectObjects percorre o registo de objetos, recolhe cada aresta que detém para um conjunto de ponteiros, desliga todas essas arestas e só depois liberta cada nó único e cada carga de stream exatamente uma vez. É esta ordem em três fases que permite que filhos partilhados, ciclos de posse, registos duplicados e aliases entre wrapper e corpo desçam todos sem uma dupla libertação e sem deixar nada para trás. Antes da v2.752.4 a mesma rotina fazia algo muito mais simples e muito pior: libertava as fontes de stream de ficheiro lazy, chamava Clear à lista IndirectObjects, libertava o contentor da lista, e deixava cada objeto PDF real para o processo libertar ao sair. O comentário nesse código era também honesto quanto a isso. Libertar os objetos individualmente causava access violations, por isso a «abordagem segura» era não os libertar de todo. Este artigo é sobre porque é que a abordagem individual realmente rebentava, e como é que um desmantelamento que funciona se parece numa linguagem com gestão manual de memória

Porque é que não pode simplesmente fazer Free a cada objeto registado?

Porque os destrutores das classes de objetos discordam quanto a quem detém o quê, e o registo contém entradas a vários níveis da mesma cadeia de posse. Percorrer a lista e chamar Free a cada entrada liberta portanto alguma memória duas vezes e outra nunca, dependendo de que classes por acaso ficam lado a lado

Três assimetrias no HPDFObjs.pas e no HPDFDoc.pas criam o problema. O THPDFDictionaryObject.Destroy percorre os seus Items e só liberta um valor quando IsIndirect é False, partindo do princípio de que os filhos indiretos pertencem ao registo e serão libertados lá. O THPDFArrayObject.Destroy não faz essa distinção e liberta cada item que detém. E o THPDFIndirectObject.Destroy, o wrapper que transporta um número de objeto, liberta o seu corpo InternalObject. Considere agora um registo que detém um dicionário indireto, um array que lista esse mesmo dicionário numa das suas posições, e um wrapper cujo corpo está também registado como raiz separada, que é exatamente o que o parser produz em ficheiros reais. Liberte primeiro o array e o dicionário desaparece antes de o registo lá chegar. Liberte o wrapper e o corpo, em qualquer ordem, e a segunda chamada corre um destrutor sobre um ponteiro pendente. Liberte só o dicionário e qualquer filho indireto que ele tenha saltado fica alocado para sempre. Nenhuma ordenação do registo resolve isto, porque o registo é uma lista plana e a relação de posse é um grafo, e raciocinar sobre o grafo é a única saída

Porque é que libertar cada entrada do registo do HotPDF rebentava: o THPDFDictionaryObject.Destroy salta os filhos indiretos enquanto o THPDFArrayObject.Destroy liberta tudo o que detém e o THPDFIndirectObject.Destroy liberta o seu corpo InternalObject, pelo que com um wrapper, um array e um dicionário partilhado na mesma lista plana IndirectObjects alguma memória morre duas vezes e outra nunca
Os destrutores discordam quanto a quem detém o quê, e o registo contém entradas a vários níveis da mesma cadeia de posse, pelo que nenhuma ordenação de uma lista plana consegue transformar um Free ingénuo por objeto num desmantelamento correto

O que conta como aresta que detém num grafo de objetos PDF?

Uma aresta que detém é um ponteiro cujo alvo a origem é responsável por destruir; uma referência é tudo o resto, e o desmantelamento tem de 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, o seu Dictionary e a sua carga Stream. Os tipos de referência importam exatamente tanto, porque seguir uma transforma uma caminhada no grafo num ciclo infinito ou num use-after-free. Um THPDFLink transporta 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 noutro sítio, não o objeto em si. Resolver esse número através do registo devolve um nó que alguma outra aresta já detém, pelo que o CloseIndirectObjects nunca desreferencia links de todo. O back-pointer FParent que os dicionários e os arrays guardam é a mesma história na direção oposta; o pai já detém o filho, por isso seguir o ponteiro para cima só revisitaria um nó por onde a caminhada já passou. Ambos ficam em paz, e o comentário no código di-lo numa linha: os links e os ponteiros para o pai são referências, não arestas que detêm

Arestas que detêm 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 noutro sítio, pelo que o CloseIndirectObjects nunca os desreferencia
Uma aresta que detém é um ponteiro cujo alvo a origem tem de destruir; seguir antes uma referência transformaria a caminhada em largura num ciclo infinito ou num use-after-free, por isso os links e os ponteiros para o pai ficam em paz

Como funciona o desmantelamento em três fases?

A fase um é uma recolha em largura. A rotina semeia uma worklist com todas as entradas de IndirectObjects e depois, para cada nó, acrescenta os alvos das arestas que esse nó detém, saltando tudo o que já viu. O conjunto de vistos é um array de endereçamento aberto de ponteiros em bruto, com hash HPDFFastCacheHashInt64 sobre o valor do ponteiro, com sondagem linear e um GrowSeen que duplica o tamanho quando chega a meio cheio. Nada nessa estrutura aloca por nó, o que importa quando um documento transporta algumas centenas de milhares de objetos. As cargas de stream vão para uma lista Streams separada porque são descendentes de TStream e não nós THPDFObject, e são libertadas numa passagem própria

O desmantelamento em três fases do CloseIndirectObjects no HotPDF: uma recolha em largura semeia a worklist a partir de IndirectObjects e segue apenas as arestas que detêm através de um conjunto de vistos com endereçamento aberto e hash HPDFFastCacheHashInt64, a fase dois desliga cada aresta com MarkAsFreed e atribuição de nil, e a fase três liberta cada nó e carga de stream exatamente uma vez
Cortar as arestas antes de qualquer destrutor correr é o que torna os destrutores existentes seguros de reutilizar: cada um não encontra então nada onde recursar, pelo que filhos partilhados, ciclos e aliases entre wrapper e corpo descem todos sem dupla libertação
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á recolhido
    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 registo e seguir apenas as arestas que detêm
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 destrutores seguros de correr: todas as arestas que detêm são postas a nil antes de qualquer destrutor executar. Um wrapper recebe MarkAsFreed, que limpa o FInternalObject e define o flag que o seu destrutor verifica primeiro. Um objeto de stream tem Dictionary e Stream atribuídos a nil. Cada item de dicionário tem Item^.Value limpo e cada posição de array é sobrescrita com nil. Depois desta passagem o grafo não tem arestas nenhumas, por isso, quando a fase três chama Free a cada nó em Nodes e depois a cada carga em Streams, cada destrutor não encontra nada onde recursar e destrói apenas a si próprio

// Fase dois: desligar todas as arestas que detêm antes de libertar seja o que for
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 carga únicos são libertados 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);

Repare no que a separação compra. Um dicionário partilhado por dois objetos de stream é recolhido uma vez, desligado de ambos e libertado uma vez. Um ciclo em que um array lista o seu próprio dicionário pai termina porque o conjunto de vistos recusa a segunda visita. Um wrapper e o seu corpo, ambos registados como raízes, são dois ponteiros distintos no conjunto, pelo que ambos são libertados, e o destrutor do wrapper já não tenta libertar o corpo porque o MarkAsFreed já lhe tirou essa aresta. Um único TMemoryStream atribuído como carga de dois objetos de stream fica em Streams exatamente uma vez. Nenhum desses casos precisa de tratamento especial, o que é o sinal de que o modelo está certo

Como se distingue uma fuga da retenção do alocador?

Verificando se a contagem de alocações vivas do gestor de memória se move com a carga de trabalho, e não apenas a sua pegada reservada. Um gestor de memória Delphi guarda os blocos grandes já libertados para reutilização, pelo que um processo que fica nos 400 MiB depois de fechar um documento não tem necessariamente uma fuga; um processo cuja contagem de blocos vivos sobe um por página por execução tem. A sonda que conduziu esta correção foi deliberadamente pequena: um writer THotPDF a produzir uma única página e depois três readers a carregá-la. Depois de os quatro serem libertados, o relatório de heap mostrava exatamente quatro alocações vivas de 512 KiB, uma por instância, que é a carga do content stream que cada um detinha e nunca libertava. Aumentar a escala tornou o mesmo padrão inequívoco. Correr o pipeline de renderização paralelo duas vezes moveu a cifra de blocos grandes alocados de 384 MiB para 640 MiB, um aumento proporcional ao número de páginas que a retenção do alocador 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 desapareciam. Se anda à caça do mesmo tipo de crescimento no seu próprio processo, o grafo de dependências de objetos com bytes retidos diz-lhe que objetos seguram a memória enquanto o documento está aberto; este artigo é sobre o comportamento de libertação deles quando ele fecha

Os limiares de memória dão testes de regressão frágeis, por isso os testes que saíram contam chamadas ao destrutor. Um fixture constrói o grafo patológico à mão, com um dicionário partilhado sob dois streams, um array que contém tanto o dicionário partilhado como a sua própria raiz, uma carga atribuída aos dois streams, a raiz registada duas vezes, e um wrapper cujo corpo está registado em separado, e depois liberta o documento e afirma uma destruição por objeto único: uma carga, dois streams, dois dicionários, um array, um wrapper, um número. Sob o 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 processo ao sair»

O que tem de acontecer antes de o grafo descer?

Qualquer trabalho em segundo plano que peça objetos emprestados ao grafo tem de parar primeiro, e qualquer cache que guarde display lists ou bitmaps compilados a partir desses objetos tem de ser descartada, caso contrário uma thread de trabalho ou uma referência em cache lê memória já libertada. O CloseIndirectObjects abre portanto com CancelLoadedPagePrefetch e só depois invalida a cache de páginas desenhadas, antes de tocar no registo. O caminho de recarregamento no LoadFromFile e no LoadFromStream e o destrutor do componente passam ambos por lá, pelo que a mesma ordenação se aplica quer esteja a substituir um documento quer a descartar a instância; as regras para reutilizar um THotPDF entre documentos assentam nessa garantia. Dois detalhes desse preâmbulo só apareceram por se correrem os testes. Primeiro, quando o destrutor fecha o grafo já se desfez dos esboços de frequência por trás das caches de renderização e de display list, por isso a invalidação está protegida por esses campos serem não nulos em vez de ser chamada sem condições. Segundo, o InvalidateRenderedPageCache é a rotina que dispara o OnLoadedDocumentModified com um índice de página de -1, e um chamador que recarregue um ficheiro não deve receber uma notificação de edição pelo desmantelamento interno do documento antigo. O handler é guardado, posto a nil durante a chamada e restaurado num finally, e a regressão de recarregamento afirma uma contagem de notificações de zero depois do segundo LoadFromStream. Uma correção de memória que muda silenciosamente um contrato de eventos é uma regressão com melhor imprensa, por isso tem a sua própria asserção. Se correr o pipeline de renderização paralelo contra um documento e depois o recarregar, o passo de cancelamento é o que impede a pool de workers de competir com o desmantelamento

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 os destrutores detenham filhos de forma inconsistente, em que o mesmo filho possa ser alcançado a partir de vários pais, ou em que back-pointers e ponteiros para a frente coexistam, vai rebentar ou ter fugas sob um Free ingénuo por objeto. A correção tem sempre a mesma forma: decidir que campos de ponteiro detêm e quais são referências, recolher o fecho das arestas que detêm através de um conjunto de ponteiros que tolere revisitas, cortar todas as arestas e depois destruir a lista plana. O passo de cortar é o que as pessoas saltam, e é o que torna os destrutores existentes seguros de reutilizar em vez de obrigar a reescrever todas as classes do modelo. As fronteiras, no entanto, valem a pena ser ditas com clareza. O conjunto de ponteiros usa o endereço do objeto como identidade, por isso um objeto já libertado cujo endereço tenha sido reutilizado por uma alocação nova seria indistinguível; a ordenação garante que nenhum destrutor corre durante a recolha, o que é o que descarta esse caso. A caminhada só vê os quatro tipos de aresta que conhece, por isso uma classe nova que detenha um filho através de um campo que a caminhada não inspeciona deixará esse filho a fugir até que a caminhada seja ensinada sobre ele. E como os links são resolvidos através do registo em vez de seguidos, um objeto referenciado apenas por um link e que nunca tenha sido registado não é alcançável por este desmantelamento de todo; no HotPDF o parser garante o registo, mas um grafo construído à mão tem de respeitar a mesma regra

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