HotXLS, det native Excel-komponentbiblioteket för Delphi och C++Builder, packar upp flera XLSX-kalkylblad samtidigt från ett enda öppet ZIP-paket. Mekanismen är TZipReadGate, en liten klass i lxZipArchive.pas som håller paketströmmen plus en kritisk sektion och exponerar exakt en metod. Den seriealiserar sök-och-läs-paret. Allt ovanför det paret körs samtidigt
Problemet som tvingade fram den här designen är ett varje Delphi-utvecklare som öppnat en stor arbetsbok har mött. En 80 MB xlsx är 80 MB deflaterad XML, och kalkylbladsdelarna inuti den expanderar ungefär fem till tio gånger. Om din öppningsväg extraherar varje kalkylblad till en minnesström innan den tolkas, betalar du för de uppackade byten ovanpå arbetsboken du bygger, och toppen kommer innan en enda cell har skapats. Den här artikeln handlar om paketnivå-samtidigheten som tar bort det mellanlagringssteget. Allokeringstaket som sitter ovanför det täcks i artikeln om parallell XLSX-tolkning och minneshanteraren, och läs-en-gång-materialisera-aldrig-API:et täcks i genomgången av den strömmande direktläsaren
Varför den gamla öppningsvägen mellanlagrade varje kalkylblad i RAM
Den ursprungliga parallella öppningen i HotXLS var en trefasig pipeline, och mittfasen var den enda som kördes på arbetare. Fas A vandrade bladlistan seriellt, skapade varje kalkylblad, läste dess relationsdel, och kopierade hela den uppackade kalkylblads-XML:en till en privat TMemoryStream. Fas B fläktade ut ParseWorksheetXml över poolen. Fas C gick tillbaka till arkivet på den anropande tråden för de små satellitdelarna: kommentarer, trådade kommentarer, ritningar, diagram, tabeller. Den formen valdes av en angiven anledning. Huvudkommentaren på lxParallelParse.pas brukade säga, i så många ord, att zip-arkivet och dess uppackningstillstånd inte är trådsäkra, och de interna anteckningarna gick längre: bry dig inte om att låsa arkivet, eftersom när uppackningstillståndsmaskinen väl är serialiserad per post köper låset ingenting. Fas A existerade för att hålla varje arkivberöring på en tråd. Kostnaden var att en arbetsbok med åtta upptagna blad höll åtta fullt uppackade kalkylblads-XML-buffertar i minnet samtidigt, och de buffertarna är de största transienta objekten i hela öppningsvägen
Kan två trådar packa upp från en ZIP-ström?
Ja, och det gamla omdömet var fel på ett specifikt, lokaliserbart sätt: det slog samman två olika delar av tillstånd till en enda mening. Uppackningstillstånd är genuint icke-delbart. En zlib z_stream bär det glidande fönstret, Huffman-tabellerna och bitpositionen för en komprimerad medlem, och två trådar som trycker byte genom samma en producerar skräp. Den underliggande bytekällan är en helt annan fråga, och svaret där är att en filström har exakt en bit muterbart delat tillstånd värt att skydda, dess positionsmarkör
ZIP-containern gör separationen laglig. Varje medlem i ett ZIP-arkiv komprimeras oberoende: sitt eget lokala filhuvud, sin egen deflate-bitström vid sin egen DataOffset, sin egen CRC32 och storlekar i centralkatalogen. Det finns ingen delad ordbok som sträcker sig över medlemmar på det sätt ett solitt 7z-block har, så post N kan packas upp utan att röra post M. Ge varje arbetare sin egen z_stream över sitt eget byteintervall och det enda de kolliderar på är söket. Den kollisionen är vad TZipReadGate tar bort, och hela klassen är tillräckligt kort att läsa på en 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;
Vad TZipReadGate skyddar och vad den avsiktligt inte gör
TZipReadGate.ReadAt skyddar en odelbar operation, positionering av den delade strömmen och läsning från den, och inget annat. TZipArchive.OpenArchive konstruerar grinden över FInputStream när centralkatalogen har tolkats framgångsrikt, och TZipArchive.Close frigör den. Arkiv öppnade för skrivning får aldrig en. Varje läsning en arbetare utför på paketet kanaliseras därför genom en enda kritisk sektion hållen under varaktigheten av en buffrad läsning
Allt annat förblir utanför låset eftersom det redan är privat eller redan oföränderligt. TZipSubStream håller sin egen FPosition, så varje arbetare spårar sin egen plats i sin egen post. TZLibStream som TZipEntry.GetStream bygger över den underströmmen är per post, skapad med windowBits på -15 för rå deflate, och delas aldrig. Centralkatalogen är fullständigt tolkad innan någon arbetare startar, inklusive varje lokalt huvud, så GetEntryByName är en skrivskyddad hash-uppslagning vid den tidpunkt samtidighet börjar. Dirigeringen själv är tre rader i TZipSubStream.Read, och grindlösa grenen är vad som håller varje befintlig enkeltrådig anropare på den gamla kodvägen
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;
Hur mycket kostar grinden under konkurrens?
Mindre än frasen "globalt lås på arkivet" antyder, på grund av granulariteten som TZLibStream råkar använda. Dess indatabuffert är BufferSize, definierad som $4000, så ReadInputBuffer drar 16 KB komprimerade byte per påfyllning och lämnar dem till zng_inflate. En låsförvärvning täcker därför 16 KB deflate-indata, vilket för kalkylblads-XML expanderar till något i storleksordningen 100 KB markup som arbetaren sedan avkodar och tolkar utan att hålla något. Låset hålls för en positionerad läsning mot operativsystemets cache; arbetet det grindar mäts i millisekunder
Den ärliga gränsen är där det förhållandet vänder. Poster lagrade snarare än deflaterade läses genom grinden ett-till-ett utan uppackningsarbete att dölja latensen bakom, så ett paket fullt av lagrade medlemmar skulle serialisera mycket hårdare. En kall fil på långsamt media breddar den kritiska sektionen, eftersom läsningen inuti den nu är en verklig disköverföring snarare än en cache-träff. Och bortom en handfull arbetare är grinden ändå inte vad du träffar först: kalkylbladstolkning är allokeringstungt, och Delphi-minneshanteraren serialiserar allokeringar över trådar långt innan läsgrinden blir begränsningen. Det är varför TXLSXWorkbook.ParallelParseThreads som standard har ett automatiskt tak i stället för en tråd per kärna
Arbetarkroppen, och tömningsloopen som är lätt att glömma
Med grinden på plats tog HotXLS bort Fas A-mellanlagringen helt. Arbetaren öppnar nu sin egen poströmm och matar den direkt till tolkaren. Två transienta fält bär indata: FParZip håller arkivet under varaktigheten av den parallella fasen, FParSheetPartNames håller delnamnen, och båda rensas i finally-blocket så ingen föråldrad pekare överlever en misslyckad öppning. Strömmen som kommer tillbaka från TZipArchive.OpenFile är en TZipVerifiedStream som omsluter en TZLibStream som omsluter en TZipSubStream, och att frigöra den yttre frigör kedjan
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;
Tömningsloopen är detaljen en rak port av den gamla koden skulle tappa, och att tappa den tystar integritetskontroll utan varning. TZipVerifiedStream ackumulerar en löpande CRC32 medan byte passerar genom och anropar VerifyComplete bara när dess position når den okomprimerade storleken registrerad i centralkatalogen; det är där storleksmissmatch- och CRC32-missmatch-undantagen kommer ifrån, plus en en-byte-sonderingsläsning som fångar en post längre än deklarerad. En XML-läsare stannar vid det avslutande elementet och lämnar vanligtvis en nyrad eller några byte efterföljande blanktecken olästa, så utan tömningen når positionen aldrig den deklarerade storleken och kontrollerna avfyras aldrig. Att läsa resten till en skrapbuffert kostar ingenting och återställer dem. När mellanlagringsströmmarna fanns gjorde XlsxCopyStreamAll detta av misstag
Vad som fortfarande körs seriellt, och flaggan som stänger av allt
Fas A överlever, minus extraktionen. Den skapar fortfarande varje kalkylblad och läser dess relationer på den anropande tråden, vilket är vad som lämnar varje delad karta oföränderlig när arbetare startar. Fas C vandrar fortfarande bladen seriellt efteråt för kommentarer, ritningar, diagram och tabeller, och dess vakt ändrades från en null-kontroll på den gamla mellanlagringsarrayen till zip.Exists mot delnamnet. De delade skrivskyddade indata arbetarna rör, den delade strängtabellen och cellXf-kartorna, är kompletta innan Fas B börjar och skrivs 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;
Att sätta ParallelParse till False före Open dispatchar samma jobbprocedur med ett trådantal på ett, och RunParallelJobs degenererar till en ren loop på den anropande tråden. Det är värt att veta av två anledningar: det är enradssvaret om en trådoro någonsin dyker upp i fält, och det betyder att den seriella och den parallella vägen delar en enda kropp av tolkningskod snarare än att divergera. Arbetarundantag fångas, det lägsta jobbindexet vinner, och felet kastas om på den anropande tråden efter att varje arbetare gått samman, så ett korrupt kalkylblad dyker fortfarande upp som ett undantag på förväntad plats. Allmän finjustering av den omgivande öppningsvägen täcks i guiden till stora arbetsbokers prestanda i Delphi
Läsgrinden, den parallella öppningsfasen och strömmande poståtkomst beskriven här levereras som del av standard-HotXLS Excel-komponenten för Delphi och C++Builder, med fullständig källkod; produktsidan bär den fullständiga TXLSXWorkbook-referensen inklusive de parallella öppningsegenskaperna