Techninis straipsnis

Lygiagretus ZIP išplėtimas Delphi: HotXLS skaitymo vartai

HotXLS, natyvi Excel komponentų biblioteka Delphi ir C++Builder platformoms, vienu metu išplečia kelis XLSX darbalapius iš vieno atverto ZIP paketo. Mechanizmas — TZipReadGate, nedidelė klasė modulyje lxZipArchive.pas, kuri laiko paketo srautą ir vieną kritinę sekciją bei atveria lygiai vieną metodą. Ji suserializuoja pozicionavimo-ir-skaitymo porą. Viskas virš tos poros vyksta lygiagrečiai

Problema, privertusi šį dizainą, — tokia, su kuria susidūrė kiekvienas Delphi kūrėjas, kada nors atvėręs didelę darbaknygę. 80 MB xlsx yra 80 MB suglaudinto XML, o darbalapio dalys jo viduje išsiplečia maždaug penkis–dešimt kartų. Jei jūsų atvėrimo kelias ištraukia kiekvieną darbalapį į atminties srautą prieš jį analizuojant, mokate už išplėstus baitus virš darbaknygės, kurią kuriate, o pikas atvyksta prieš sukuriant nė vieną ląstelę. Šis straipsnis — apie paketo lygio lygiagretumą, pašalinantį šį tarpinį žingsnį. Paskirstytuvo riba virš jo aprašyta straipsnyje apie lygiagrečią XLSX analizę ir atminties valdytoją, o skaityk-vieną-kartą, niekada-nematerializuok API aprašyta straipsnyje apie srautinį tiesioginį skaitytuvą

Kodėl senasis atvėrimo kelias talpino kiekvieną darbalapį RAM atmintyje

Originalus lygiagretus atvėrimas HotXLS buvo trijų fazių vamzdynas, o vidurinė fazė buvo vienintelė, veikusi darbuotojuose. Fazė A nuosekliai ėjo per lapų sąrašą, kūrė kiekvieną darbalapį, skaitė jo ryšio dalį ir kopijavo visą išplėstą darbalapio XML į privatų TMemoryStream. Fazė B paskleisdavo ParseWorksheetXml per baseiną. Fazė C grįždavo į archyvą iškviečiančioje gijoje mažoms palydovinėms dalims: komentarams, gijų komentarams, piešiniams, diagramoms, lentelėms. Ši forma buvo pasirinkta dėl nurodytos priežasties. Antraštės komentaras lxParallelParse.pas anksčiau tiesiogiai sakydavo, kad zip archyvas ir jo išplėtimo būsena nėra saugūs giją, o vidiniai užrašai ėjo dar toliau: neverta rakinti archyvo, nes kai tik išplėtimo būsenos mašina suserializuota kiekvienam įrašui, spyna nieko nenupirkta. Fazė A egzistavo tam, kad kiekvieną archyvo prisilietimą laikytų vienoje gijoje. Kaina buvo ta, kad darbaknygė su aštuoniais užimtais lapais vienu metu laikė aštuonis visiškai išplėstus darbalapio XML buferius atmintyje, ir tie buferiai — didžiausi laikini objektai visame atvėrimo kelyje

Ar dvi gijos gali išplėsti iš vieno ZIP srauto?

Taip, ir senasis sprendimas buvo neteisingas konkrečiu, aptinkamu būdu: jis sulydė du skirtingus būsenos elementus į vieną sakinį. Išplėtimo būsena iš tikrųjų nedalinamasi. zlib z_stream neša slystantį langą, Hafmano lenteles ir bitų poziciją vienam suglaudintam nariui, o dvi gijos, stumiančios baitus per tą pačią, gauna šiukšles. Pagrindinis baitų šaltinis — visai kitas klausimas, ir atsakymas ten toks, kad failo srautas turi lygiai vieną kintamą bendrą būsenos elementą, vertą apsaugoti — jo pozicijos žymeklį

ZIP konteineris atskyrimą padaro teisėtą. Kiekvienas narys ZIP archyve suglaudintas nepriklausomai: savo vietinė failo antraštė, savo deflate bitų srautas savo DataOffset, savo CRC32 ir dydžiai centriniame kataloge. Nėra jokio bendro žodyno, apimančio narius, taip, kaip solidus 7z blokas, todėl įrašas N gali būti išplėstas, neliečiant įrašo M. Kiekvienam darbuotojui duokite savo z_stream per savo baitų intervalą, ir vienintelis dalykas, dėl kurio jie susikerta — pozicionavimas. Ta kolizija — tai, ką pašalina TZipReadGate, ir visa klasė pakankamai trumpa, kad ją būtų galima perskaityti vienoje ekrano dalyje

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;

Ką apsaugo TZipReadGate, ir ko jis sąmoningai neapsaugo

TZipReadGate.ReadAt apsaugo vieną nedalomą operaciją, bendro srauto pozicionavimą ir skaitymą iš jo, ir nieko daugiau. TZipArchive.OpenArchive sukonstruoja vartus virš FInputStream, kai centrinis katalogas sėkmingai išanalizuotas, o TZipArchive.Close juos paleidžia. Archyvai, atverti rašymui, jų niekada negauna. Kiekvienas skaitymas, kurį darbuotojas atlieka pakete, todėl praeina per vieną kritinę sekciją, laikomą vieno buferizuoto skaitymo trukmei

Viskas kita lieka už spynos ribų, nes tai jau privatu arba jau nekintama. TZipSubStream laiko savo pačios FPosition, todėl kiekvienas darbuotojas seka savo vietą savo pačios įraše. TZLibStream, kurį TZipEntry.GetStream sukuria virš to poįrašio srauto, yra vieno įrašo, sukurtas su windowBits lygiu -15 žaliam deflate, ir niekada nesidalinamas. Centrinis katalogas pilnai išanalizuotas prieš pradedant bet kuriam darbuotojui, įskaitant kiekvieną vietinę antraštę, todėl GetEntryByName yra tik-skaityti maišos paieška, kai prasideda lygiagretumas. Pats maršrutizavimas — trys eilutės TZipSubStream.Read, o šaka be vartų kaip tik ir laiko kiekvieną esamą vienos gijos iškviečiantįjį senajame kodo kelyje

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;

Kiek kainuoja vartai esant konkurencijai?

Mažiau, nei rodytų frazė „globali spyna ant archyvo“, dėl granuliuotumo, kurią atsitiktinai naudoja TZLibStream. Jos įvesties buferis — BufferSize, apibrėžtas kaip $4000, todėl ReadInputBuffer ištraukia 16 KB suglaudintų baitų kiekvienam papildymui ir perduoda juos zng_inflate. Vienas spynos gavimas todėl apima 16 KB deflate įvesties, kuri darbalapio XML atveju išsiplečia į kažką maždaug 100 KB žymėjimo, kurį darbuotojas tada dekoduoja ir analizuoja, nieko nelaikydamas. Spyna laikoma pozicionuotam skaitymui prieš operacinės sistemos podėlį; darbas, kurį ji riboja, matuojamas milisekundėmis

Sąžininga riba — ten, kur šis santykis apsiverčia. Įrašai, saugomi be glaudinimo, o ne suglaudinti, skaitomi per vartus vienas su vienu, be jokio išplėtimo darbo, kuris paslėptų delsą, todėl paketas, pilnas saugomų narių, serializuotųsi kur kas sunkiau. Šaltas failas lėtoje laikmenoje išplečia kritinę sekciją, nes skaitymas jos viduje dabar — tikras disko perdavimas, ne podėlio pataikymas. O virš saujelės darbuotojų vartai — ne tai, ką pirmiausia pasieksite: darbalapio analizė gausi paskirstymui, o Delphi atminties valdytojas suserializuoja paskirstymus per gijas gerokai anksčiau, nei skaitymo vartai taps riba. Kaip tik dėl to TXLSXWorkbook.ParallelParseThreads pagal nutylėjimą turi automatinę ribą, o ne po vieną giją kiekvienam branduoliui

Darbuotojo kūnas, ir nusausinimo ciklas, kurį lengva pamiršti

Su vartais vietoje, HotXLS visiškai ištrynė A fazės talpinimą. Darbuotojas dabar atveria savo pačios įrašo srautą ir tiesiogiai jį perduoda analizatoriui. Du laikini laukai neša įvestis: FParZip laiko archyvą lygiagretaus etapo trukmei, FParSheetPartNames laiko dalies pavadinimus, ir abu išvalomi finally bloke, todėl joks pasenęs rodyklė nepergyvena nepavykusio atvėrimo. Srautas, grįžtantis iš TZipArchive.OpenFile, yra TZipVerifiedStream, apgaubiantis TZLibStream, apgaubiantį TZipSubStream, o išorinio paleidimas paleidžia visą grandinę

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;

Nusausinimo ciklas — detalė, kurią tiesioginis senojo kodo perkėlimas nuleistų, o jo nuleidimas tyliai išjungia vientisumo tikrinimą. TZipVerifiedStream kaupia bėgantį CRC32, kai baitai praeina, ir iškviečia VerifyComplete tik tada, kai jos pozicija pasiekia nesuglaudintą dydį, užregistruotą centriniame kataloge; kaip tik iš to atsiranda dydžio nesutapimo ir CRC32 nesutapimo išimtys, plius vieno baito zondavimo skaitymas, gaunantis įrašą, ilgesnį nei deklaruota. XML skaitytuvas sustoja ties uždarančiu elementu ir dažniausiai palieka naują eilutę ar kelis baitus nepaskaitytų tarpo simbolių, todėl be nusausinimo pozicija niekada nepasiekia deklaruoto dydžio, ir patikrinimai niekada nesuveikia. Likučio skaitymas į juodraštinį buferį nieko nekainuoja ir juos atkuria. Kai talpinimo srautai egzistavo, XlsxCopyStreamAll tai darė atsitiktinai

Kas vis dar veikia nuosekliai, ir vėliavėlė, viską išjungianti

Fazė A išgyvena, minus ištraukimą. Ji vis dar kuria kiekvieną darbalapį ir skaito jo ryšius iškviečiančioje gijoje, o tai palieka kiekvieną bendrą žemėlapį nekintamą, kai prasideda darbuotojai. Fazė C vis dar nuosekliai eina per lapus po to komentarams, piešiniams, diagramoms ir lentelėms, o jos apsauga pasikeitė nuo nulinio patikrinimo senajame talpinimo masyve į zip.Exists pagal dalies pavadinimą. Bendros tik-skaityti įvestys, kurias liečia darbuotojai, bendra eilučių lentelė ir cellXf žemėlapiai, yra pilnos prieš prasidedant B fazei ir niekada nerašomos jos metu

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 nustatymas į False prieš Open paskirsto tą pačią darbo procedūrą su vienos gijos skaičiumi, o RunParallelJobs išsigimsta į paprastą ciklą iškviečiančioje gijoje. Tai verta žinoti dėl dviejų priežasčių: tai — vienos eilutės atsakymas, jei giją susijęs rūpestis kada nors iškiltų lauke, ir tai reiškia, kad nuoseklus ir lygiagretus keliai dalijasi vienu analizės kodo kūnu, o ne skiriasi. Darbuotojo išimtys fiksuojamos, mažiausias darbo indeksas laimi, o klaida iš naujo iškeliama iškviečiančioje gijoje po to, kai kiekvienas darbuotojas prisijungia, todėl sugadintas darbalapis vis tiek pasirodo kaip viena išimtis laukiamoje vietoje. Bendras aplinkinio atvėrimo kelio derinimas aprašytas straipsnyje apie didelės darbaknygės našumą Delphi

Skaitymo vartai, lygiagretaus atvėrimo etapas ir srautinis įrašo pasiekiamumas, aprašyti čia, dalyvauja standartiniame HotXLS Excel komponente Delphi ir C++Builder platformoms, su pilnu šaltiniu; produkto puslapyje pateikta pilna TXLSXWorkbook dokumentacija, įskaitant lygiagretaus atvėrimo savybes