Twee threads delen één TFileStream over een .xlsx-package. Thread A zet Position op de eerste gecomprimeerde byte van sheet1.xml, thread B zet hem een moment later op de eerste byte van sheet2.xml, en de Read van A geeft een buffer terug die aan het andere lid toebehoort. De inflater van A decodeert nu het midden van iemand anders zijn deflate-stream; zlib stopt meestal met Z_DATA_ERROR na een paar blokken, en als hij toevallig output produceert, zal de CRC32 van het lid niet matchen. Deflate is hier niet het probleem. De race zit volledig in de positiecursor van de stream, en één lock rond het seek-en-read-paar is genoeg om veel workers tegelijk leden van hetzelfde package te laten inflaten
De reden dat iemand dat wil is geheugen. Stel dat een werkmap 80 MB op schijf is en zijn werkblad-XML vijf tot tien keer uitdijt bij het inflaten. Een openpad dat elk werkblad in een buffer extraheert voordat hij het parseert, betaalt die geïnflateerde bytes bovenop de werkmap die hij aan het bouwen is, en de piek komt voordat er één enkele cel bestaat. Dit artikel behandelt de concurrency op package-niveau die de extractiestap wegneemt. Het allocatoreplafon erboven staat in het artikel over parallelle XLSX-parsing en de geheugenbeheerder, en de read-once-API die helemaal geen werkmap bouwt staat in de walkthrough van de streaming direct reader
Het staging-ontwerp, en wat het in geheugen houdt
De voorzichtige manier om een XLSX-open te parallelliseren is een driefasenpijplijn waarbij alleen de middelste fase op workers draait. Fase A loopt de bladlijst af op de aanroepende thread, maakt elk werkblad aan, leest zijn relationship-part en kopieert de hele geïnflateerde werkblad-XML in een privé-TMemoryStream. Fase B waaiert de XML-parsing uit over een threadpool. Fase C keert terug naar het archief, nog steeds op de aanroepende thread, voor de kleine satellietdelen: commentaar, threaded comments, drawings, grafieken en tabellen
Het argument voor die vorm klinkt waterdicht. Een ZIP-archief en zijn inflatetoestand zijn niet thread-safe, dus houd elke archiefaanraking op één thread en sla locking helemaal over. De rekening is een werkmap met acht drukke bladen die op hetzelfde moment acht volledig geïnflateerde werkbladbuffers vasthoudt, en die buffers zijn de grootste tijdelijke objecten waar ook in het openpad. Met het hierboven veronderstelde package van 80 MB is dat een paar honderd megabyte XML-tekst die alleen bestaat zodat een parser hem één keer kan lezen
HotXLS houdt fase A en C aan maar laat de kopie vallen. Elke worker in fase B opent zijn eigen entrystream op het gedeelde package en geeft hem rechtstreeks aan de XML-lezer, dus het geïnflateerde werkblad bestaat nooit als één blok. Dat kan alleen omdat het argument "niet thread-safe" over twee verschillende dingen blijkt te gaan
Kunnen twee threads van één ZIP-stream inflaten?
Ja, mits elke thread zijn eigen inflater bezit en de gedeelde bytebron op de read wordt geserialiseerd. Het voorzichtige argument vouwt twee soorten toestand in één zin. Inflatetoestand kan echt niet worden gedeeld: een zlib-z_stream draagt het schuifvenster van 32 KB dat RFC 1951 definieert, de actuele Huffman-tabellen en de bitpositie binnen één gecomprimeerd lid, en twee threads die bytes door dezelfde duwen produceren rommel. De bytebron is iets anders. Een bestandsstream heeft precies één stuk muteerbare toestand dat bescherming waard is: zijn positie
De ZIP-container maakt de splitsing legaal. PKWARE's APPNOTE (sectie 4.3) legt een package neer als onafhankelijke leden, elk met zijn eigen local file header, zijn eigen gecomprimeerde data op zijn eigen offset, en zijn eigen CRC32 en groottes opgetekend in de central directory. Geen enkel dictionary overbrugt leden zoals een solid 7z-blok dat doet, dus entry N kan worden geïnflateerd zonder één byte van entry M te lezen. Geef elke worker een privé-inflater over zijn eigen bytebereik en de enige botsing die overblijft is de seek. In HotXLS wordt die botsing afgehandeld door TZipReadGate in lxZipArchive.pas, en de hele class past op één scherm
type
TZipReadGate = class
private
FBaseStream: TStream;
FLock: TXLSCriticalSection; // lxPlatform-wrapper over een critical section
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;
Wat de read gate lockt, en wat buiten hem blijft
TZipReadGate.ReadAt bewaakt één ondeelbare operatie: de gedeelde stream positioneren en eruit lezen. TZipArchive.OpenArchive bouwt de gate zodra de central directory is geparseerd, voor zowel de bestandsnaam- als de TStream-overload, en het vrijgeven van het archief geeft hem vrij. Archieven die voor schrijven zijn gemaakt krijgen er nooit een, want niemand leest er gelijktijdig uit
Alles andere blijft buiten de lock omdat het al privé of al bevroren is. Elke entrystream die TZipArchive.OpenFile teruggeeft houdt zijn eigen positie binnen zijn eigen lid bij en zet die pas om naar een absolute package-offset op het moment dat hij ReadAt aanroept. De inflater eromheen wordt per entry aangemaakt met een venstergrootte van -15, wat zlib leest als rauwe deflate zonder zlib-header, en wordt nooit aan een andere thread gegeven. De central directory, local headers incluis, is volledig geparseerd voordat een worker start, dus een entry op naam opzoeken is een read-only hash-opzoek tegen de tijd dat de concurrency begint. Opzoekingen zijn hoofdletterongevoelig en weigeren namen met ..-segmenten
Eén consequentie telt als u TXLSXWorkbook.Open(Stream) aanroept met uw eigen stream. De gate wordt om die stream gewikkeld, en zijn lock coördineert de workers van de bibliotheek met elkaar. Hij weet niets van uw code. Als een andere thread in uw applicatie dezelfde TStream leest of verplaatst terwijl Open draait, is de cursorrace uit de eerste alinea terug, dus geef de stream uit handen en laat hem met rust tot Open terugkomt
Hoeveel kost één lock onder contention?
Minder dan "een globale lock op het archief" doet vermoeden, vanwege hoeveel werk elke acquisitie dekt. De inflater vult zijn invoer aan in chunks van 64 KB gecomprimeerde bytes, dus één trip door de gate voedt een worker met 64 KB deflate-data die hij daarna decodeert en parseert zonder iets vast te houden. Bij de eerder veronderstelde uitbreiding van vijf tot tien keer wordt die aanvulling ruwweg 320 tot 640 KB werkblad-opmaak. De lock zelf dekt een gepositioneerde read die meestal uit de bestandscache van het besturingssysteem wordt geserveerd
De verhouding keert om op drie plekken, allemaal over de invoer of de machine. Leden die zonder compressie zijn opgeslagen gaan per read-aanroep één keer door de gate zonder inflatiewerk tussen de acquisities; de meeste schrijvers deflaten werkbladonderdelen, maar een package van een tool die alles opslaat serialiseert harder. Een koud bestand op trage of netwerkopslag verbreedt de critical section, want de read erin wordt een echte overdracht. En boven een handjevol workers is de gate toch niet de eerste muur: werkbladparsing alloceert zwaar, en de Delphi-geheugenbeheerder serialiseert toewijzingen over threads heen ruim voordat de read gate verzadigt
Dat laatste punt beslist het threadaantal. TXLSXWorkbook.ParallelParseThreads staat standaard op 0, wat één worker per logische processor start, nooit meer dan het aantal bladen. Op een laptop met vier tot acht logische processoren is die standaard prima. Op een buildserver met 32 threads die werkmappen met tientallen bladen opent, zet een expliciet klein plafon zoals 4: de extra workers staan vooral in de rij bij de allocator en elk houdt zijn eigen inflater en buffers vast. Om het juiste getal voor uw bestanden te vinden, open één representatieve werkmap in een lus met de property op 1, 2, 4 en 8, en vergelijk wandkloktijd met piek-working set in Task Manager of Process Explorer
Een entry tot het einde lezen is wat de CRC-controle laat afgaan
Elke entrystream die het archief teruggeeft controleert zichzelf, maar alleen aan het einde. Hij houdt een lopende CRC32 bij terwijl bytes passeren, en als zijn positie de ongecomprimeerde grootte uit de central directory bereikt, vergelijkt hij beide waarden en peilt hij één extra byte. De fouten komen naar boven als exceptions met letterlijke berichten: 'ZIP entry uncompressed size mismatch', 'ZIP entry CRC32 mismatch', 'ZIP entry exceeds its declared size', en 'ZIP entry ended before its declared size' voor een lid dat vroeg opraakt
Een XML-lezer stopt bij de afsluitende </worksheet>-tag. Alles erna, vaak alleen een afsluitende newline, blijft ongelezen, de positie bereikt nooit de opgegeven grootte en de vergelijking draait nooit. Het staging-ontwerp kreeg de controle gratis, want een entry in een buffer kopiëren leest elke byte. Een parser die de entrystream rechtstreeks leest niet, dus elke HotXLS-worker ledigt wat de parser achterlaat met een scratch-buffer van 32 KB voordat hij de stream vrijgeeft, en het vrijgeven van de buitenste stream geeft de inflater en de sub-stream eronder vrij
Dezelfde regel geldt als uw eigen code TZipArchive uit lxZipArchive gebruikt, bijvoorbeeld om een binnenkomend package te sanity-checken voordat het naar de werkmaploader gaat. Een functie die alleen de eerste paar kilobytes van elk onderdeel leest om zijn rootelement te snuffelen bewijst niets over integriteit. Lees tot het einde:
uses
Classes, SysUtils, lxZipArchive;
// Leest één package-onderdeel tot zijn opgegeven einde zodat de size- en CRC32-controles draaien.
// PartIsIntact is een hulpfunctie die u zelf schrijft; hij hoort niet bij de bibliotheek
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 als het onderdeel niet bestaat
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; // bijv. '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;
De tweede valkuil in die code is het terugspoelen. Entrystreams zijn alleen vooruit: Size lezen werkt, want de opgegeven grootte staat vooraf vast, maar na een kijkje Position := 0 zetten gooit 'ZIP entry stream is forward-only'. Als een component per se wil seeken, open de entry dan een tweede keer met OpenFile, wat goedkoop is en u een verse stream op offset nul geeft. Verschillende threads mogen elk OpenFile aanroepen op één archief dat voor lezen is geopend, want dat is precies wat de werkmaploader doet, zolang niets het archief vrijgeeft terwijl hun streams leven
Wat nog serieel draait, en de schakelaar die het uit zet
Fase A draait nog steeds, minus de extractie. Hij maakt elk werkblad aan en leest zijn relationships op de aanroepende thread, wat elke gedeelde map onveranderlijk laat zodra workers starten. De shared string table en de celindelingstabellen zijn compleet voordat fase B begint en worden er niet in geschreven. Fase C loopt daarna nog steeds de bladen in volgorde af voor commentaar, drawings, grafieken en tabellen, en slaat elk blad over waarvan het onderdeel ontbreekt in het package
uses
SysUtils, lxHandleX;
var
Wb: TXLSXWorkbook;
begin
Wb := TXLSXWorkbook.Create;
try
Wb.ParallelParse := True; // standaard; False parseert één blad per keer
Wb.ParallelParseThreads := 4; // 0 = één worker per logische processor
Wb.SelectedSheets.Add('Q3 Detail'); // optioneel: cellen alleen voor dit blad parsen
if Wb.Open('quarterly-consolidation.xlsx') <> 1 then
raise Exception.Create('Workbook open failed');
// De geladen werkmap is identiek, welk pad hem ook parseerde
finally
Wb.Free;
end;
end;
ParallelParse op False zetten vóór Open draait dezelfde per-blad-taak met een threadaantal van één, en de pool degeneert tot een kale lus op de aanroepende thread. Een werkmap met één werkblad neemt dat pad hoe dan ook. Houd de schakelaar in gedachten bij het diagnosticeren van een openprobleem: hij haalt threading in één regel uit beeld, en beide paden draaien dezelfde parsingcode, dus de werkmap die u terugkrijgt hangt niet van de instelling af
Fouten houden ook de seriële vorm. Als een worker gooit, worden er geen verdere bladen uitgezonden, joinen alle workers en wordt de exception van de laagste bladindex opnieuw gegooid op de aanroepende thread, dus een corrupt sheet3.xml komt nog steeds naar boven als één exception uit Open. Met RecoveryMode op True wordt een werkblad dat niet parseert overgeslagen en als waarschuwing opgetekend in de werkmapdiagnostiek. Bladen die buiten SelectedSheets vallen openen hun entrystream helemaal niet, en MetadataOnly slaat de parallelle fase helemaal over. Bredere afstelling van het openpad staat in de gids voor prestaties bij grote werkmappen in Delphi
De read gate, de parallelle openfase en de entryniveaubottoeg tot ZIP die hier worden getoond, maken deel uit van het HotXLS Delphi Excel component voor Delphi en C++Builder, dat met volledige bron wordt geleverd, zodat u lxZipArchive.pas kunt lezen naast de TXLSXWorkbook-openproperties die op de productpagina worden beschreven