Tehnični članak

Sočasno ZIP dekompresiranje v Delphiju: HotXLS bralna vrata

HotXLS, izvorna knjižnica komponent Excel za Delphi in C++Builder, hkrati dekompresira več delovnih listov XLSX iz enega odprtega paketa ZIP. Mehanizem je TZipReadGate, majhen razred v lxZipArchive.pas, ki hrani tok paketa plus en kritični odsek in izpostavi natanko eno metodo. Serializira par iskanje-branje. Vse nad tem parom teče sočasno

Problem, ki je vsilil to zasnovo, je nekaj, s čimer se je srečal vsak Delphi razvijalec, ki je odprl velik delovni zvezek. 80 MB xlsx je 80 MB deflated XML, deli delovnih listov znotraj njega pa se razširijo približno pet do desetkrat. Če vaša pot odpiranja izvleče vsak delovni list v pomnilniški tok, preden ga razčleni, plačate za napihnjene bajte poleg delovnega zvezka, ki ga gradite, vrh pa pride, preden je bila ustvarjena ena sama celica. Ta članek se osredotoča na sočasnost na ravni paketa, ki to stopnjo postavljanja odpravi. Zgornjo mejo alokatorja, ki sedi nad njo, pokriva članek o vzporednem razčlenjevanju XLSX in upravitelju pomnilnika, API branja enkrat, brez materializacije, pa pokriva vodič po pretakajočem neposrednem bralniku

Zakaj je stara pot odpiranja postavljala vsak delovni list v RAM

Prvotno vzporedno odpiranje v HotXLS je bil tridelen cevovod, srednja faza pa je bila edina, ki je tekla na delavcih. Faza A je serijsko sprehodila seznam listov, ustvarila vsak delovni list, prebrala njegov del odnosov in kopirala celoten napihnjen XML delovnega lista v zaseben TMemoryStream. Faza B je razpošiljala ParseWorksheetXml čez bazen. Faza C se je vrnila v arhiv na klicni niti za majhne satelitske dele: komentarje, verižene komentarje, risbe, grafikone, tabele. Ta oblika je bila izbrana iz navedenega razloga. Glavni komentar na lxParallelParse.pas je nekoč povedal, z več besedami, da arhiv zip in njegovo stanje dekompresije nista varna za niti, notranje opombe pa so šle dlje: ne trudite se zaklepati arhiva, ker ko je stanje dekompresije serializirano na vnos, ključavnica ne prinese ničesar. Faza A je obstajala, da bi vsak dotik arhiva ostal na eni niti. Cena je bila, da je delovni zvezek z osmimi zaposlenimi listi hkrati hranil osem popolnoma napihnjenih medpomnilnikov XML delovnih listov v pomnilniku, ti medpomnilniki pa so največji prehodni objekti v celotni poti odpiranja

Ali lahko dve niti dekompresirata iz enega toka ZIP?

Da, stara presoja pa je bila napačna na specifičen, izsledljiv način: zložila je dva različna dela stanja v en stavek. Stanje dekompresije resnično ni deljivo. Zlib z_stream nosi drseče okno, Huffmanove tabele in bitni položaj za enega stisnjenega člana, dve niti, ki potiskata bajte skozi isto stvar, pa proizvedeta smeti. Osnovni vir bajtov je povsem drugo vprašanje, odgovor tam pa je, da ima tok datoteke natanko en kos spremenljivega deljenega stanja, vreden zaščite, njegov kazalec položaja

Vsebnik ZIP naredi delitev zakonito. Vsak član v arhivu ZIP je stisnjen neodvisno: svoja lastna lokalna glava datoteke, svoj lasten bitni tok deflate na svojem lastnem DataOffset, svoj lasten CRC32 in velikosti v osrednjem imeniku. Ni deljenega slovarja, ki bi se razprostiral čez člane, kot ima to trden blok 7z, zato je mogoče vnos N dekompresirati, ne da bi se dotaknili vnosa M. Dajte vsakemu delavcu svoj lasten z_stream nad svojim lastnim razponom bajtov, edino, za kar se sprejo, pa je iskanje. To trčenje odstrani TZipReadGate, celoten razred pa je dovolj kratek za branje na enem zaslonu

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;

Kaj TZipReadGate ščiti in česa namerno ne

TZipReadGate.ReadAt varuje eno nedeljivo operacijo, postavitev deljenega toka na položaj in branje iz njega, nič drugega. TZipArchive.OpenArchive zgradi vrata nad FInputStream, ko se osrednji imenik uspešno razčleni, TZipArchive.Close pa jih sprosti. Arhivi, odprti za pisanje, jih nikoli ne dobijo. Vsako branje, ki ga delavec izvede na paketu, se zato izlije skozi eno samo kritično sekcijo, zadržano za trajanje enega medpomnjenega branja

Vse ostalo ostaja zunaj ključavnice, ker je že zasebno ali že nespremenljivo. TZipSubStream hrani svoj lasten FPosition, tako da vsak delavec sledi svojemu mestu v svojem lastnem vnosu. TZLibStream, ki ga TZipEntry.GetStream zgradi nad tem podtokom, je na vnos, ustvarjen z windowBits -15 za surov deflate, in se nikoli ne deli. Osrednji imenik je v celoti razčlenjen, preden začne kateri koli delavec, vključno z vsako lokalno glavo, tako da je GetEntryByName do začetka sočasnosti le za branje razpršeno iskanje. Samo usmerjanje so tri vrstice v TZipSubStream.Read, veja brez vrat pa je tisto, kar drži vsak obstoječi enoniten klicatelj na stari poti kode

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 stanejo vrata pri sporu?

Manj, kot nakazuje fraza "globalna ključavnica na arhivu", zaradi granularnosti, ki jo TZLibStream naključno uporablja. Njegov vhodni medpomnilnik je BufferSize, opredeljen kot $4000, tako da ReadInputBuffer na osvežitev povleče 16 KB stisnjenih bajtov in jih preda zng_inflate. Ena pridobitev ključavnice tako pokrije 16 KB vhoda deflate, kar se za XML delovnega lista razširi v nekaj reda 100 KB oznak, ki jih delavec nato dekodira in razčleni, ne da bi kar koli držal. Ključavnica je zadržana za pozicionirano branje proti predpomnilniku operacijskega sistema; delo, ki ga varuje, se meri v milisekundah

Iskrena meja je tam, kjer se to razmerje obrne. Vnosi, shranjeni namesto stisnjeni, berejo skozi vrata ena proti ena brez dela dekompresije, ki bi skril zakasnitev, tako da bi se paket, poln shranjenih članov, veliko trše serializiral. Hladna datoteka na počasnem mediju razširi kritično sekcijo, ker je branje znotraj nje zdaj pravi prenos diska, ne zadetek predpomnilnika. In mimo peščice delavcev vrata sploh niso tisto, kar zadenete najprej: razčlenjevanje delovnega lista je intenzivno z alokacijami, upravitelj pomnilnika Delphi pa serializira alokacije čez niti dolgo preden bralna vrata postanejo omejitev. Zato je TXLSXWorkbook.ParallelParseThreads privzeto samodejna zgornja meja namesto ene niti na jedro

Telo delavca in zanka izpraznitve, ki jo je lahko pozabiti

Z vrati na mestu je HotXLS povsem izbrisal postavljanje faze A. Delavec zdaj odpre svoj lasten tok vnosa in ga poda naravnost razčlenjevalniku. Dve prehodni polji nosita vhode: FParZip hrani arhiv za trajanje vzporedne faze, FParSheetPartNames hrani imena delov, oba pa se počistita v bloku finally, tako da noben zastarel kazalec ne preživi neuspelega odpiranja. Tok, ki se vrne iz TZipArchive.OpenFile, je TZipVerifiedStream, ki ovija TZLibStream, ki ovija TZipSubStream, sprostitev zunanjega pa sprosti verigo

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;

Zanka izpraznitve je podrobnost, ki bi jo neposreden prenos stare kode izpustil, izpustitev pa tiho onemogoči preverjanje celovitosti. TZipVerifiedStream nabira tekoč CRC32, medtem ko bajti tečejo skozi, in pokliče VerifyComplete le, ko njegov položaj doseže nestisnjeno velikost, zabeleženo v osrednjem imeniku; od tam prihajata izjemi za neujemanje velikosti in neujemanje CRC32, plus branje ene sonde po enem bajtu, ki ujame vnos, daljši od izjavljenega. Bralnik XML se ustavi pri zaključnem elementu in običajno pusti neprebran nov znak vrstice ali nekaj bajtov sledečega presledka, tako da brez izpraznitve položaj nikoli ne doseže izjavljene velikosti in se preverjanja nikoli ne sprožijo. Branje preostanka v pomožen medpomnilnik ne stane ničesar in jih obnovi. Ko so obstajali postavitveni tokovi, je to po naključju počel XlsxCopyStreamAll

Kaj še vedno teče serijsko, in zastavica, ki vse to izklopi

Faza A preživi, minus ekstrakcija. Še vedno ustvari vsak delovni list in prebere njegove odnose na klicni niti, kar je tisto, kar pusti vsako deljeno preslikavo nespremenljivo, ko delavci začnejo. Faza C še vedno serijsko sprehodi liste kasneje za komentarje, risbe, grafikone in tabele, njena varovalka pa se je spremenila iz ničelnega preverjanja na starem postavitvenem polju v zip.Exists proti imenu dela. Deljeni vhodi le za branje, ki se jih delavci dotikajo, deljena tabela nizov in preslikave cellXf, so dokončani, preden se faza B začne, in se med njo nikoli ne pišejo

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;

Nastavitev ParallelParse na False pred Open razpošlje isti postopek opravila s številom niti ena, RunParallelJobs pa se degenerira v navadno zanko na klicni niti. To je vredno vedeti iz dveh razlogov: je enovrstičen odgovor, če se v praksi kdaj pojavi skrb glede niti, in pomeni, da si serijska in vzporedna pot delita eno samo telo kode razčlenjevanja namesto da bi se razšli. Izjeme delavcev so zajete, zmaga najnižji indeks opravila, napaka pa se ponovno sproži na klicni niti, potem ko se vsak delavec pridruži, tako da se pokvarjen delovni list še vedno pokaže kot ena izjema na pričakovanem mestu. Splošno nastavljanje okoliške poti odpiranja pokriva vodič po zmogljivosti velikih delovnih zvezkov v Delphiju

Bralna vrata, faza vzporednega odpiranja in dostop do pretakajočih vnosov, opisani tukaj, so del standardne komponente HotXLS Excel za Delphi in C++Builder, s celotnim izvorno kodo; stran izdelka nosi celoten referenčni pregled TXLSXWorkbook, vključno z lastnostmi vzporednega odpiranja