HotXLS, den native Excel-komponentbibliotek til Delphi og C++Builder, inflaterer flere XLSX-regneark på samme tid ud af én åben ZIP-pakke. Mekanismen er TZipReadGate, en lille klasse i lxZipArchive.pas, der holder pakke-strømmen plus én kritisk sektion og eksponerer nøjagtig én metode. Den serialiserer seek-og-læs-parret. Alt over det par kører samtidigt
Problemet, der tvang dette design frem, er ét, hver Delphi-udvikler, der har åbnet en stor projektmappe, har mødt. En 80 MB xlsx er 80 MB deflateret XML, og regneark-delene indeni udvider sig groft sagt fem til ti gange. Ekstraherer ens åbningssti hvert regneark til en hukommelsesstrøm, før den parses, betaler man for de oppustede bytes oven i den projektmappe, man er ved at bygge, og toppen ankommer, før en eneste celle er oprettet. Denne artikel handler om pakke-niveau-samtidigheden, der fjerner det mellemtrin. Allokeringsloftet, der sidder over det, er dækket i artiklen om parallel XLSX-parsing og hukommelsesstyringen, og den læs-én-gang, aldrig-materialiser-API er dækket i gennemgangen af den strømmende direkte læser
Hvorfor iscenesatte den gamle åbningssti hvert regneark i RAM
Den oprindelige parallelle åbning i HotXLS var en tre-fase pipeline, og den midterste fase var den eneste, der kørte på workere. Fase A gennemgik arklisten serielt, oprettede hvert regneark, læste dets relationsdel, og kopierede hele det inflaterede regneark-XML ind i en privat TMemoryStream. Fase B spredte ParseWorksheetXml ud over poolen. Fase C gik tilbage til arkivet på den kaldende tråd for de små satellit-dele: kommentarer, threaded comments, tegninger, diagrammer, tabeller. Den form var valgt af en angivet grund. Header-kommentaren på lxParallelParse.pas plejede at sige, med lige de ord, at zip-arkivet og dets inflate-tilstand ikke er trådsikre, og de interne noter gik videre: gider ikke låse arkivet, for når inflate-tilstandsmaskinen først er serialiseret pr. post, køber låsen intet. Fase A eksisterede for at holde hver arkivberøring på én tråd. Prisen var, at en projektmappe med otte travle ark holdt otte fuldt inflaterede regneark-XML-buffere i hukommelsen samtidig, og de buffere er de største transiente objekter i hele åbningsstien
Kan to tråde inflatere fra én ZIP-strøm?
Ja, og den gamle vurdering var forkert på en specifik, lokaliserbar måde: den slog to forskellige stykker tilstand sammen i én sætning. Inflate-tilstand er genuint ikke delbar. En zlib z_stream bærer det glidende vindue, Huffman-tabellerne og bitpositionen for ét komprimeret medlem, og to tråde, der skubber bytes gennem samme ét, producerer skrammel. Den underliggende bytekilde er et helt andet spørgsmål, og svaret der er, at en filstrøm har præcis ét stykke muterbar delt tilstand, det er værd at beskytte, dens positionscursor
ZIP-containeren gør adskillelsen lovlig. Hvert medlem i et ZIP-arkiv komprimeres uafhængigt: sit eget lokale filhoved, sin egen deflate-bitstrøm ved sin egen DataOffset, sit eget CRC32 og størrelser i den centrale mappe. Der er ingen delt ordbog, der spænder over medlemmer, sådan som en solid 7z-blok har, så post N kan inflateres uden at røre post M. Giver man hver worker sin egen z_stream over sit eget byteinterval, kolliderer de kun på seeket. Den kollision er, hvad TZipReadGate fjerner, og hele klassen er kort nok til at læse på én skærm
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;
Hvad TZipReadGate beskytter, og hvad den bevidst ikke gør
TZipReadGate.ReadAt vogter én udelelig operation, positionering af den delte strøm og læsning fra den, og intet andet. TZipArchive.OpenArchive konstruerer gaten over FInputStream, når den centrale mappe har parset med succes, og TZipArchive.Close frigiver den. Arkiver åbnet til skrivning får aldrig én. Hver læsning, en worker udfører på pakken, kanaliseres derfor gennem én kritisk sektion holdt for varigheden af én bufret læsning
Alt andet forbliver uden for låsen, fordi det allerede er privat eller allerede uforanderligt. TZipSubStream holder sin egen FPosition, så hver worker sporer sit eget sted i sin egen post. TZLibStream, som TZipEntry.GetStream bygger over den delstrøm, er pr. post, oprettet med windowBits på -15 for rå deflate, og aldrig delt. Den centrale mappe er fuldt parset, før nogen worker starter, inklusive hvert lokalt hoved, så GetEntryByName er et skrivebeskyttet hash-opslag, når samtidigheden begynder. Selve routingen er tre linjer i TZipSubStream.Read, og den gateløse gren er det, der holder hver eksisterende enkelttrådet kalder på den gamle kodesti
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;
Hvor meget koster gaten under belastning?
Mindre end frasen "global lås på arkivet" antyder, på grund af granulariteten TZLibStream tilfældigvis bruger. Dens inputbuffer er BufferSize, defineret som $4000, så ReadInputBuffer trækker 16 KB komprimerede bytes pr. genopfyldning og afleverer dem til zng_inflate. Én låserhvervelse dækker derfor 16 KB deflate-input, hvilket for regneark-XML udvider sig til noget i størrelsesordenen 100 KB markup, som workeren så afkoder og parser uden at holde noget. Låsen holdes for en positioneret læsning mod operativsystemets cache; arbejdet, den vogter, måles i millisekunder
Den ærlige grænse er, hvor det forhold vender. Poster, der er lagret frem for deflaterede, læser gennem gaten én til én uden inflate-arbejde til at skjule latensen, så en pakke fuld af lagrede medlemmer ville serialisere langt hårdere. En kold fil på langsomme medier udvider den kritiske sektion, fordi læsningen inde i den nu er en reel disktransport frem for et cache-hit. Og forbi en håndfuld workere er gaten ikke det, man rammer først alligevel: regneark-parsing er allokeringstungt, og Delphis hukommelsesstyring serialiserer allokeringer på tværs af tråde længe før læsegaten bliver begrænsningen. Det er grunden til, at TXLSXWorkbook.ParallelParseThreads som standard vælger et automatisk loft frem for én tråd pr. kerne
Worker-kroppen, og dræningsløkken, det er let at glemme
Med gaten på plads slettede HotXLS Fase A-iscenesættelsen helt. Workeren åbner nu sin egen post-strøm og fodrer den lige til parseren. To transiente felter bærer input: FParZip holder arkivet for varigheden af den parallelle fase, FParSheetPartNames holder delnavnene, og begge ryddes i finally-blokken, så ingen forældet pointer overlever en fejlet åbning. Strømmen, der kommer tilbage fra TZipArchive.OpenFile, er en TZipVerifiedStream, der pakker en TZLibStream, der pakker en TZipSubStream, og at frigive den ydre frigiver kæden
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;
Dræningsløkken er den detalje, en direkte porting af den gamle kode ville droppe, og at droppe den deaktiverer stiltiende integritetstjek. TZipVerifiedStream akkumulerer en løbende CRC32, mens bytes passerer igennem, og kalder kun VerifyComplete, når dens position når den ukomprimerede størrelse registreret i den centrale mappe; det er der, størrelsesuoverensstemmelsen og CRC32-uoverensstemmelsen kommer fra, plus en enkeltbyte-sondering, der fanger en post længere end erklæret. En XML-læser stopper ved det lukkende element og efterlader som regel en linjeskift eller nogle få bytes efterfølgende whitespace ulæst, så uden dræning når positionen aldrig den erklærede størrelse, og tjekkene udløses aldrig. At læse resten ind i en scratch-buffer koster intet og genopretter dem. Da iscenesættelsesstrømmene eksisterede, gjorde XlsxCopyStreamAll dette ved et tilfælde
Hvad der stadig kører serielt, og flaget der slukker det hele
Fase A overlever, minus ekstraktionen. Den opretter stadig hvert regneark og læser dets relationer på den kaldende tråd, hvilket er det, der lader hvert delt map forblive uforanderligt, når workere starter. Fase C gennemgår stadig arkene serielt bagefter for kommentarer, tegninger, diagrammer og tabeller, og dens vagt skiftede fra et null-tjek på det gamle iscenesættelsesarray til zip.Exists mod delnavnet. De delte skrivebeskyttede input, workerne rører, den delte strengtabel og cellXf-mapsene, er fuldstændige før Fase B begynder, og skrives aldrig under den
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;
At sætte ParallelParse til False før Open dispatcher den samme job-procedure med et trådantal på ét, og RunParallelJobs degenererer til en almindelig løkke på den kaldende tråd. Det er værd at vide af to grunde: det er ét-linje-svaret, hvis en trådningsbekymring nogensinde dukker op i marken, og det betyder, at den serielle og den parallelle sti deler én krop af parsing-kode frem for at afvige. Worker-undtagelser opfanges, det laveste jobindeks vinder, og fejlen genrejses på den kaldende tråd, efter hver worker har sluttet sig sammen, så et korrupt regneark stadig fremstår som én undtagelse på det forventede sted. Generel tuning af den omgivende åbningssti er dækket i guiden til stor projektmappe-ydeevne i Delphi
Læsegaten, den parallelle åbningsfase og den strømmende post-adgang beskrevet her leveres som en del af den standard HotXLS Excel-komponent til Delphi og C++Builder, med fuld kildekode; produktsiden bærer den komplette TXLSXWorkbook-reference inklusive de parallelle åbningsegenskaber