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
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
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
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