Tekninen artikkeli

Rinnakkainen ZIP-purku Delphissä: HotXLS-lukuportti

HotXLS, natiivi Excel-komponenttikirjasto Delphille ja C++Builderille, purkaa useita XLSX-työarkkeja samaan aikaan yhdestä avoimesta ZIP-paketista. Mekanismi on TZipReadGate, pieni luokka tiedostossa lxZipArchive.pas, joka pitää pakettivirran plus yhden kriittisen alueen ja paljastaa täsmälleen yhden metodin. Se sarjallistaa haku-ja-luku-parin. Kaikki tuon parin yläpuolella ajetaan rinnakkain

Ongelma, joka pakotti tämän suunnittelun, on sellainen, jonka jokainen suuren työkirjan avannut Delphi-kehittäjä on kohdannut. 80 megatavun xlsx on 80 megatavua deflate-pakattua XML:ää, ja sen sisällä olevat työarkkiosat laajenevat suunnilleen viisi–kymmenkertaisiksi. Jos avauspolkusi erottaa jokaisen työarkin muistivirtaan ennen sen jäsentämistä, maksat puretuista tavuista rakentamasi työkirjan päälle, ja huippu saapuu ennen kuin yhtäkään solua on luotu. Tämä artikkeli käsittelee pakettitason rinnakkaisuutta, joka poistaa tuon välivaiheen. Sen yläpuolella istuva varaajan katto käsitellään artikkelissa rinnakkainen XLSX-jäsennys ja muistinhallinta, ja lue-kerran-älä-koskaan-materialisoi-API käsitellään artikkelissa suoratoistava suora lukija

Miksi vanha avauspolku vaiheisti jokaisen työarkin RAM-muistiin

Alkuperäinen rinnakkainen avaus HotXLS:ssä oli kolmivaiheinen putki, ja keskimmäinen vaihe oli ainoa, joka ajettiin työntekijöillä. Vaihe A käveli arkkilistan sarjallisesti, loi jokaisen työarkin, luki sen suhdeosan ja kopioi koko puretun työarkin XML:n yksityiseen TMemoryStream:iin. Vaihe B levitti ParseWorksheetXml:n poolin yli. Vaihe C palasi arkistoon kutsuvalla säikeellä pienten satelliittiosien vuoksi: kommentit, säikeistetyt kommentit, piirrokset, kaaviot, taulukot. Tuo muoto valittiin ilmoitetusta syystä. Otsikkokommentti tiedostossa lxParallelParse.pas sanoi ennen suoraan, että zip-arkisto ja sen purkutila eivät ole säiekelpoisia, ja sisäiset muistiinpanot menivät pidemmälle: älä vaivaudu lukitsemaan arkistoa, koska kun purkutilakone on kerran sarjallistettu merkinnittäin, lukitus ei osta mitään. Vaihe A oli olemassa pitääkseen jokaisen arkistokosketuksen yhdellä säikeellä. Kustannus oli, että työkirja, jossa oli kahdeksan kiireistä arkkia, piti kahdeksan täysin purettua työarkin XML-puskuria muistissa samanaikaisesti, ja nuo puskurit ovat koko avauspolun suurimmat väliaikaiset objektit

Voiko kaksi säiettä purkaa yhdestä ZIP-virrasta?

Kyllä, ja vanha arvio oli väärä tietyllä, paikannettavalla tavalla: se sulautti kaksi eri tilaosaa yhteen lauseeseen. Purkutila ei todella ole jaettavissa. Zlib-z_stream kantaa liukuvan ikkunan, Huffman-taulukot ja bittiaseman yhdelle pakatulle jäsenelle, ja kaksi säiettä, jotka työntävät tavuja saman läpi, tuottaa roskaa. Taustalla oleva tavulähde on täysin eri kysymys, ja sen vastaus on, että tiedostovirralla on täsmälleen yksi suojaamisen arvoinen muuttuva jaettu tilaosa, sen positiokursori

ZIP-säilö tekee erottelusta laillisen. Jokainen jäsen ZIP-arkistossa pakataan itsenäisesti: oma paikallinen tiedosto-otsikko, oma deflate-bittivirta omassa DataOffset:ssaan, oma CRC32 ja koot keskushakemistossa. Ei ole jäsenten yli ulottuvaa jaettua sanakirjaa samalla tavalla kuin kiinteällä 7z-lohkolla on, joten merkintä N voidaan purkaa koskematta merkintään M. Anna jokaiselle työntekijälle oma z_stream omalle tavualueelleen, ja ainoa, mistä ne törmäävät, on haku. Tuon törmäyksen TZipReadGate poistaa, ja koko luokka on tarpeeksi lyhyt luettavaksi yhdellä ruudulla

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;

Mitä TZipReadGate suojaa ja mitä se tarkoituksella ei suojaa

TZipReadGate.ReadAt vartioi yhtä jakamatonta operaatiota, jaetun virran positioimista ja siitä lukemista, eikä mitään muuta. TZipArchive.OpenArchive rakentaa portin FInputStream:in ympärille heti kun keskushakemisto on jäsennetty onnistuneesti, ja TZipArchive.Close vapauttaa sen. Kirjoittamista varten avatut arkistot eivät koskaan saa sellaista. Jokainen luku, jonka työntekijä suorittaa pakettiin, kulkee siis yhden kriittisen alueen läpi, joka pidetään yhden puskuroidun luvun ajan

Kaikki muu pysyy lukituksen ulkopuolella, koska se on jo yksityistä tai jo muuttumatonta. TZipSubStream pitää oman FPosition:insa, joten jokainen työntekijä seuraa omaa paikkaansa omassa merkinnässään. TZLibStream, jonka TZipEntry.GetStream rakentaa tuon alivirran ympärille, on merkintäkohtainen, luotu windowBits-arvolla -15 raakaa deflatea varten, eikä koskaan jaettu. Keskushakemisto on täysin jäsennetty ennen kuin yksikään työntekijä alkaa, mukaan lukien jokainen paikallinen otsikko, joten GetEntryByName on vain-luku-hakemistohaku siihen mennessä kun rinnakkaisuus alkaa. Reititys itsessään on kolme riviä tiedostossa TZipSubStream.Read, ja portiton haara on se, mikä pitää jokaisen olemassa olevan yksisäikeisen kutsujan vanhalla koodipolulla

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;

Kuinka paljon portti maksaa kilpailussa?

Vähemmän kuin lause "globaali lukitus arkistolla" antaa ymmärtää, koska TZLibStream sattuu käyttämään tiettyä rakeisuutta. Sen syötepuskuri on BufferSize, määritelty $4000:na, joten ReadInputBuffer vetää 16 kilotavua pakattuja tavuja per täyttö ja antaa ne zng_inflate:lle. Yksi lukituksen hankinta kattaa siis 16 kilotavua deflate-syötettä, joka työarkin XML:lle laajenee suuruusluokkaa 100 kilotavuun merkintäkieltä, jonka työntekijä sitten dekoodaa ja jäsentää pitämättä mitään. Lukitus pidetään positioidun luvun ajan käyttöjärjestelmän välimuistia vasten; työ, jota se porttaa, mitataan millisekunneissa

Rehellinen raja on siinä, missä tuo suhde kääntyy. Merkinnät, jotka on tallennettu deflate-pakkauksen sijaan, luetaan portin läpi yksi yhteen ilman purkutyötä, joka piilottaisi viiveen, joten paketti täynnä tallennettuja jäseniä sarjallistuisi paljon kovemmin. Kylmä tiedosto hitaalla medialla leventää kriittisen alueen, koska sen sisällä oleva luku on nyt todellinen levysiirto eikä välimuistiosuma. Ja kourallisen työntekijän jälkeen portti ei ole ensimmäinen asia, johon törmää joka tapauksessa: työarkin jäsennys on varausraskasta, ja Delphi-muistinhallinta sarjallistaa varaukset säikeiden yli hyvin ennen kuin lukuportista tulee rajoite. Siksi TXLSXWorkbook.ParallelParseThreads on oletuksena automaattinen katto yhden säikeen per ydin sijaan

Työntekijän runko ja tyhjennyssilmukka, joka on helppo unohtaa

Portin ollessa paikallaan HotXLS poisti Vaihe A -vaiheistuksen kokonaan. Työntekijä avaa nyt oman merkintävirtansa ja syöttää sen suoraan jäsentäjälle. Kaksi väliaikaista kenttää kantavat syötteet: FParZip pitää arkiston rinnakkaisvaiheen ajan, FParSheetPartNames pitää osanimet, ja molemmat tyhjennetään finally-lohkossa, jotta yksikään vanhentunut osoitin ei selviä epäonnistuneesta avauksesta. Virta, joka tulee takaisin TZipArchive.OpenFile:sta, on TZipVerifiedStream, joka kääriytyy TZLibStream:n ympärille, joka kääriytyy TZipSubStream:n ympärille, ja uloimman vapauttaminen vapauttaa ketjun

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;

Tyhjennyssilmukka on yksityiskohta, jonka vanhan koodin suora siirto pudottaisi, ja sen pudottaminen poistaa eheystarkistuksen hiljaa käytöstä. TZipVerifiedStream kerää juoksevan CRC32:n tavujen kulkiessa läpi ja kutsuu VerifyComplete:a vasta kun sen positio saavuttaa keskushakemistoon tallennetun puretun koon; siitä tulevat kokoristiriitapoikkeus ja CRC32-ristiriitapoikkeus, plus yhden tavun luotausluku, joka nappaa merkinnän, joka on pidempi kuin ilmoitettu. XML-lukija pysähtyy sulkevaan elementtiin ja jättää yleensä rivinvaihdon tai muutaman tavun perässä olevaa tyhjätilaa lukematta, joten ilman tyhjennystä positio ei koskaan saavuta ilmoitettua kokoa eivätkä tarkistukset koskaan laukea. Loppuosan lukeminen luonnospuskuriin ei maksa mitään ja palauttaa ne. Kun vaiheistusvirrat olivat olemassa, XlsxCopyStreamAll teki tätä vahingossa

Mikä ajetaan vielä sarjallisesti, ja lippu, joka kytkee sen kaiken pois

Vaihe A selviää, miinus erottaminen. Se yhä luo jokaisen työarkin ja lukee sen suhteet kutsuvalla säikeellä, mikä on se, mikä jättää jokaisen jaetun kartan muuttumattomaksi heti kun työntekijät alkavat. Vaihe C kävelee yhä arkit sarjallisesti sen jälkeen kommenttien, piirrosten, kaavioiden ja taulukoiden vuoksi, ja sen vartija muuttui vanhan vaiheistustaulukon null-tarkistuksesta zip.Exists:iin osanimeä vasten. Jaetut vain-luku-syötteet, joita työntekijät koskettavat, jaettu merkkijonotaulukko ja cellXf-kartat, ovat valmiit ennen kuin Vaihe B alkaa eikä niihin koskaan kirjoiteta sen aikana

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:n asettaminen False:ksi ennen Open:ia lähettää saman työprosessin yhden säikeen määrällä, ja RunParallelJobs rappeutuu tavalliseksi silmukaksi kutsuvalla säikeellä. Tämä kannattaa tietää kahdesta syystä: se on yhden rivin vastaus, jos säikeistysongelma joskus nousee esiin kentällä, ja se tarkoittaa, että sarjallinen ja rinnakkainen polku jakavat yhden jäsennyskoodin rungon sen sijaan, että ne poikkeaisivat toisistaan. Työntekijän poikkeukset kaapataan, alin työindeksi voittaa, ja virhe nostetaan uudelleen kutsuvalla säikeellä sen jälkeen kun jokainen työntekijä liittyy takaisin, joten korruptoitunut työarkki näkyy yhä yhtenä poikkeuksena odotetussa paikassa. Ympäröivän avauspolun yleinen viritys käsitellään artikkelissa suurten työkirjojen suorituskyky Delphissä

Lukuportti, rinnakkainen avausvaihe ja tässä kuvattu suoratoistava merkintäkäyttö toimitetaan osana vakiota HotXLS Excel -komponenttia Delphille ja C++Builderille, täydellä lähdekoodilla; tuotesivu kantaa täyden TXLSXWorkbook-viitteen mukaan lukien rinnakkaisen avauksen ominaisuudet