Article technique

Inflate ZIP Concurrent en Delphi : Le Verrou de Lecture HotXLS

HotXLS, la bibliothèque de composants Excel native pour Delphi et C++Builder, décompresse plusieurs feuilles de calcul XLSX en même temps à partir d un seul paquet ZIP ouvert. Le mécanisme est TZipReadGate, une petite classe dans lxZipArchive.pas qui détient le flux du paquet plus une section critique et n expose exactement qu une seule méthode. Elle sérialise la paire seek-et-read. Tout ce qui se trouve au-dessus de cette paire s exécute simultanément

Le problème qui a forcé cette conception est un que tout développeur Delphi ayant ouvert un grand classeur a rencontré. Un xlsx de 80 Mo est 80 Mo de XML déflaté, et les parties de feuille de calcul à l intérieur se dilatent à peu près cinq à dix fois. Si votre chemin d ouverture extrait chaque feuille de calcul dans un flux mémoire avant de l analyser, vous payez pour les octets décompressés en plus du classeur que vous construisez, et le pic arrive avant qu une seule cellule n ait été créée. Cet article porte spécifiquement sur la concurrence au niveau du paquet qui supprime cette étape intermédiaire. Le plafond de l allocateur qui se trouve au-dessus est couvert dans l article sur l analyse XLSX parallèle et le gestionnaire de mémoire, et l API de lecture unique, jamais matérialisée, est couverte dans la présentation du lecteur direct en flux

Pourquoi l ancien chemin d ouverture mettait-il en scène chaque feuille de calcul en RAM

L ouverture parallèle originale dans HotXLS était un pipeline à trois phases, et la phase intermédiaire était la seule à s exécuter sur des workers. La phase A parcourait la liste des feuilles de façon sérielle, créait chaque feuille de calcul, lisait sa partie de relation, et copiait le XML entier de la feuille décompressée dans un TMemoryStream privé. La phase B distribuait ParseWorksheetXml à travers le pool. La phase C retournait à l archive sur le thread appelant pour les petites parties satellites : commentaires, commentaires en fil de discussion, dessins, graphiques, tableaux. Cette forme a été choisie pour une raison énoncée. Le commentaire d en-tête sur lxParallelParse.pas disait, en autant de mots, que l archive zip et son état de décompression ne sont pas thread-safe, et les notes internes allaient plus loin : ne prenez pas la peine de verrouiller l archive, car une fois que l état de décompression est sérialisé par entrée, le verrou n apporte rien. La phase A existait pour garder chaque contact avec l archive sur un seul thread. Le coût était qu un classeur avec huit feuilles actives détenait huit tampons XML de feuille entièrement décompressés en mémoire simultanément, et ces tampons sont les plus gros objets transitoires de tout le chemin d ouverture

Deux threads peuvent-ils décompresser à partir d un seul flux ZIP ?

Oui, et l ancien jugement était faux d une manière spécifique et localisable : il a effondré deux éléments d état différents en une seule phrase. L état de décompression n est véritablement pas partageable. Un z_stream zlib porte la fenêtre glissante, les tables de Huffman et la position de bit pour un membre compressé, et deux threads poussant des octets à travers le même produisent du charabia. La source d octets sous-jacente est une question entièrement différente, et la réponse là-bas est qu un flux de fichier n a exactement qu un seul élément d état mutable partagé qui mérite protection, son curseur de position

Le conteneur ZIP rend la séparation légale. Chaque membre d une archive ZIP est compressé indépendamment : son propre en-tête de fichier local, son propre flux de bits deflate à son propre DataOffset, son propre CRC32 et ses tailles dans le répertoire central. Il n y a pas de dictionnaire partagé s étendant sur les membres comme le fait un bloc 7z solide, donc l entrée N peut être décompressée sans toucher à l entrée M. Donnez à chaque worker son propre z_stream sur sa propre plage d octets et la seule chose sur laquelle ils entrent en collision est le seek. Cette collision est ce que TZipReadGate supprime, et toute la classe est assez courte pour se lire en un seul écran

type
  TZipReadGate = class
  private
    FBaseStream: TStream;
    FLock: TRTLCriticalSection;
  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;
  EnterCriticalSection(FLock);
  try
    FBaseStream.Position := AOffset;
    Result := FBaseStream.Read(Buffer, Count);
  finally
    LeaveCriticalSection(FLock);
  end;
end;

Ce que TZipReadGate protège et ce qu il ne fait délibérément pas

TZipReadGate.ReadAt garde une opération indivisible, positionner le flux partagé et y lire, et rien d autre. TZipArchive.OpenArchive construit le verrou sur FInputStream une fois le répertoire central analysé avec succès, et TZipArchive.Close le libère. Les archives ouvertes en écriture n en reçoivent jamais. Chaque lecture qu un worker effectue sur le paquet passe donc par une section critique unique détenue pendant la durée d une lecture mise en tampon

Tout le reste reste hors du verrou car c est déjà privé ou déjà immuable. TZipSubStream conserve son propre FPosition, donc chaque worker suit sa propre place dans sa propre entrée. Le TZLibStream que TZipEntry.GetStream construit sur ce sous-flux est propre à chaque entrée, créé avec un windowBits de -15 pour le deflate brut, et jamais partagé. Le répertoire central est entièrement analysé avant qu un seul worker ne démarre, y compris chaque en-tête local, donc GetEntryByName est une recherche de hachage en lecture seule au moment où la concurrence commence. Le routage lui-même tient en trois lignes dans TZipSubStream.Read, et la branche sans verrou est ce qui garde tous les appelants monothread existants sur l ancien chemin de code

function TZipSubStream.Read(var buffer; Count: longint): longint;
var
  rest: Int64;
  rc: longint;
begin
  rest := FSize - FPosition;
  if (Count > rest) then
    Count := rest;
  if FReadGate <> nil then
    rc := FReadGate.ReadAt(FOffset + FPosition, buffer, Count)
  else
  begin
    FBaseStream.Position := FOffset + FPosition;
    rc := FBaseStream.Read(buffer, Count);
  end;
  FPosition := FPosition + rc;
  Result := rc;
end;

Combien coûte le verrou sous contention ?

Moins que ce que la phrase « verrou global sur l archive » suggère, à cause de la granularité que TZLibStream utilise justement. Son tampon d entrée est BufferSize, défini à $4000, donc ReadInputBuffer tire 16 Ko d octets compressés par rechargement et les remet à zng_inflate. Une acquisition de verrou couvre donc 16 Ko d entrée deflate, qui pour du XML de feuille de calcul se dilate en quelque chose de l ordre de 100 Ko de balisage que le worker décode ensuite et analyse sans rien détenir. Le verrou est détenu pour une lecture positionnée contre le cache du système d exploitation ; le travail qu il garde est mesuré en millisecondes

La limite honnête est là où ce ratio s inverse. Les entrées stockées plutôt que déflatées passent par le verrou un pour un sans travail de décompression pour masquer la latence, donc un paquet plein de membres stockés sérialiserait beaucoup plus durement. Un fichier froid sur média lent élargit la section critique, car la lecture à l intérieur est maintenant un vrai transfert disque plutôt qu un hit de cache. Et au-delà d une poignée de workers, le verrou n est de toute façon pas ce que vous heurtez en premier : l analyse de feuille de calcul est intensive en allocations, et le gestionnaire de mémoire Delphi sérialise les allocations à travers les threads bien avant que le verrou de lecture ne devienne la contrainte. C est pourquoi TXLSXWorkbook.ParallelParseThreads a par défaut un plafond automatique plutôt qu un thread par cœur

Le corps du worker, et la boucle de vidage facile à oublier

Avec le verrou en place, HotXLS a purement et simplement supprimé la mise en scène de la phase A. Le worker ouvre maintenant son propre flux d entrée et l alimente directement à l analyseur. Deux champs transitoires portent les entrées : FParZip détient l archive pour la durée de la phase parallèle, FParSheetPartNames détient les noms de partie, et les deux sont effacés dans le bloc finally afin qu aucun pointeur périmé ne survive à une ouverture échouée. Le flux qui revient de TZipArchive.OpenFile est un TZipVerifiedStream enveloppant un TZLibStream enveloppant un TZipSubStream, et libérer l extérieur libère la chaîne

procedure TXLSXWorkbook.ParseSheetJob(AIndex: Integer);
var
  Stream: TStream;
  DrainBuffer: array [0..32767] of Byte;
  PartName: WideString;
begin
  PartName := WideString(FParSheetPartNames[AIndex]);
  Stream := FParZip.OpenFile(PartName);
  if Stream = nil then
    Exit;
  try
    ParseWorksheetXml(Stream, FSheets.ByPos[AIndex], FParSst,
      FParRels[AIndex], FParFontMap, FParFillMap, FParBorderMap,
      FParNumFmtMap, FParAlignMap, FParProtMap, FParDateMap);
    // Consume any trailing bytes so the ZIP entry size and CRC are verified.
    while Stream.Read(DrainBuffer, SizeOf(DrainBuffer)) > 0 do
      ;
  finally
    Stream.Free;
  end;
end;

La boucle de vidage est le détail qu un portage direct de l ancien code laisserait tomber, et le laisser tomber désactive silencieusement le contrôle d intégrité. TZipVerifiedStream accumule un CRC32 courant à mesure que les octets passent et n appelle VerifyComplete que quand sa position atteint la taille non compressée enregistrée dans le répertoire central ; c est de là que viennent les exceptions de non-correspondance de taille et de non-correspondance CRC32, plus une lecture de sondage d un octet qui attrape une entrée plus longue que déclarée. Un lecteur XML s arrête à l élément de fermeture et laisse généralement une nouvelle ligne ou quelques octets d espace blanc final non lus, donc sans le vidage, la position n atteint jamais la taille déclarée et les vérifications ne se déclenchent jamais. Lire le reste dans un tampon de travail ne coûte rien et les restaure. Quand les flux de mise en scène existaient, XlsxCopyStreamAll faisait cela par accident

Ce qui reste sériel, et le drapeau qui désactive tout

La phase A survit, moins l extraction. Elle crée toujours chaque feuille de calcul et lit ses relations sur le thread appelant, ce qui laisse chaque carte partagée immuable une fois que les workers démarrent. La phase C parcourt toujours les feuilles sérielle-ment ensuite pour les commentaires, dessins, graphiques et tableaux, et sa garde est passée d une vérification nulle sur l ancien tableau de mise en scène à zip.Exists contre le nom de partie. Les entrées partagées en lecture seule que les workers touchent, la table de chaînes partagée et les cartes cellXf, sont complètes avant que la phase B ne commence et ne sont jamais écrites pendant celle-ci

var
  Wb: TXLSXWorkbook;
begin
  Wb := TXLSXWorkbook.Create;
  try
    Wb.ParallelParse := True;      // default; False forces one sheet at a time
    Wb.ParallelParseThreads := 4;  // 0 selects the automatic cap
    Wb.Open('quarterly-consolidation.xlsx');
    // ... workbook is identical either way ...
  finally
    Wb.Free;
  end;
end;

Définir ParallelParse à False avant Open distribue la même procédure de tâche avec un nombre de threads de un, et RunParallelJobs dégénère en une simple boucle sur le thread appelant. Cela vaut la peine d être connu pour deux raisons : c est la réponse en une ligne si une préoccupation de threading surgit un jour sur le terrain, et cela signifie que les chemins sériel et parallèle partagent un seul corps de code d analyse plutôt que de diverger. Les exceptions de worker sont capturées, l index de tâche le plus bas gagne, et l erreur est relevée sur le thread appelant après que chaque worker a rejoint, donc une feuille de calcul corrompue surgit toujours comme une seule exception à l endroit attendu. Le réglage général du chemin d ouverture environnant est couvert dans le guide de performance des grands classeurs en Delphi

Le verrou de lecture, la phase d ouverture parallèle et l accès aux entrées en flux décrits ici sont livrés dans le cadre du composant Excel HotXLS standard pour Delphi et C++Builder, avec le code source complet ; la page produit porte la référence complète de TXLSXWorkbook y compris les propriétés d ouverture parallèle