Teknisk artikel

Samtidig ZIP-uppackning i Delphi: HotXLS läsgrind

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