Article technique

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

HotPDF Delphi Component libère chaque objet PDF que possède un document quand ce document se ferme ou se recharge : THotPDF.CloseIndirectObjects parcourt le registre d'objets, rassemble chaque arête de possession dans un ensemble de pointeurs, détache toutes ces arêtes, et seulement ensuite libère chaque nœud unique et chaque charge de flux exactement une fois. C'est cet ordre en trois phases qui permet aux enfants partagés, aux cycles de possession, aux enregistrements en double et aux alias wrapper/corps de tous tomber sans double libération et sans rien laisser derrière. Avant la v2.752.4, la même routine faisait quelque chose de bien plus simple et de bien pire : elle libérait les sources de flux de fichier paresseuses, appelait Clear sur la liste IndirectObjects, libérait le conteneur de la liste, et laissait chaque objet PDF réel à la sortie du processus. Le commentaire de ce code était honnête là-dessus, d'ailleurs. Libérer les objets individuellement provoquait des access violations, donc l'« approche sûre » consistait à ne pas les libérer du tout. Cet article explique pourquoi l'approche individuelle plantait vraiment, et à quoi ressemble un démontage qui fonctionne dans un langage à gestion mémoire manuelle

Pourquoi ne peut-on pas simplement appeler Free sur chaque objet enregistré ?

Parce que les destructeurs des classes d'objets ne sont pas d'accord sur qui possède quoi, et que le registre contient des entrées à plusieurs niveaux de la même chaîne de possession. Parcourir la liste et appeler Free sur chaque entrée libère donc certaines mémoires deux fois et d'autres jamais, selon les classes qui se trouvent voisiner

Trois asymétries dans HPDFObjs.pas et HPDFDoc.pas créent le problème. 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 et y seront libérés. THPDFArrayObject.Destroy ne fait aucune distinction de ce genre et libère chaque élément qu'il détient. Et THPDFIndirectObject.Destroy, le wrapper qui porte un numéro d'objet, libère son corps InternalObject. Considérez maintenant un registre qui contient un dictionnaire indirect, un tableau qui liste ce même dictionnaire dans l'une de ses cases, et un wrapper dont le corps est aussi enregistré comme racine séparée, ce que produit exactement l'analyseur sur de vrais fichiers. Libérez le tableau en premier et le dictionnaire disparaît avant que le registre ne l'atteigne. Libérez le wrapper et le corps, dans un ordre ou dans l'autre, et le second appel exécute un destructeur sur un pointeur qui pendouille. Libérez le dictionnaire seul et tout enfant indirect qu'il a sauté reste alloué pour toujours. Aucun ordre du registre ne corrige cela, parce que le registre est une liste plate et que la relation de possession est un graphe, et raisonner sur le graphe est la seule issue

Pourquoi libérer chaque entrée du registre HotPDF plantait : THPDFDictionaryObject.Destroy saute les enfants indirects, THPDFArrayObject.Destroy libère tout ce qu'il détient et THPDFIndirectObject.Destroy libère son corps InternalObject, donc avec un wrapper, un tableau et un dictionnaire partagé dans une seule liste IndirectObjects plate, certaines mémoires meurent deux fois et d'autres jamais
Les destructeurs ne sont pas d'accord sur qui possède quoi, et le registre contient des entrées à plusieurs niveaux de la même chaîne de possession, donc aucun ordre d'une liste plate ne peut transformer un Free naïf par objet en démontage correct

Qu'est-ce qui compte comme arête de possession dans un graphe d'objets PDF ?

Une arête de possession est un pointeur dont la cible est à la charge de la source, qui doit la détruire ; une référence est tout le reste, et le démontage doit suivre le premier type et ignorer le second. Dans HotPDF, cela donne exactement quatre types d'arêtes : les Items d'un THPDFDictionaryObject, les Items d'un THPDFArrayObject, l'InternalObject derrière un THPDFIndirectObject, et les deux moitiés d'un THPDFStreamObject, son Dictionary et sa charge Stream. Les types de référence comptent tout autant, parce qu'en suivre un transforme un parcours de graphe en boucle infinie ou en usage après libération. Un THPDFLink détient un numéro d'objet et une génération, ce qui est la définition que donne ISO 32000-1 §7.3.10 d'une référence indirecte : un nom pour un objet qui vit ailleurs, pas l'objet lui-même. Résoudre ce numéro à travers le registre donne un nœud que quelque autre arête possède déjà, donc CloseIndirectObjects ne déréférence jamais les liens. Le pointeur arrière FParent que gardent les dictionnaires et les tableaux raconte la même histoire dans l'autre sens ; le parent possède déjà l'enfant, donc suivre le pointeur vers le haut ne ferait que revisiter un nœud par lequel le parcours est déjà passé. Les deux sont laissés tranquilles, et le commentaire du source le dit en une ligne : les liens et les pointeurs parents sont des références, pas des arêtes de possession

Arêtes de possession contre références dans le graphe d'objets HotPDF : les Items d'un DictionaryObject, les Items d'un ArrayObject, l'InternalObject d'un IndirectObject et les deux moitiés d'un StreamObject sont suivis et détachés, tandis qu'un numéro d'objet THPDFLink et le pointeur arrière FParent sont des noms pour des objets qui vivent ailleurs, donc CloseIndirectObjects ne les déréférence jamais
Une arête de possession est un pointeur dont la cible doit être détruite par la source ; suivre une référence à la place transformerait le parcours en largeur en boucle infinie ou en usage après libération, donc les liens et les pointeurs parents sont laissés tranquilles

Comment fonctionne le démontage en trois phases ?

La phase un est une collecte en largeur d'abord. La routine amorce une liste de travail avec chaque entrée d'IndirectObjects, puis pour chaque nœud ajoute les cibles des arêtes de possession de ce nœud, en sautant tout ce qui a déjà été vu. L'ensemble des vus est un tableau à adressage ouvert de pointeurs bruts hachés avec HPDFFastCacheHashInt64 sur la valeur du pointeur, avec sondage linéaire et un GrowSeen qui double la taille quand il atteint la moitié. Rien dans cette structure n'alloue par nœud, ce qui compte quand un document porte quelques centaines de milliers d'objets. Les charges de flux vont dans une liste Streams séparée parce que ce sont des descendants de TStream et non des nœuds THPDFObject, et elles sont libérées dans leur propre passe

Le démontage CloseIndirectObjects en trois phases dans HotPDF : une collecte en largeur amorce la liste de travail depuis IndirectObjects et ne suit que les arêtes de possession via un ensemble de vus à adressage ouvert haché avec HPDFFastCacheHashInt64, la phase deux détache chaque arête par MarkAsFreed et affectation à nil, et la phase trois libère chaque nœud et chaque charge de flux exactement une fois
Couper les arêtes avant qu'un seul destructeur ne s'exécute est ce qui rend les destructeurs existants sûrs à réutiliser : chacun ne trouve alors rien où récurser, donc les enfants partagés, les cycles et les alias wrapper-corps tombent tous sans double libération
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;   // 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;

// Phase un : amorcer avec le registre, puis suivre seulement 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);
  Inc(I);
end;

La phase deux est celle qui rend les destructeurs sûrs à exécuter : chaque arête de possession est mise à nil avant qu'un seul destructeur s'exécute. Un wrapper reçoit MarkAsFreed, qui vide FInternalObject et positionne le drapeau que son destructeur teste en premier. Un objet flux voit ses Dictionary et Stream affectés à nil. Chaque élément de dictionnaire voit son Item^.Value vidé, et chaque case de tableau est écrasée par nil. Après cette passe, le graphe n'a plus d'arêtes, donc quand la phase trois appelle Free sur chaque nœud de Nodes puis sur chaque charge de Streams, chaque destructeur ne trouve rien où récurser et ne détruit que lui-même

// Phase deux : détacher chaque arête de possession avant toute libération
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;

// Phase trois : chaque nœud et chaque charge unique 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);

Regardez ce que ce découpage achète. Un dictionnaire partagé par deux objets 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. Un wrapper et son corps tous deux enregistrés comme racines sont deux pointeurs distincts dans l'ensemble, donc les deux sont libérés, et le destructeur du wrapper ne tente plus de libérer le corps parce que MarkAsFreed a déjà retiré cette arête. Un seul TMemoryStream affecté comme charge de deux objets flux figure dans Streams exactement une fois. Aucun de ces cas n'a besoin d'un traitement particulier, ce qui est le signe que le modèle est le bon

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

En vérifiant si le nombre d'allocations vivantes du gestionnaire de mémoire bouge avec la charge de travail, et pas seulement son empreinte réservée. Un gestionnaire de mémoire Delphi garde les gros blocs libérés pour les réutiliser, donc un processus qui reste à 400 Mio après la fermeture d'un document n'a pas forcément fuité ; un processus dont le nombre de blocs vivants grimpe de un par page et par exécution, si. La sonde qui a motivé ce correctif était délibérément petite : un writer THotPDF produisant une seule page, puis trois lecteurs qui la chargent. Après libération des quatre, le rapport de tas montrait exactement quatre allocations vivantes de 512 Kio, une par instance, qui est la charge de flux de contenu que chacune possédait et n'avait jamais libérée. En montant en charge, le même schéma devenait impossible à manquer. Exécuter deux fois le pipeline de rendu parallèle faisait passer le chiffre des gros blocs alloués de 384 Mio à 640 Mio, une augmentation proportionnelle au nombre de pages que la rétention de l'allocateur ne peut pas expliquer. Après la réécriture, le diagnostic sur une page rapportait zéro gros octet alloué et zéro réservé une fois les instances disparues. Si vous traquez le même genre de croissance dans votre propre processus, le graphe de dépendances d'objets avec les octets retenus vous dit quels objets détiennent la mémoire pendant que le document est ouvert ; cet article traite de leur comportement de libération quand il se ferme

Les seuils de mémoire font des tests de régression fragiles, donc les tests livrés comptent plutôt les appels de destructeurs. Une fixture construit le graphe pathologique à la main, avec un dictionnaire partagé sous deux flux, un tableau contenant à la fois le dictionnaire partagé et sa propre racine, une charge affectée aux deux flux, la racine enregistrée deux fois, et un wrapper dont le corps est enregistré séparément, puis libère le document et vérifie une destruction par objet unique : une charge, deux flux, deux dictionnaires, un tableau, un wrapper, un nombre. Avec l'ancien code, les trois tests de durée de vie rapportaient zéro destruction, ce qui est l'énoncé le plus direct possible de ce que signifie « laisser à la sortie du processus »

Que doit-il se passer avant que le graphe ne soit démonté ?

Tout travail de fond qui emprunte des objets au graphe doit d'abord s'arrêter, et tout cache qui détient des display lists ou des bitmaps compilés depuis ces objets doit être vidé, sinon un thread worker ou une référence en cache lit de la mémoire libérée. CloseIndirectObjects commence donc par CancelLoadedPagePrefetch, puis invalide le cache de pages rendues avant de toucher au registre. Le chemin de rechargement dans LoadFromFile et LoadFromStream ainsi que le destructeur du composant y passent tous, donc le même ordre s'applique, que vous remplaciez un document ou que vous disposiez de l'instance ; les règles pour réutiliser un même THotPDF sur plusieurs documents s'appuient sur cette garantie. Deux détails de ce préambule n'ont émergé qu'à l'exécution des tests. D'abord, le destructeur a déjà disposé des esquisses de fréquence derrière les caches de rendu et de display list au moment où il ferme le graphe, donc l'invalidation est gardée par le fait que ces champs sont non nil plutôt qu'appelée sans condition. Ensuite, InvalidateRenderedPageCache est la routine qui déclenche OnLoadedDocumentModified avec un index de page de -1, et un appelant qui recharge un fichier ne devrait pas recevoir de notification d'édition pour le démontage interne de l'ancien document. Le handler est sauvegardé, mis à nil autour de l'appel, et restauré dans un finally, et la régression de rechargement vérifie un compteur de notifications à zéro après le second LoadFromStream. Un correctif mémoire qui change discrètement un contrat d'événement est une régression mieux habillée, donc il a droit à sa propre assertion. Si vous lancez le pipeline de rendu parallèle sur un document puis le rechargez, l'étape d'annulation est ce qui empêche le pool de workers de courir contre le démontage

Réutiliser le schéma dans votre propre code Delphi

La technique n'est pas propre au PDF. Tout modèle objet Delphi où les destructeurs possèdent leurs enfants de façon incohérente, où le même enfant peut être atteint depuis plusieurs parents, ou où pointeurs arrière et pointeurs avant coexistent, plantera ou fuira sous un Free naïf par objet. Le correctif a toujours la même forme : décider quels champs de pointeur possèdent et lesquels référencent, collecter la fermeture des arêtes de possession via un ensemble de pointeurs qui tolère les revisites, couper chaque arête, puis détruire la liste plate. L'étape de coupe est celle que les gens sautent, et c'est celle qui rend les destructeurs existants sûrs à réutiliser au lieu d'imposer une réécriture de chaque classe du modèle. Les frontières méritent toutefois d'être énoncées clairement. L'ensemble de pointeurs utilise l'adresse de l'objet comme identité, donc un objet déjà libéré et dont l'adresse a été réutilisée par une allocation neuve serait indiscernable ; l'ordre garantit qu'aucun destructeur ne s'exécute pendant la collecte, ce qui écarte ce cas. Le parcours ne voit que les quatre types d'arêtes qu'il connaît, donc une nouvelle classe qui possède un enfant via un champ que le parcours n'inspecte pas fera fuir cet enfant jusqu'à ce qu'on l'apprenne au parcours. Et comme les liens sont résolus à travers le registre plutôt que suivis, un objet référencé uniquement par un lien et jamais enregistré n'est pas atteignable par ce démontage du tout ; dans HotPDF, l'analyseur garantit l'enregistrement, mais un graphe construit à la main doit respecter la même règle

Tout cela est interne au composant, donc l'effet visible pour une application est simplement que fermer ou recharger un document rend sa mémoire, sans changement d'API. HotPDF est une bibliothèque PDF VCL native pour Delphi et C++Builder, avec le code source complet ; la référence d'API et une version d'essai sont sur la page du composant PDF HotPDF pour Delphi