Article technique

Inflate ZIP Concurrent en Delphi

Deux threads partagent un TFileStream au-dessus d'un paquet .xlsx. Le thread A met Position au premier octet compressé de sheet1.xml, le thread B le met au premier octet de sheet2.xml un instant plus tard, et le Read de A retourne un tampon qui appartient à l'autre membre. Le décompresseur de A décode désormais le milieu du flux deflate de quelqu'un d'autre ; zlib s'arrête en général avec Z_DATA_ERROR quelques blocs plus loin, et s'il produit par hasard une sortie, le CRC32 du membre ne concordera pas. Deflate n'est pas le problème ici. La course vit entièrement dans le curseur de position du flux, et un verrou autour de la paire seek-et-read suffit pour laisser beaucoup de workers déflater des membres du même paquet à la fois

La raison pour laquelle on veut cela, c'est la mémoire. Supposez qu'un classeur fasse 80 Mo sur disque et que son XML de feuilles se dilate de cinq à dix fois une fois déflaté. Un chemin d'ouverture qui extrait chaque feuille dans un tampon avant de l'analyser paie ces octets déflatés en plus du classeur qu'il construit, et le pic arrive avant qu'une seule cellule existe. Cet article couvre la concurrence au niveau paquet qui supprime l'étape d'extraction. Le plafond d'allocateur au-dessus est couvert dans l'article sur l'analyse XLSX parallèle et le gestionnaire de mémoire, et l'API de lecture en flux qui ne construit jamais de classeur est dans le pas à pas du lecteur direct en flux

La conception par mise en scène, et ce qu'elle garde en mémoire

La façon prudente de paralléliser une ouverture XLSX est un pipeline à trois phases où seule la phase du milieu tourne sur des workers. La phase A parcourt la liste des feuilles sur le thread appelant, crée chaque feuille, lit sa partie de relations, et copie le XML entier de la feuille déflatée dans un TMemoryStream privé. La phase B distribue l'analyse XML sur un pool de threads. La phase C retourne à l'archive, toujours sur le thread appelant, pour les petites parties satellites : commentaires, commentaires en fil, dessins, graphiques et tableaux

L'argument pour cette forme semble étanche. Une archive ZIP et son état de déflation ne sont pas thread-safe, alors gardez tout contact avec l'archive sur un thread et oubliez les verrous. La facture est un classeur de huit feuilles bien remplies tenant huit tampons de feuilles entièrement déflatés au même moment, et ces tampons sont les plus gros objets transitoires de tout le chemin d'ouverture. Avec le paquet de 80 Mo supposé plus haut, cela fait plusieurs centaines de mégaoctets de texte XML qui n'existent que pour qu'un analyseur le lise une fois

Diagramme d'un chemin d'ouverture XLSX par mise en scène en Delphi où la phase A copie chaque XML de feuille déflaté en mémoire avant que la phase B n'analyse les feuilles en parallèle
La phase A copie chaque feuille déflatée dans un flux mémoire privé sur un seul thread, si bien qu'un classeur de huit feuilles remplies tient huit grands tampons au pic

HotXLS garde les phases A et C mais abandonne la copie. Chaque worker de la phase B ouvre son propre flux d'entrée sur le paquet partagé et le remet directement au lecteur XML, si bien que la feuille déflatée n'existe jamais comme un bloc. Cela ne marche que parce que l'argument "not thread-safe" se révèle porter sur deux choses différentes

Deux threads peuvent-ils déflater depuis un seul flux ZIP ?

Oui, à condition que chaque thread possède son décompresseur et que la source d'octets partagée soit sérialisée à la lecture. L'argument prudent replie deux sortes d'état dans une seule phrase. L'état de déflation ne peut vraiment pas être partagé : un z_stream zlib porte la fenêtre glissante de 32 Ko que RFC 1951 définit, les tables de Huffman courantes et la position de bit à l'intérieur d'un membre compressé, et deux threads poussant des octets à travers le même produisent du charabia. La source d'octets est une autre affaire. Un flux de fichier a exactement un seul élément d'état mutable qui vaille protection, sa position

Le conteneur ZIP rend la séparation légale. L'APPNOTE de PKWARE (section 4.3) dispose un paquet en membres indépendants, chacun avec son propre en-tête de fichier local, ses propres données compressées à son propre décalage, et son propre CRC32 et ses tailles enregistrés dans le répertoire central. Aucun dictionnaire ne s'étend sur les membres comme le fait un bloc 7z solide, si bien que l'entrée N peut être déflatée sans lire un octet de l'entrée M. Donnez à chaque worker un décompresseur privé sur sa propre plage d'octets et la seule collision restante est le seek. Dans HotXLS, cette collision est gérée par TZipReadGate dans lxZipArchive.pas, et la classe entière tient sur un écran

type
  TZipReadGate = class
  private
    FBaseStream: TStream;
    FLock: TXLSCriticalSection;   // enveloppe lxPlatform d'une section critique
  public
    constructor Create(ABaseStream: TStream);
    destructor Destroy; override;
    function ReadAt(AOffset: Int64; var Buffer; Count: Longint): Longint;
  end;

function TZipReadGate.ReadAt(AOffset: Int64; var Buffer;
  Count: Longint): Longint;
begin
  if Count <= 0 then
  begin
    Result := 0;
    Exit;
  end;
  XLSEnterCriticalSection(FLock);
  try
    FBaseStream.Position := AOffset;
    Result := FBaseStream.Read(Buffer, Count);
  finally
    XLSLeaveCriticalSection(FLock);
  end;
end;

Ce que la porte de lecture verrouille, et ce qui reste dehors

TZipReadGate.ReadAt garde une opération indivisible, positionner le flux partagé et y lire. TZipArchive.OpenArchive construit la porte une fois le répertoire central analysé, pour les surcharges par nom de fichier comme par TStream, et libérer l'archive la relâche. Les archives créées en écriture n'en reçoivent jamais, parce que rien n'y lit en concurrence

Tout le reste reste hors du verrou parce que déjà privé ou déjà figé. Chaque flux d'entrée que TZipArchive.OpenFile retourne garde sa propre position dans son propre membre et la convertit en décalage absolu de paquet seulement au moment d'appeler ReadAt. Le décompresseur enveloppé autour est créé par entrée avec une taille de fenêtre de -15, que zlib lit comme du deflate brut sans en-tête zlib, et n'est jamais remis à un autre thread. Le répertoire central, en-têtes locaux compris, est entièrement analysé avant que tout worker démarre, si bien que chercher une entrée par nom est une recherche de hachage en lecture seule au moment où la concurrence commence. Les recherches sont insensibles à la casse et rejettent tout nom portant un segment .. dans son chemin

Diagramme d'architecture de la porte de lecture HotXLS en Delphi où des workers de feuilles concurrents déflatent des plages d'octets privées tandis qu'une seule section critique sérialise uniquement le seek et la lecture du flux partagé
Chaque worker possède sa position de sous-flux et son z_stream, si bien que le seul état partagé est le curseur du paquet et que TZipReadGate tient le verrou pour une seule lecture positionnée

Une conséquence compte quand vous appelez TXLSXWorkbook.Open(Stream) avec votre propre flux. La porte enveloppe ce flux, et son verrou coordonne les workers de la bibliothèque entre eux. Elle ne sait rien de votre code. Si un autre thread de votre application lit ou repositionne le même TStream pendant que Open tourne, la course de curseur du premier paragraphe est de retour, alors remettez le flux et laissez-le tranquille jusqu'à ce que Open retourne

Combien coûte un verrou sous contention ?

Moins que ce que suggère "un verrou global sur l'archive", vu tout le travail que couvre chaque acquisition. Le décompresseur remplit son entrée par blocs de 64 Ko d'octets compressés, si bien qu'un passage par la porte alimente 64 Ko de données deflate à un worker qui les décode et les analyse ensuite sans rien détenir. À la dilatation de cinq à dix fois supposée plus haut, ce rechargement devient environ 320 à 640 Ko de balisage de feuille. Le verrou lui-même couvre une lecture positionnée qui est en général servie depuis le cache de fichiers du système d'exploitation

Le ratio s'inverse à trois endroits, tous liés à l'entrée ou à la machine. Les membres stockés sans compression passent par la porte à chaque appel de lecture sans travail de déflation entre les acquisitions ; la plupart des rédacteurs déflatent les parties de feuilles, mais un paquet produit par un outil qui stocke tout sérialisera plus fort. Un fichier froid sur stockage lent ou réseau élargit la section critique, puisque la lecture dedans devient un vrai transfert. Et au-delà d'une poignée de workers, la porte n'est de toute façon pas le premier mur : l'analyse de feuilles alloue lourdement, et le gestionnaire de mémoire Delphi sérialise les allocations entre threads bien avant que la porte de lecture sature

Ce dernier point décide du compte de threads. TXLSXWorkbook.ParallelParseThreads vaut 0 par défaut, ce qui démarre un worker par processeur logique, jamais plus que le nombre de feuilles. Sur un portable à quatre à huit processeurs logiques, ce défaut va bien. Sur un serveur de build à 32 threads qui ouvre des classeurs à des dizaines de feuilles, posez un petit plafond explicite comme 4 : les workers en plus font en grande partie la queue sur l'allocateur et chacun détient son propre décompresseur et ses tampons. Pour trouver le bon nombre pour vos fichiers, ouvrez un classeur représentatif en boucle avec la propriété à 1, 2, 4 et 8, et comparez le temps mural au pic de mémoire de travail dans le Gestionnaire des tâches ou Process Explorer

Lire une entrée jusqu'au bout, c'est ce qui déclenche le contrôle CRC

Chaque flux d'entrée que l'archive retourne se contrôle, mais seulement à la fin. Il accumule un CRC32 courant au fil des octets qui passent, et quand sa position atteint la taille non compressée enregistrée dans le répertoire central, il compare les deux valeurs et sonde un octet de plus. Les échecs font surface comme des exceptions aux messages littéraux : 'ZIP entry uncompressed size mismatch', 'ZIP entry CRC32 mismatch', 'ZIP entry exceeds its declared size', et 'ZIP entry ended before its declared size' pour un membre qui s'arrête en route

Un lecteur XML s'arrête à la balise fermante </worksheet>. Tout ce qui suit, souvent juste un retour à la ligne final, reste non lu, la position n'atteint jamais la taille déclarée, et la comparaison ne tourne jamais. La conception par mise en scène avait le contrôle gratuitement, puisque copier une entrée dans un tampon lit chaque octet. Un analyseur qui lit le flux d'entrée directement, non, si bien que chaque worker HotXLS vide ce que l'analyseur a laissé avec un tampon de travail de 32 Ko avant de libérer le flux, et libérer le flux le plus extérieur relâche le décompresseur et le sous-flux en dessous

Diagramme de la chaîne de flux de worker HotXLS en Delphi depuis le paquet partagé via la porte de lecture jusqu'au flux vérifié, avec la boucle de vidage qui laisse les contrôles de taille et de CRC32 se déclencher
La boucle de vidage pousse la position jusqu'à la taille déclarée de l'entrée pour que la comparaison de taille et de CRC32 puisse tourner, attrapant une corruption qu'un lecteur XML laisserait non lue

La même règle vaut quand votre propre code utilise TZipArchive de lxZipArchive, par exemple pour vérifier la santé d'un paquet entrant avant de le remettre au chargeur de classeurs. Une fonction qui ne lit que les premiers kilooctets de chaque partie pour renifler son élément racine ne prouve rien sur l'intégrité. Lisez jusqu'au bout :

uses
  Classes, SysUtils, lxZipArchive;

// Lit une partie du paquet jusqu'à sa fin déclarée pour que les contrôles de taille et de CRC32 tournent.
// PartIsIntact est un helper que vous écrivez vous-même ; il ne fait pas partie de la bibliothèque
function PartIsIntact(Zip: TZipArchive; const PartName: WideString;
  out Problem: string): Boolean;
var
  Stream: TStream;
  Buffer: array [0..32767] of Byte;
begin
  Result := False;
  Problem := '';
  Stream := Zip.OpenFile(PartName);   // nil quand la partie n'existe pas
  if Stream = nil then
  begin
    Problem := 'part not found';
    Exit;
  end;
  try
    try
      while Stream.Read(Buffer, SizeOf(Buffer)) > 0 do
        ;
      Result := True;
    except
      on E: Exception do
        Problem := E.Message;          // p.ex. 'ZIP entry CRC32 mismatch'
    end;
  finally
    Stream.Free;
  end;
end;

var
  Zip: TZipArchive;
  Problem: string;
begin
  Zip := TZipArchive.Create;
  try
    Zip.OpenArchive('quarterly-consolidation.xlsx');
    if not PartIsIntact(Zip, 'xl/worksheets/sheet1.xml', Problem) then
      Writeln('sheet1.xml: ', Problem);
  finally
    Zip.Free;
  end;
end;

Le second piège de ce code est le rembobinage. Les flux d'entrée ne vont qu'en avant : lire Size marche, parce que la taille déclarée est connue d'avance, mais mettre Position := 0 après un coup d'œil lève 'ZIP entry stream is forward-only'. Si un composant insiste pour chercher, ouvrez l'entrée une seconde fois avec OpenFile, ce qui est bon marché et vous donne un flux neuf au décalage zéro. Plusieurs threads peuvent chacun appeler OpenFile sur une archive ouverte en lecture, puisque c'est exactement ce que fait le chargeur de classeurs, tant que rien ne libère l'archive pendant que leurs flux vivent

Ce qui tourne encore en série, et l'interrupteur qui éteint tout

La phase A tourne encore, moins l'extraction. Elle crée chaque feuille et lit ses relations sur le thread appelant, ce qui laisse chaque carte partagée immuable dès que les workers démarrent. La table de chaînes partagée et les tables de formats de cellule sont complètes avant que la phase B commence et ne sont jamais écrites pendant. La phase C parcourt encore les feuilles dans l'ordre ensuite pour commentaires, dessins, graphiques et tableaux, en sautant toute feuille dont la partie manque au paquet

uses
  SysUtils, lxHandleX;

var
  Wb: TXLSXWorkbook;
begin
  Wb := TXLSXWorkbook.Create;
  try
    Wb.ParallelParse := True;        // défaut ; False analyse une feuille à la fois
    Wb.ParallelParseThreads := 4;    // 0 = un worker par processeur logique
    Wb.SelectedSheets.Add('Q3 Detail');  // optionnel : analyser les cellules de cette seule feuille
    if Wb.Open('quarterly-consolidation.xlsx') <> 1 then
      raise Exception.Create('Workbook open failed');
    // Le classeur chargé est identique quel que soit le chemin qui l'a analysé
  finally
    Wb.Free;
  end;
end;

Mettre ParallelParse à False avant Open exécute le même travail par feuille avec un compte de threads de un, et le pool dégénère en une simple boucle sur le thread appelant. Un classeur à une seule feuille prend ce chemin quoi qu'il arrive. Gardez l'interrupteur en tête quand vous diagnostiquez un problème d'ouverture : il sort le threading de l'équation en une ligne, et les deux chemins exécutent le même code d'analyse, si bien que le classeur que vous récupérez ne dépend pas du réglage

Les erreurs gardent aussi la forme série. Quand un worker lève, plus aucune feuille n'est distribuée, chaque worker rejoint, et l'exception du plus bas index de feuille est relevée sur le thread appelant, si bien qu'un sheet3.xml corrompu fait toujours surface comme une seule exception depuis Open. Avec RecoveryMode à True, une feuille qui échoue à l'analyse est sautée à la place et enregistrée comme avertissement dans les diagnostics du classeur. Les feuilles laissées hors de SelectedSheets n'ouvrent jamais leur flux d'entrée du tout, et MetadataOnly saute entièrement la phase parallèle. Le réglage plus large du chemin d'ouverture est couvert dans le guide des performances des gros classeurs en Delphi

La porte de lecture, la phase d'ouverture parallèle et l'accès ZIP au niveau entrée montrés ici font partie du HotXLS Delphi Excel component pour Delphi et C++Builder, qui est livré avec le source complet, si bien que vous pouvez lire lxZipArchive.pas à côté des propriétés d'ouverture de TXLSXWorkbook décrites sur la page produit