HotXLS, de native Excel-componentbibliotheek voor Delphi en C++Builder, inflate meerdere XLSX-werkbladen tegelijkertijd uit één geopende ZIP-package. Het mechanisme is TZipReadGate, een kleine klasse in lxZipArchive.pas die de packagestream plus één critical section vasthoudt en precies één methode blootstelt. Het serialiseert het seek-en-read-paar. Alles daarboven draait gelijktijdig
Het probleem dat dit ontwerp afdwong, is er een die elke Delphi-ontwikkelaar die ooit een grote werkmap opende, kent. Een xlsx van 80 MB is 80 MB gedeflated XML, en de werkbladdelen daarbinnen zetten ruwweg vijf- tot tienvoudig uit. Als je openpad elk werkblad naar een memorystream extraheert voordat het geparseerd wordt, betaal je voor de opgeblazen bytes bovenop de werkmap die je aan het bouwen bent, en de piek arriveert voordat er ook maar één cel gemaakt is. Dit artikel gaat over de package-niveau-gelijktijdigheid die die tussenstap wegneemt. Het allocatorplafond dat daarboven zit, wordt behandeld in het artikel over parallel XLSX parsen en de memory manager, en de read-once-nooit-materialiseren-API wordt behandeld in de doorloop van de streaming directe reader
Waarom het oude openpad elk werkblad in RAM in scène zette
De oorspronkelijke parallelle open in HotXLS was een pipeline met drie fasen, en de middelste fase was de enige die op workers draaide. Fase A liep de bladlijst serieel af, maakte elk werkblad aan, las zijn relatiedeel, en kopieerde de hele geïnflatete werkblad-XML naar een private TMemoryStream. Fase B waaierde ParseWorksheetXml uit over de pool. Fase C ging terug naar het archief op de aanroepende thread voor de kleine satellietdelen: comments, threaded comments, drawings, charts, tabellen. Die vorm was om een genoemde reden gekozen. Het headercommentaar op lxParallelParse.pas zei destijds, in min of meer die woorden, dat het zip-archief en zijn inflate-staat niet thread-safe zijn, en de interne notities gingen verder: moeite niet doen om het archief te vergrendelen, want zodra de inflate-staatmachine per item geserialiseerd is, levert de lock niets op. Fase A bestond om elke archiefaanraking op één thread te houden. De kost was dat een werkmap met acht drukke bladen acht volledig geïnflatete werkblad-XML-buffers tegelijk in geheugen hield, en die buffers zijn de grootste tijdelijke objecten in het hele openpad
Kunnen twee threads uit één ZIP-stream inflaten?
Ja, en het oude oordeel was fout op een specifieke, te lokaliseren manier: het smolt twee verschillende stukken staat samen in één zin. Inflate-staat is werkelijk niet deelbaar. Een zlib-z_stream draagt het schuivende venster, de Huffman-tabellen en de bitpositie voor één gecomprimeerd lid, en twee threads die bytes door dezelfde duwen, produceren rommel. De onderliggende bytebron is een geheel andere kwestie, en het antwoord daar is dat een filestream precies één stuk veranderlijke gedeelde staat heeft die het waard is om te beschermen, zijn positiecursor
De ZIP-container maakt de scheiding legaal. Elk lid in een ZIP-archief wordt onafhankelijk gecomprimeerd: zijn eigen lokale bestandsheader, zijn eigen deflate-bitstream op zijn eigen DataOffset, zijn eigen CRC32 en groottes in de central directory. Er is geen gedeeld woordenboek dat over leden heen loopt zoals een solid 7z-blok dat heeft, dus item N kan geïnflate worden zonder item M aan te raken. Geef elke worker zijn eigen z_stream over zijn eigen bytebereik en het enige waar ze op botsen, is de seek. Die botsing is wat TZipReadGate wegneemt, en de hele klasse is kort genoeg om in één scherm te lezen
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;
Wat TZipReadGate beschermt en wat het bewust niet doet
TZipReadGate.ReadAt bewaakt één ondeelbare bewerking, het positioneren van de gedeelde stream en het lezen ervan, en verder niets. TZipArchive.OpenArchive construeert de gate over FInputStream zodra de central directory succesvol geparseerd is, en TZipArchive.Close geeft hem vrij. Archieven geopend voor schrijven krijgen er nooit één. Elke lees die een worker op de package uitvoert, loopt daarom door één enkele critical section die vastgehouden wordt voor de duur van één gebufferde lees
Al het overige blijft buiten de lock omdat het al private of al onveranderlijk is. TZipSubStream houdt zijn eigen FPosition bij, dus elke worker volgt zijn eigen plek in zijn eigen item. De TZLibStream die TZipEntry.GetStream over die substream bouwt, is per item, aangemaakt met windowBits van -15 voor raw deflate, en nooit gedeeld. De central directory is volledig geparseerd voordat enige worker begint, inclusief elke lokale header, dus GetEntryByName is een read-only hash-lookup tegen de tijd dat gelijktijdigheid begint. De routering zelf is drie regels in TZipSubStream.Read, en de gateless tak is wat elke bestaande single-threaded aanroeper op het oude codepad houdt
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;
Hoeveel kost de gate onder contentie?
Minder dan de uitdrukking "globale lock op het archief" doet vermoeden, vanwege de granulariteit die TZLibStream toevallig gebruikt. Zijn invoerbuffer is BufferSize, gedefinieerd als $4000, dus ReadInputBuffer haalt 16 KB gecomprimeerde bytes per hervulling op en geeft die aan zng_inflate. Eén lockverwerving dekt daarom 16 KB deflate-invoer, wat voor werkblad-XML uitzet tot iets in de orde van 100 KB markup die de worker vervolgens decodeert en parseert zonder iets vast te houden. De lock wordt vastgehouden voor een gepositioneerde lees tegen de OS-cache; het werk dat het bewaakt, wordt in milliseconden gemeten
De eerlijke grens is waar die verhouding omslaat. Items die opgeslagen zijn in plaats van gedeflated, lezen één-op-één door de gate zonder inflate-werk om de latentie te verbergen, dus een package vol opgeslagen leden zou veel harder serialiseren. Een koud bestand op trage media verbreedt de critical section, omdat de lees erbinnen nu een echte schijfoverdracht is in plaats van een cache-hit. En voorbij een handjevol workers is de gate sowieso niet wat je als eerste raakt: werkbladparsing is allocatie-intensief, en de Delphi memory manager serialiseert allocaties over threads heen ruim voordat de read gate de beperkende factor wordt. Daarom valt TXLSXWorkbook.ParallelParseThreads standaard terug op een automatisch plafond in plaats van één thread per core
Het workerlichaam, en de afvoerlus die makkelijk vergeten wordt
Met de gate op zijn plaats heeft HotXLS Fase A-staging volledig verwijderd. De worker opent nu zijn eigen itemstream en voert die rechtstreeks aan de parser. Twee tijdelijke velden dragen de invoer: FParZip houdt het archief voor de duur van de parallelle fase, FParSheetPartNames houdt de deelnamen, en beide worden gewist in het finally-blok zodat geen verouderde pointer een mislukte open overleeft. De stream die terugkomt van TZipArchive.OpenFile is een TZipVerifiedStream die een TZLibStream omhult die een TZipSubStream omhult, en het vrijgeven van de buitenste geeft de hele keten vrij
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;
De afvoerlus is het detail dat een rechttoe-rechtaan port van de oude code zou laten vallen, en het laten vallen ervan schakelt stilzwijgend integriteitscontrole uit. TZipVerifiedStream accumuleert een lopende CRC32 terwijl bytes erdoorheen gaan en roept VerifyComplete pas aan wanneer zijn positie de ongecomprimeerde grootte bereikt die in de central directory staat; daar komen de groottemismatch- en CRC32-mismatch-exceptions vandaan, plus een lees van één probeer-byte die een item vangt dat langer is dan verklaard. Een XML-reader stopt bij het sluitende element en laat gewoonlijk een newline of een paar bytes trailing whitespace ongelezen achter, dus zonder de afvoer bereikt de positie nooit de verklaarde grootte en vuren de controles nooit. De rest inlezen in een kladbuffer kost niets en herstelt ze. Toen de stagingstreams nog bestonden, deed XlsxCopyStreamAll dit per ongeluk
Wat nog steeds serieel draait, en de vlag die het allemaal uitzet
Fase A overleeft, minus de extractie. Het maakt nog steeds elk werkblad aan en leest zijn relaties op de aanroepende thread, wat is wat elke gedeelde map onveranderlijk maakt zodra workers beginnen. Fase C loopt daarna nog steeds serieel door de bladen voor comments, drawings, charts en tabellen, en zijn bewaker veranderde van een null-check op de oude staging-array naar zip.Exists tegen de deelnaam. De gedeelde read-only invoer die de workers aanraken, de gedeelde stringtabel en de cellXf-maps, zijn compleet voordat Fase B begint en worden er nooit tijdens beschreven
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;
ParallelParse op False zetten vóór Open stuurt dezelfde jobprocedure aan met een threadaantal van één, en RunParallelJobs degenereert tot een gewone lus op de aanroepende thread. Dat is om twee redenen het weten waard: het is het eenregelige antwoord als een threading-kwestie ooit in het veld opduikt, en het betekent dat het seriële en het parallelle pad één lichaam parsingcode delen in plaats van uiteen te lopen. Workerexceptions worden opgevangen, de laagste job-index wint, en de fout wordt opnieuw geworpen op de aanroepende thread nadat elke worker joint, dus een corrupt werkblad verschijnt nog steeds als één exception op de verwachte plek. Algemene afstemming van het omringende openpad wordt behandeld in de gids voor prestaties van grote werkmappen in Delphi
De read gate, de parallelle openfase en de streaming-itemtoegang die hier beschreven worden, worden geleverd als onderdeel van het standaard HotXLS Excel-component voor Delphi en C++Builder, met volledige broncode; de productpagina draagt de complete TXLSXWorkbook-referentie inclusief de parallelle open-eigenschappen