Teknisk artikkel

Samtidig ZIP-inflate i Delphi: HotXLS lesegate

HotXLS, den native Excel-komponentbiblioteket for Delphi og C++Builder, pakker ut flere XLSX-regneark samtidig fra én åpen ZIP-pakke. Mekanismen er TZipReadGate, en liten klasse i lxZipArchive.pas som holder pakkestrømmen pluss én kritisk seksjon og eksponerer nøyaktig én metode. Den serialiserer seek-og-read-paret. Alt over det paret kjører samtidig

Problemet som tvang frem dette designet er ett hver Delphi-utvikler som har åpnet en stor arbeidsbok har møtt. En 80 MB xlsx er 80 MB deflatert XML, og regnearkdelene inni den ekspanderer omtrent fem til ti ganger. Hvis den åpne stien din trekker ut hvert regneark til en minnestrøm før den parser det, betaler du for de utpakkede bytene oppå arbeidsboken du bygger, og toppen ankommer før en eneste celle er opprettet. Denne artikkelen handler om samtidigheten på pakkenivå som fjerner det mellomlagringstrinnet. Allokeringstaket som sitter over det er dekket i artikkelen om parallell XLSX-parsing og minnehåndtereren, og API-et for les-én-gang, aldri-materialiser er dekket i gjennomgangen av den strømmende direktelesningen

Hvorfor mellomlagret den gamle åpne stien hvert regneark i RAM

Den opprinnelige parallelle åpningen i HotXLS var en tre-fase-pipeline, og den midterste fasen var den eneste som kjørte på arbeidere. Fase A gikk gjennom arklisten serielt, opprettet hvert regneark, leste dens relasjonsdel, og kopierte hele den utpakkede regneark-XML-en inn i en privat TMemoryStream. Fase B viftet ParseWorksheetXml ut over poolen. Fase C gikk tilbake til arkivet på den kallende tråden for de små satellittdelene: kommentarer, trådede kommentarer, tegninger, diagrammer, tabeller. Den formen ble valgt av en oppgitt grunn. Header-kommentaren i lxParallelParse.pas pleide å si, med rett ut sagte ord, at zip-arkivet og dets inflate-tilstand ikke er trådsikre, og de interne notatene gikk lenger: ikke bry deg med å låse arkivet, fordi når inflate-tilstandsmaskinen først er serialisert per oppføring, gir låsen ingenting. Fase A eksisterte for å holde hver arkivberøring på én tråd. Kostnaden var at en arbeidsbok med åtte travle ark holdt åtte fullstendig utpakkede regneark-XML-buffere i minnet samtidig, og de bufferne er de største flyktige objektene i hele den åpne stien

Kan to tråder pakke ut fra én ZIP-strøm?

Ja, og den gamle vurderingen var feil på en spesifikk, lokaliserbar måte: den slo sammen to forskjellige tilstandsbiter til én setning. Inflate-tilstand er genuint ikke delbar. En zlib z_stream bærer det glidende vinduet, Huffman-tabellene og bitposisjonen for ett komprimert medlem, og to tråder som skyver bytes gjennom samme én produserer søppel. Den underliggende bytekilden er et helt annet spørsmål, og svaret der er at en filstrøm har nøyaktig én bit muterbar delt tilstand verdt å beskytte, dens posisjonspeker

ZIP-containeren gjør skillet lovlig. Hvert medlem i et ZIP-arkiv er komprimert uavhengig: sin egen lokale filheader, sin egen deflate-bitstrøm ved sin egen DataOffset, sin egen CRC32 og størrelser i den sentrale katalogen. Det finnes ingen delt ordbok som spenner over medlemmer slik en solid 7z-blokk har, så oppføring N kan pakkes ut uten å røre oppføring M. Gi hver arbeider sin egen z_stream over sitt eget byteintervall, og det eneste de kolliderer på er seek-en. Den kollisjonen er det TZipReadGate fjerner, og hele klassen er kort nok til å lese i én skjerm

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;

Hva TZipReadGate beskytter, og hva den bevisst ikke gjør

TZipReadGate.ReadAt vokter én udelelig operasjon, å posisjonere den delte strømmen og lese fra den, og ingenting annet. TZipArchive.OpenArchive konstruerer gaten over FInputStream når den sentrale katalogen har parset uten feil, og TZipArchive.Close frigir den. Arkiver åpnet for skriving får aldri en. Hver lesing en arbeider utfører på pakken kanaliseres derfor gjennom én kritisk seksjon holdt for varigheten av én bufret lesing

Alt annet holder seg utenfor låsen fordi det allerede er privat eller allerede uforanderlig. TZipSubStream holder sin egen FPosition, så hver arbeider sporer sitt eget sted i sin egen oppføring. TZLibStream-en som TZipEntry.GetStream bygger over den delstrømmen er per oppføring, opprettet med windowBits på -15 for rå deflate, og aldri delt. Den sentrale katalogen er fullstendig parset før noen arbeider starter, inkludert hver lokal header, så GetEntryByName er et skrivebeskyttet hash-oppslag når samtidighet begynner. Ruteinen selv er tre linjer i TZipSubStream.Read, og den gate-løse grenen er det som holder hver eksisterende enkelttrådet klient på den gamle kodestien

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 mye koster gaten under kontensjon?

Mindre enn frasen «global lås på arkivet» antyder, på grunn av granulariteten TZLibStream tilfeldigvis bruker. Inndatabufferen dens er BufferSize, definert som $4000, så ReadInputBuffer trekker inn 16 KB komprimerte bytes per refyll og gir dem til zng_inflate. Én låseervervelse dekker derfor 16 KB deflate-inndata, som for regneark-XML ekspanderer til noe i størrelsesorden 100 KB markup som arbeideren deretter dekoder og parser uten å holde noe. Låsen holdes for en posisjonert lesing mot operativsystem-cachen; arbeidet den porter er målt i millisekunder

Den ærlige grensen er der det forholdet snur. Oppføringer lagret snarere enn deflaterte leser gjennom gaten én-til-én uten noe inflate-arbeid til å skjule ventetiden, så en pakke full av lagrede medlemmer ville serialisere mye hardere. En kald fil på treg media utvider den kritiske seksjonen, fordi lesingen inni den nå er en reell disktransfer snarere enn et cache-treff. Og forbi en håndfull arbeidere er ikke gaten det du treffer først uansett: regnearkparsing er allokeringstungt, og Delphi-minnehåndtereren serialiserer allokeringer på tvers av tråder godt før lesegaten blir begrensningen. Det er derfor TXLSXWorkbook.ParallelParseThreads som standard bruker et automatisk tak i stedet for én tråd per kjerne

Arbeiderkroppen, og drenerings-løkken det er lett å glemme

Med gaten på plass slettet HotXLS Fase A-mellomlagringen helt. Arbeideren åpner nå sin egen oppføringsstrøm og mater den rett til parseren. To flyktige felt bærer inndataene: FParZip holder arkivet for varigheten av den parallelle fasen, FParSheetPartNames holder delnavnene, og begge tømmes i finally-blokken slik at ingen forlept peker overlever en mislykket åpning. Strømmen som kommer tilbake fra TZipArchive.OpenFile er en TZipVerifiedStream som pakker inn en TZLibStream som pakker inn en TZipSubStream, og å frigi den ytre frigjør kjeden

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;

Drenerings-løkken er detaljen en direkte port av den gamle koden ville slippe, og å slippe den stille deaktiverer integritetssjekking. TZipVerifiedStream akkumulerer en løpende CRC32 mens bytes passerer gjennom, og kaller VerifyComplete bare når posisjonen når den ukomprimerte størrelsen registrert i den sentrale katalogen; det er der størrelsesavvik- og CRC32-avvik-unntakene kommer fra, pluss en én-byte prober-lesing som fanger en oppføring lengre enn erklært. En XML-leser stopper ved det avsluttende elementet og etterlater vanligvis et linjeskift eller noen få bytes trailing whitespace ulest, så uten dreneringen når posisjonen aldri den erklærte størrelsen og sjekkene utløses aldri. Å lese resten inn i en scratch-buffer koster ingenting og gjenoppretter dem. Da mellomlagringsstrømmene eksisterte, gjorde XlsxCopyStreamAll dette ved en tilfeldighet

Hva som fortsatt kjøres serielt, og flagget som slår alt av

Fase A overlever, minus utvinningen. Den oppretter fortsatt hvert regneark og leser dets relasjoner på den kallende tråden, som er det som lar hvert delt kart være uforanderlig når arbeidere starter. Fase C går fortsatt gjennom arkene serielt etterpå for kommentarer, tegninger, diagrammer og tabeller, og vakten dens endret seg fra en null-sjekk på det gamle mellomlagringsarrayet til zip.Exists mot delnavnet. De delte skrivebeskyttede inndataene arbeiderne rører, den delte strengtabellen og cellXf-kartene, er komplette før Fase B begynner og skrives aldri 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;

Å sette ParallelParse til False før Open sender ut samme jobbprosedyre med et trådantall på én, og RunParallelJobs degenererer til en ren løkke på den kallende tråden. Det er verdt å vite av to grunner: det er det ene-linjes svaret hvis en trådingsbekymring noensinne dukker opp i felten, og det betyr at den serielle og den parallelle stien deler én kropp av parsing-kode i stedet for å divergere. Arbeider-unntak fanges, den laveste jobbindeksen vinner, og feilen kastes på nytt på den kallende tråden etter at hver arbeider har sluttet seg til, så et korrupt regneark dukker fortsatt opp som ett unntak på det forventede stedet. Generell tilpasning av den omkringliggende åpne stien er dekket i veiledningen om ytelse for store arbeidsbøker i Delphi

Lesegaten, den parallelle åpne-fasen og den strømmende oppføringstilgangen beskrevet her leveres som del av standard HotXLS Excel-komponent for Delphi og C++Builder, med full kildekode; produktsiden bærer den fullstendige TXLSXWorkbook-referansen inkludert de parallelle åpne-egenskapene