Tehnički članak

Istovremeno ZIP inflate u Delphiju: HotXLS read gate

HotXLS, izvorna Excel biblioteka komponenti za Delphi i C++Builder, istovremeno inflate-a nekoliko XLSX radnih listova iz jednog otvorenog ZIP paketa. Mehanizam je TZipReadGate, mala klasa u lxZipArchive.pas koja drži stream paketa plus jednu kritičnu sekciju i izlaže točno jednu metodu. Serijalizira par seek-i-read. Sve iznad tog para radi istovremeno

Problem koji je prisilio ovaj dizajn poznaje svaki Delphi developer koji je otvorio velik radni sveščić. Xlsx od 80 MB je 80 MB deflatiranog XML-a, a dijelovi radnog lista unutar njega proširuju se otprilike pet do deset puta. Ako vaš put otvaranja izvlači svaki radni list u memorijski stream prije raščlambe, plaćate za inflatirane bajtove uz radni sveščić koji gradite, a vrhunac stiže prije nego je stvorena ijedna stanica. Ovaj se članak bavi istovremenošću na razini paketa koja uklanja taj korak pripreme. Granica alokatora koja sjedi iznad toga pokrivena je u članku o paralelnoj raščlambi XLSX-a i memorijskom modulu, a API čitaj-jednom, nikad-ne-materijaliziraj pokriven je u vodiču kroz streaming izravni čitač

Zašto je stari put otvaranja pripremao svaki radni list u RAM-u

Izvorno paralelno otvaranje u HotXLS-u bio je pipeline s tri faze, a srednja faza bila je jedina koja se izvodila na workerima. Faza A prolazila je popis listova serijski, stvarala svaki radni list, čitala njegov dio relacije, i kopirala cijeli inflatirani XML radnog lista u privatni TMemoryStream. Faza B raspoređivala je ParseWorksheetXml preko poola. Faza C vraćala se u arhivu na pozivajućoj niti za male satelitske dijelove: komentare, threadane komentare, crteže, grafikone, tablice. Taj oblik odabran je iz navedenog razloga. Komentar u zaglavlju na lxParallelParse.pas nekad je govorio, otprilike doslovno, da ZIP arhiva i njeno stanje inflate nisu thread-safe, a interne bilješke otišle su dalje: nema smisla zaključavati arhivu, jer kad je stanje inflate serijalizirano po unosu, brava ne kupuje ništa. Faza A postojala je da drži svaki dodir arhivi na jednoj niti. Cijena je bila da je radni sveščić s osam zauzetih listova držao osam posve inflatiranih međuspremnika XML-a radnog lista istovremeno u memoriji, a ti su međuspremnici najveći prolazni objekti u cijelom putu otvaranja

Mogu li dvije niti inflate-ati iz jednog ZIP streama?

Da, i stara je procjena bila pogrešna na specifičan, lociv način: sažela je dva različita komada stanja u jednu rečenicu. Stanje inflate istinski se ne može dijeliti. zlib z_stream nosi klizni prozor, Huffmanove tablice i poziciju bita za jednog komprimiranog člana, a dvije niti koje guraju bajtove kroz isti proizvode smeće. Osnovni izvor bajtova je posve drugo pitanje, a odgovor ondje je da stream datoteke ima točno jedan komad promjenjivog dijeljenog stanja vrijedan zaštite, svoj kursor pozicije

ZIP kontejner čini razdvajanje legalnim. Svaki član u ZIP arhivi komprimira se neovisno: vlastito lokalno zaglavlje datoteke, vlastiti bitovni stream deflate na vlastitom DataOffset, vlastiti CRC32 i veličine u centralnom direktoriju. Nema dijeljenog rječnika koji obuhvaća članove na način na koji to ima solid 7z blok, pa se unos N može inflate-ati bez diranja unosa M. Dajte svakom workeru vlastiti z_stream preko vlastitog raspona bajtova, i jedino na čemu se sudaraju je seek. Taj sudar uklanja TZipReadGate, a cijela je klasa dovoljno kratka za pročitati na jednom ekranu

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;

Što TZipReadGate štiti, a što namjerno ne štiti

TZipReadGate.ReadAt čuva jednu nedjeljivu operaciju, pozicioniranje dijeljenog streama i čitanje iz njega, i ništa drugo. TZipArchive.OpenArchive gradi gate preko FInputStream čim se centralni direktorij uspješno raščlani, a TZipArchive.Close ga oslobađa. Arhive otvorene za pisanje nikad ga ne dobiju. Svako čitanje koje worker izvodi na paketu stoga se usmjerava kroz jednu kritičnu sekciju držanu tijekom jednog međuspremljenog čitanja

Sve ostalo ostaje izvan brave jer je već privatno ili već nepromjenjivo. TZipSubStream drži vlastiti FPosition, tako da svaki worker prati vlastito mjesto u vlastitom unosu. TZLibStream koji TZipEntry.GetStream gradi preko tog podstreama je po unosu, stvoren s windowBits od -15 za sirovi deflate, i nikad se ne dijeli. Centralni direktorij posve je raščlanjen prije nego ijedan worker počne, uključujući svako lokalno zaglavlje, tako da je GetEntryByName pretraga hash-a samo za čitanje do trenutka kad istovremenost počne. Samo usmjeravanje je tri retka u TZipSubStream.Read, a grana bez gate-a je ono što drži svakog postojećeg jednonitnog pozivatelja na starom putu koda

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;

Koliko gate košta pod natjecanjem?

Manje nego što fraza "globalna brava na arhivi" sugerira, zbog granularnosti koju TZLibStream slučajno koristi. Njegov ulazni međuspremnik je BufferSize, definiran kao $4000, pa ReadInputBuffer povlači 16 KB komprimiranih bajtova po dopuni i predaje ih zng_inflate. Jedno stjecanje brave stoga pokriva 16 KB ulaza deflate, što se za XML radnog lista proširuje u nešto reda veličine 100 KB označavanja koje worker zatim dekodira i raščlanjuje bez ičega drugog da drži. Brava se drži za pozicionirano čitanje protiv predmemorije operacijskog sustava; posao koji zaključava mjeri se u milisekundama

Iskrena granica je gdje se taj omjer okreće. Unosi pohranjeni umjesto deflatirani čitaju se kroz gate jedan-na-jedan bez posla inflate koji bi sakrio latenciju, pa bi se paket pun pohranjenih članova ozbiljno serijalizirao. Hladna datoteka na sporim medijima širi kritičnu sekciju, jer je čitanje unutar nje sad stvaran prijenos s diska, a ne pogodak predmemorije. I iznad šačice workera gate ionako nije ono na što prvo naiđete: raščlamba radnog lista je teška alokacijama, a Delphi memorijski modul serijalizira alokacije preko niti puno prije nego read gate postane ograničenje. Zato TXLSXWorkbook.ParallelParseThreads zadano ima automatsku granicu umjesto jedne niti po jezgri

Tijelo workera, i petlja isušivanja koju je lako zaboraviti

S gate-om na mjestu, HotXLS je posve obrisao pripremu Faze A. Worker sad otvara vlastiti stream unosa i hrani ga izravno parseru. Dva prolazna polja nose ulaze: FParZip drži arhivu za trajanje paralelne faze, FParSheetPartNames drži imena dijelova, i oboje se briše u bloku finally tako da nijedan zastario pokazivač ne preživi neuspjelo otvaranje. Stream koji se vraća iz TZipArchive.OpenFile je TZipVerifiedStream koji omata TZLibStream koji omata TZipSubStream, a oslobađanje vanjskog oslobađa lanac

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;

Petlja isušivanja je detalj koji bi izravan port starog koda ispustio, a njeno ispuštanje tiho isključuje provjeru cjelovitosti. TZipVerifiedStream nakuplja tekući CRC32 dok bajtovi prolaze i poziva VerifyComplete tek kad njena pozicija dosegne nekomprimiranu veličinu zabilježenu u centralnom direktoriju; odatle dolaze iznimke neslaganja veličine i neslaganja CRC32, plus čitanje ispitivanja od jednog bajta koje hvata unos duži od deklariranog. XML čitač staje na zatvarajućem elementu i obično ostavlja nepročitan novi redak ili nekoliko bajtova prateće bjeline, pa bez isušivanja pozicija nikad ne dosegne deklariranu veličinu i provjere se nikad ne pokrenu. Čitanje ostatka u pomoćni spremnik ne košta ništa i vraća ih. Kad su postojali streamovi za pripremu, XlsxCopyStreamAll je to radio slučajno

Što i dalje radi serijski, i zastavica koja sve to isključuje

Faza A preživljava, minus ekstrakcija. I dalje stvara svaki radni list i čita njegove relacije na pozivajućoj niti, što je ono što ostavlja svaku dijeljenu mapu nepromjenjivom čim workeri počnu. Faza C i dalje prolazi kroz listove serijski nakon toga za komentare, crteže, grafikone i tablice, a njena zaštita promijenila se iz provjere null na starom nizu pripreme u zip.Exists protiv imena dijela. Dijeljeni ulazi samo za čitanje koje workeri diraju, dijeljena tablica nizova i mape cellXf, potpuni su prije nego Faza B počne i nikad se ne pišu tijekom nje

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;

Postavljanje ParallelParse na False prije Open raspoređuje istu proceduru posla s brojem niti od jedan, a RunParallelJobs degenerira se u običnu petlju na pozivajućoj niti. To je vrijedno znati iz dva razloga: to je jednorečenični odgovor ako se ikad na terenu pojavi zabrinutost oko niti, i znači da serijski i paralelni put dijele jedno tijelo koda raščlambe umjesto da se razilaze. Iznimke workera hvataju se, najniži indeks posla pobjeđuje, a greška se ponovno izaziva na pozivajućoj niti nakon što se svaki worker pridruži, tako da oštećen radni list i dalje izlazi na površinu kao jedna iznimka na očekivanom mjestu. Opće podešavanje okolnog puta otvaranja pokriveno je u vodiču kroz performanse velikog radnog sveščića u Delphiju

Read gate, faza paralelnog otvaranja i streaming pristup unosima opisani ovdje isporučuju se kao dio standardne HotXLS Excel komponente za Delphi i C++Builder, s potpunim izvornim kodom; stranica proizvoda nosi potpunu referencu TXLSXWorkbook uključujući svojstva paralelnog otvaranja