Article technique

Démontage du graphe d'objets HotPDF en trois phases

Un modèle objet Delphi qui reflète un fichier PDF analysé finit en général en une liste plate de tous les objets plus un graphe de pointeurs qui pend d'elle. Parcourez cette liste en appelant Free sur chaque entrée et l'exécution meurt en cours de route avec une violation d'accès à l'intérieur d'un destructeur, sur un objet qu'un Free antérieur a déjà libéré au travers d'un autre parent. Remplacez la boucle par List.Clear et le crash disparaît, mais le processus grossit maintenant d'environ un document à chaque rechargement. Les deux symptômes ont une cause : la liste est plate et la possession est un graphe. Le démontage qui fonctionne décide quels pointeurs possèdent leur cible, collecte cette clôture une fois, coupe chaque arête de possession et seulement alors libère chaque nœud, exactement une fois

Pourquoi libérer chaque objet enregistré plante-t-il ?

Dans HotPDF la liste plate est le registre d'objets du document, et les classes qu'il porte sont en désaccord sur qui détruit quoi. Chaque destructeur est raisonnable dans ses propres termes. Mettez-les ensemble au-dessus d'un registre qui contient des entrées de plusieurs niveaux de la même chaîne de possession, et un Free par entrée libère une partie de la mémoire deux fois et une autre jamais

Trois destructeurs dans HPDFObjs.pas et HPDFDoc.pas installent cela. THPDFDictionaryObject.Destroy parcourt ses Items et ne libère une valeur que quand IsIndirect vaut False, en supposant que les enfants indirects appartiennent au registre. THPDFArrayObject.Destroy libère chaque item qu'il porte, indirect ou non. THPDFIndirectObject, l'enveloppe employée pour les objets tirés des object streams (ISO 32000-1 §7.5.7), libère son corps InternalObject. Imaginez maintenant un registre portant un dictionnaire indirect, un tableau qui liste le même dictionnaire dans une case, et une enveloppe dont le corps est aussi enregistré comme racine à part entière. Libérez le tableau d'abord et le dictionnaire a disparu avant que le registre ne l'atteigne. Libérez l'enveloppe puis le corps, et le second appel fait tourner un destructeur sur de la mémoire qui n'appartient plus à personne. Ne libérez que le dictionnaire et chaque enfant indirect qu'il a sauté reste alloué jusqu'à la sortie du processus

Réordonner le registre ne peut pas sauver cela. Quel que soit l'ordre choisi, quelque paire d'entrées tombe du mauvais côté d'une arête de possession, parce qu'une liste n'a aucun moyen de dire que l'entrée 12 possède l'entrée 40

Pourquoi libérer naïvement chaque entrée du registre HotPDF plante : THPDFDictionaryObject.Destroy saute les enfants indirects tandis que THPDFArrayObject.Destroy libère tout ce qu'il porte et THPDFIndirectObject.Destroy libère son corps InternalObject, si bien qu'avec une enveloppe, un tableau et un dictionnaire partagé dans une seule liste plate IndirectObjects une partie de la mémoire meurt deux fois et une autre jamais
Les destructeurs sont en désaccord sur qui possède quoi, et le registre porte des entrées à plusieurs niveaux de la même chaîne de possession, si bien aucun ordre d'une liste plate ne peut transformer un Free naïf par objet en démontage correct

Arêtes de possession et références dans un graphe d'objets PDF

Une arête de possession est un pointeur dont la source est responsable de détruire la cible. Tout le reste est une référence, et le démontage ne suit que la première sorte. Dans HotPDF il existe exactement quatre sortes d'arêtes de possession : les Items d'un THPDFDictionaryObject, les Items d'un THPDFArrayObject, le InternalObject derrière un THPDFIndirectObject, et les deux champs d'un THPDFStreamObject — son Dictionary et sa charge Stream

Les références méritent le même soin, parce que suivre l'une transforme une marche de graphe en boucle sans fin ou en lecture de mémoire libérée. Un THPDFLink porte un numéro d'objet et une génération, qui est exactement la référence indirecte de ISO 32000-1 §7.3.10 : un nom pour un objet stocké ailleurs dans le fichier. Résoudre ce numéro au travers du registre atterrit sur un nœud qu'une autre arête possède déjà, si bien que le démontage ne déréférence jamais les liens. Le pointeur retour FParent que dictionnaires et tableaux conservent court dans la direction opposée. Le parent possède déjà l'enfant, et grimper ce pointeur ne ferait que revisiter un nœud que la marche a dépassé. Les deux sortes restent intactes pendant tout le démontage

Arêtes de possession contre références dans le graphe d'objets HotPDF : les Items de DictionaryObject, les Items de ArrayObject, le InternalObject d'IndirectObject et les deux moitiés d'un StreamObject sont suivies et détachées, tandis qu'un numéro d'objet THPDFLink et le pointeur retour FParent sont des noms pour des objets qui vivent ailleurs, si bien que CloseIndirectObjects ne les déréférence jamais
Une arête de possession est un pointeur dont la source doit détruire la cible ; suivre une référence à la place transformerait la marche en largeur en boucle infinie ou en use-after-free, si bien que les liens et pointeurs parents sont laissés tranquilles

Collecter, détacher, libérer : le démontage en trois passes

HotPDF libère les objets d'un document en trois passes à l'intérieur de la méthode protégée THotPDF.CloseIndirectObjects, et l'ordre de ces passes est toute l'astuce. La passe un collecte. Une file de travail démarre avec chaque entrée du registre, et pour chaque nœud la marche ajoute les cibles de ses arêtes de possession, en sautant tout ce qu'elle a déjà vu. L'ensemble des vus est un tableau à adressage ouvert de pointeurs bruts avec sondage linéaire qui double une fois à moitié plein, si bien que rien ne s'alloue par nœud, ce qui commence à compter quand un document porte des centaines de milliers d'objets. Les charges de flux vont dans une seconde liste parce que ce sont des descendants de TStream plutôt que des nœuds THPDFObject

Le démontage CloseIndirectObjects en trois phases dans HotPDF : une collecte en largeur sème la file de travail depuis IndirectObjects et ne suit que les arêtes de possession au travers d'un ensemble des vus à adressage ouvert haché avec HPDFFastCacheHashInt64, la phase deux détache chaque arête avec MarkAsFreed et une affectation à nil, et la phase trois libère chaque nœud et charge de flux exactement une fois
Couper les arêtes avant que tout destructeur ne tourne est ce qui rend les destructeurs existants sûrs à réemployer : chacun ne trouve alors plus rien où recursers, si bien que les enfants partagés, les cycles et les alias enveloppe-corps tombent tous sans double free

Les deux listings qui suivent sont des extraits simplifiés de l'implémentation interne du composant, montrés pour que vous voyiez les passes dans les types d'objets de la bibliothèque elle-même. Le code applicatif n'appelle rien de tout cela ; LoadFromFile, LoadFromStream et le destructeur le font pour vous. Dans l'extrait le hachage d'adresse est réduit à un décalage et un masque, et l'étape qui double la table des vus quand elle se remplit est laissée de côté

// Extrait simplifié du démontage interne, pas une API appelable
procedure Collect(Value: TObject; Payload: boolean);
var
  Slot: Integer;
begin
  if Value = nil then Exit;
  // (la vraie routine double et rehache la table une fois à moitié pleine)
  Slot := Integer(NativeUInt(Value) shr 4) and (Length(Seen) - 1);
  while Seen[Slot] <> nil do
  begin
    if Seen[Slot] = Pointer(Value) then Exit;   // déjà collecté
    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;

// Passe un : semez avec le registre, puis ne suivez que les arêtes de possession
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);
  // les valeurs THPDFLink et les pointeurs FParent ne sont jamais suivis
  Inc(I);
end;

La passe deux coupe. Chaque arête de possession est mise à nil avant qu'un seul destructeur ne tourne. Une enveloppe reçoit MarkAsFreed, qui efface InternalObject et positionne le drapeau IsFreed que son destructeur contrôle en premier. Un objet de flux voit Dictionary et Stream affectés à nil, chaque item de dictionnaire voit son Value effacé, et chaque case de tableau est écrasée par nil. La passe trois vide le registre, libère chaque nœud de Nodes puis chaque charge de Streams

// Extrait simplifié du démontage interne, suite ; pas une API appelable
// Passe deux : détachez chaque arête de possession avant de libérer quoi que ce soit
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;

// Passe trois : chaque nœud et charge uniques est libéré exactement une fois
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);

La coupe est ce qui permet aux destructeurs existants de rester exactement tels quels. Sans arêtes restantes, chaque destructeur ne trouve plus rien où recursers et ne détruit que lui-même. L'autre route vers le même résultat est d'enseigner à chaque destructeur l'existence du registre, pour qu'un dictionnaire, un tableau et une enveloppe demandent chacun si un autre chemin libérera leurs enfants. Cela répand la connaissance du graphe sur quatre classes et chaque classe ajoutée plus tard, et une seule réponse fausse n'importe où ramène le double free. Garder tout le modèle de possession dans une seule routine de démontage est l'arrangement le plus sûr

Passez les cas pénibles au travers et aucun n'a besoin d'une branche spéciale. Un dictionnaire partagé par deux objets de flux est collecté une fois, détaché des deux et libéré une fois. Un cycle où un tableau liste son propre dictionnaire parent se termine parce que l'ensemble des vus refuse la seconde visite. Une enveloppe et son corps enregistrés comme deux racines sont deux pointeurs distincts dans l'ensemble, si bien que les deux sont libérés, et le destructeur de l'enveloppe laisse le corps tranquille parce que MarkAsFreed a déjà retiré cette arête. Un TMemoryStream affecté comme charge de deux objets de flux se trouve dans Streams exactement une fois

Qu'est-ce qui doit s'arrêter avant que le graphe ne tombe ?

Tout ce qui emprunte des objets au graphe doit s'arrêter d'abord. HotPDF peut rendre des pages sur un thread de travail au travers de PrefetchLoadedPages, et il met en cache des bitmaps et des listes d'affichage compilés depuis les objets du document ; un worker ou une entrée de cache qui survivrait au graphe lirait de la mémoire libérée. Le démontage ouvre donc avec CancelLoadedPagePrefetch et invalide le cache de pages rendues avant de toucher au registre. LoadFromFile, LoadFromStream et le destructeur THotPDF passent tous par là, si bien que l'ordre est identique que vous remplaciez un document ou libériez le composant, et les règles pour réemployer un THotPDF au travers de documents s'appuient là-dessus

Le contrat de notification compte tout autant. InvalidateRenderedPageCache est la méthode qui déclenche OnLoadedDocumentModified avec PageIndex = -1, et un visualiseur qui recharge un fichier n'a aucune raison de recevoir une notification d'édition pour le nettoyage interne de l'ancien document. À l'intérieur du démontage le handler est sauvegardé, mis à nil autour de cet appel et restauré dans un bloc finally. Un rechargement produit quand même un appel PageIndex = -1, déclenché par le chargement lui-même quand il réinitialise le cache pour le nouveau document, et c'est celui sur lequel un visualiseur doit repeindre. Dans le destructeur les caches sont déjà disposés au moment où le graphe se ferme, si bien que le démontage y saute complètement l'invalidation

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           // votre propre méthode de visualiseur
  else
    RepaintPage(PageIndex);   // votre propre méthode de visualiseur
end;

procedure TViewerForm.OpenDocument(const FileName: string);
begin
  // LoadFromFile annule un prefetch en cours et libère les objets du document
  // précédent avant l'analyse ; PdfModified voit un seul appel à -1
  FPdf.LoadFromFile(FileName);
  FPdf.PrefetchLoadedPages('1-10', 150);
end;

Aucun appel explicite à CancelLoadedPagePrefetch n'est nécessaire dans OpenDocument, parce que le chargement le fait. L'appeler vous-même reste le bon geste quand vous vous apprêtez à modifier le document chargé pendant qu'un prefetch pourrait tourner ; le commentaire de déclaration dans HPDFDoc.pas le demande exactement. Pour du rendu en pool de workers à plus grande échelle, l'article sur le pipeline de rendu parallèle couvre la façon dont le travail en arrière-plan est régulé

Comment distinguer une fuite de la rétention de l'allocateur ?

Observez le compte d'allocations vivantes du gestionnaire de mémoire au fil de chargements répétés, et ignorez le working set du processus. Le gestionnaire de mémoire Delphi garde la mémoire libérée en réserve pour réemploi, si bien qu'un processus qui se tient stable à une empreinte élevée après la fermeture d'un document n'a pas nécessairement fuité. Un processus dont le compte de gros blocs vivants grimpe à chaque rechargement, si. La boucle ci-dessous charge le même fichier vingt fois sur une instance et affiche les chiffres des gros blocs après chaque passe

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.

Lisez la sortie comme une forme. Des nombres qui se stabilisent après la première passe sont de la rétention ou de l'état ordinaire par document. Des nombres qui montent du même pas à chaque passe sont une fuite, et pour un PDF le pas est en général la taille des charges de flux les plus grandes, contenu de pages et images embarquées, puisque ce sont les allocations assez grosses pour atterrir dans les chiffres des gros blocs. Employez un fichier d'une douzaine de pages ou plus pour que le pas se voie facilement contre le bruit. Une croissance proportionnelle au nombre de pages, la rétention de l'allocateur ne peut pas l'expliquer. Pour trouver quels objets détiennent de la mémoire pendant qu'un document est ouvert, le graphe de dépendance d'objets avec octets retenus répond à ce côté de la question

Réemployer le motif dans votre propre modèle objet Delphi

Rien ici n'est spécifique au PDF. Tout modèle objet Delphi où des destructeurs possèdent des enfants de façon incohérente, où un enfant est atteignable depuis plusieurs parents, ou où des pointeurs retours voisinent avec des pointeurs avant plantera ou fuira sous un Free par objet. Le correctif a la même forme à chaque fois : classer chaque champ pointeur comme possession ou référence, collecter la clôture des arêtes de possession au travers d'un ensemble de pointeurs qui tolère les revisites, couper chaque arête, détruire la liste plate. L'étape de coupe est celle que les gens sautent. Sautez-la et chaque destructeur finit par avoir besoin de connaître tous les autres

Le motif vient avec des bornes qui valent d'être notées avant de le copier. L'ensemble de pointeurs emploie l'adresse de l'objet comme identité, ce qui n'est sain que parce qu'aucun destructeur ne tourne pendant la collecte, si bien qu'aucune adresse ne peut être libérée et rendue à une nouvelle allocation en pleine marche. La marche ne voit que les sortes d'arêtes pour lesquelles elle a été écrite ; une nouvelle classe qui possède un enfant par un autre champ doit être ajoutée à la fois à la passe de collecte et à celle de détachement. Et parce que les liens sont résolus plutôt que suivis, un objet référencé seulement par un lien doit être dans le registre pour être atteint. L'analyseur HotPDF enregistre chaque objet qu'il lit, et un graphe construit à la main dans votre propre code doit garder la même règle

Testez-le en comptant des appels de destructeurs plutôt que des octets. Construisez le graphe pénible à la main : un dictionnaire partagé par deux flux, un tableau portant à la fois ce dictionnaire et son propre parent, une charge affectée aux deux flux, une racine enregistrée deux fois, et une enveloppe dont le corps numérique est enregistré lui aussi. Démontez-le et attendez-vous à exactement une destruction par objet unique — une charge, deux flux, deux dictionnaires, un tableau, une enveloppe, un nombre. Dans vos propres classes, un compteur de classe incrémenté dans chaque destructeur suffit pour cela (un hook d'instrumentation que vous ajoutez vous-même), et un démontage qui se contente de vider la liste rapporte zéro

Dans HotPDF tout cela se cache derrière LoadFromFile, LoadFromStream et le destructeur, si bien que le code applicatif ne voit que le résultat : fermer ou recharger un document rend sa mémoire. HotPDF est une bibliothèque PDF VCL native pour Delphi et C++Builder livrée avec ses sources complètes, si bien que le démontage décrit ici se lit ligne à ligne dans HPDFDoc.pas ; la référence API et un build d'essai sont sur la page du HotPDF PDF component for Delphi