Műszaki cikk

Egyidejű ZIP kitömörítés Delphiben: HotXLS olvasási kapu

A HotXLS, a natív Excel komponenskönyvtár Delphihez és C++Builderhez, egyszerre több XLSX munkalapot tömörít ki egyetlen nyitott ZIP csomagból. A mechanizmus a TZipReadGate, egy kis osztály az lxZipArchive.pas-ban, amely hordozza a csomagfolyamot plusz egy kritikus szekciót, és pontosan egy metódust tesz közzé. Ez sorosítja a seek-és-read párt. Minden, ami e páros fölött van, egyidejűleg fut

A probléma, amely ezt a tervezést kikényszerítette, olyan, amellyel minden Delphi fejlesztő találkozott már, aki nagy munkafüzetet nyitott meg. Egy 80 MB-os xlsx 80 MB deflate-elt XML, és a benne lévő munkalaprészek nagyjából ötszörösükre-tízszeresükre bővülnek. Ha a megnyitási útvonalunk minden munkalapot memóriafolyamba nyer ki elemzés előtt, akkor a kitömörített bájtokért fizetünk az éppen épülő munkafüzet tetejére, és a csúcs azelőtt érkezik, hogy egyetlen cella is létrejött volna. Ez a cikk arról a csomagszintű egyidejűségről szól, amely eltávolítja ezt a köztes lépést. Az efölött lévő allokátorfelső korlátot a párhuzamos XLSX elemzésről és a memóriakezelőről szóló cikk tárgyalja, az egyszer-olvasás, sosem-materializálás API-t pedig a streamelő direct reader bemutatója

Miért tárolta a régi megnyitási útvonal minden munkalapot RAM-ban

Az eredeti párhuzamos megnyitás a HotXLS-ben egy háromfázisú pipeline volt, és a középső fázis volt az egyetlen, amely workereken futott. Az A fázis sorosan bejárta a laplistát, létrehozta minden munkalapot, beolvasta a kapcsolatrészét, és a teljes kitömörített munkalap-XML-t egy privát TMemoryStream-be másolta. A B fázis a ParseWorksheetXml-t szórta szét a poolon. A C fázis visszatért az archívumhoz a hívó szálon a kis kísérőrészekért: megjegyzések, szálas megjegyzések, rajzok, diagramok, táblák. Ezt az alakot egy kimondott okból választották. Az lxParallelParse.pas fejlécmegjegyzése korábban szó szerint azt mondta, hogy a zip archívum és a kitömörítési állapota nem szálbiztos, a belső jegyzetek pedig tovább mentek: ne bajlódjunk az archívum zárolásával, mert amint a kitömörítési állapotgép bejegyzésenként sorosítva van, a zárolás semmit nem ér. Az A fázis azért létezett, hogy minden archívumérintést egy szálon tartson. Az ára az volt, hogy egy nyolc aktív lapos munkafüzet nyolc teljesen kitömörített munkalap-XML puffert tartott a memóriában egyszerre, és ezek a pufferek a legnagyobb átmeneti objektumok a teljes megnyitási útvonalon

Ki tud tömöríteni két szál egyetlen ZIP folyamból?

Igen, és a régi ítélet egy konkrét, azonosítható módon volt hibás: két különböző állapotdarabot egyetlen mondatba sűrített. A kitömörítési állapot valóban nem megosztható. Egy zlib z_stream hordozza a csúszóablakot, a Huffman-táblákat és a bitpozíciót egyetlen tömörített taghoz, és két szál, amely bájtokat tol ugyanazon keresztül, szemetet termel. A mögöttes bájtforrás egészen más kérdés, és ott a válasz az, hogy egy fájlfolyamnak pontosan egyetlen módosítható, védelemre méltó megosztott állapota van, a pozícióban lévő kurzora

A ZIP konténer legálissá teszi ezt a szétválasztást. Egy ZIP archívum minden tagja függetlenül van tömörítve: saját lokális fájlfejléce, saját deflate bitfolyama a saját DataOffset-jénél, saját CRC32-je és méretei a központi könyvtárban. Nincs egy tagokon átívelő megosztott szótár, ahogy egy tömör 7z blokknál van, így az N bejegyzés kitömöríthető az M érintése nélkül. Adjunk minden workernek saját z_stream-et a saját bájttartománya fölött, és az egyetlen dolog, amin ütköznek, a seek. Ezt az ütközést szünteti meg a TZipReadGate, és a teljes osztály elég rövid ahhoz, hogy egy képernyőn elolvassuk

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 véd a TZipReadGate, és mit nem szándékosan

A TZipReadGate.ReadAt egyetlen oszthatatlan műveletet őriz: a megosztott folyam pozicionálását és az abból való olvasást, semmi mást. A TZipArchive.OpenArchive felépíti a kaput az FInputStream fölött, amint a központi könyvtár sikeresen elemzésre került, a TZipArchive.Close pedig felszabadítja. Az íráshoz megnyitott archívumok soha nem kapnak egyet. Minden olvasás, amelyet egy worker végez a csomagon, ezért egyetlen kritikus szekcióba torkollik, amelyet egyetlen pufferelt olvasás időtartamára tartunk fenn

Minden más a zároláson kívül marad, mert az már privát vagy már megváltoztathatatlan. A TZipSubStream a saját FPosition-jét tartja, így minden worker a saját helyét követi a saját bejegyzésében. A TZLibStream, amelyet a TZipEntry.GetStream épít e alfolyam fölött, bejegyzésenkénti, -15-ös windowBits-szal létrehozva a nyers deflate-hez, és soha nem osztott. A központi könyvtár teljesen elemzésre kerül, mielőtt bármelyik worker elindulna, beleértve minden lokális fejlécet, így a GetEntryByName egy csak olvasható hash-keresés, mire az egyidejűség elkezdődik. Maga az útválasztás három sor a TZipSubStream.Read-ben, a kapu nélküli ág pedig az, ami minden meglévő egyszálas hívót a régi kódútvonalon tart

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;

Mennyibe kerül a kapu terhelés alatt?

Kevesebbe, mint amit a "globális zárolás az archívumon" kifejezés sugallna, a TZLibStream által véletlenül használt szemcsézettség miatt. A bemeneti puffere BufferSize, amelyet $4000-ként definiálnak, így a ReadInputBuffer 16 KB tömörített bájtot húz be újratöltésenként, és átadja a zng_inflate-nek. Egy zárolásmegszerzés tehát 16 KB deflate-bemenetet fed le, ami munkalap-XML esetén nagyságrendileg 100 KB jelölésre bővül, amelyet a worker aztán dekódol és elemez anélkül, hogy bármit is tartana. A zárolás egy pozicionált olvasásra van fenntartva az operációs rendszer gyorsítótára ellen; az általa kapuzott munka milliszekundumokban mérhető

A becsületes határ ott van, ahol ez az arány megfordul. A deflate helyett tárolt bejegyzések egy az egyben olvasnak keresztül a kapun, kitömörítési munka nélkül, amely elrejtené a késleltetést, így egy csupa tárolt tagból álló csomag sokkal keményebben sorosodna. Egy hideg fájl lassú médián kiszélesíti a kritikus szekciót, mert az azon belüli olvasás most egy valódi lemezátvitel, nem gyorsítótár-találat. Egy maroknyi workeren túl a kapu egyébként sem az, amibe elsőként belefutunk: a munkalap-elemzés allokáció-intenzív, és a Delphi memóriakezelő szálakon keresztül sorosítja az allokációkat jóval azelőtt, hogy az olvasási kapu korláttá válna. Ezért van az, hogy a TXLSXWorkbook.ParallelParseThreads alapértelmezésben egy automatikus felső korlátot állít be magonkénti szál helyett

A worker törzse, és a lefolyatási ciklus, amelyet könnyű elfelejteni

A kapuval a helyén a HotXLS teljesen törölte az A fázis kinyerését. A worker most saját bejegyzésfolyamát nyitja meg, és közvetlenül táplálja az elemzőnek. Két átmeneti mező hordozza a bemeneteket: az FParZip tartja az archívumot a párhuzamos fázis idejére, az FParSheetPartNames tartja a résznevek, és mindkettő törlésre kerül a finally blokkban, így semmilyen elavult mutató nem éli túl egy sikertelen megnyitást. A TZipArchive.OpenFile-tól visszaérkező folyam egy TZipVerifiedStream, amely egy TZLibStream-et burkol, amely egy TZipSubStream-et burkol, és a külső felszabadítása felszabadítja a láncot

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;

A lefolyatási ciklus az a részlet, amelyet a régi kód egyenes átültetése elhagyna, és elhagyása csendben kikapcsolja az integritásellenőrzést. A TZipVerifiedStream egy futó CRC32-t halmoz fel, ahogy a bájtok áthaladnak, és csak akkor hívja meg a VerifyComplete-et, amikor a pozíciója eléri a központi könyvtárban rögzített kitömörítetlen méretet; innen ered a méret-eltérés és a CRC32-eltérés kivétele, plusz egy egybájtos vizsgáló olvasás, amely elkapja a deklaráltnál hosszabb bejegyzést. Egy XML-olvasó megáll a záró elemnél, és általában olvasatlanul hagy egy sortörést vagy néhány bájtnyi záró üres helyet, így lefolyatás nélkül a pozíció soha nem éri el a deklarált méretet, és az ellenőrzések soha nem sülnek el. A maradék beolvasása egy ideiglenes pufferbe semmibe nem kerül, és visszaállítja őket. Amikor a köztes tárolófolyamok léteztek, az XlsxCopyStreamAll véletlenül ezt tette

Ami még mindig sorosan fut, és a jelző, amely mindezt kikapcsolja

Az A fázis megmarad, mínusz a kinyerés. Továbbra is létrehozza minden munkalapot, és beolvassa a kapcsolatait a hívó szálon, ami az, ami minden megosztott map-et megváltoztathatatlanná tesz, mire a workerek elindulnak. A C fázis továbbra is sorosan bejárja a lapokat utólag a megjegyzésekhez, rajzokhoz, diagramokhoz és táblákhoz, és az őrzője a régi tárolótömbön végzett null-ellenőrzésről a résznévvel szembeni zip.Exists-re változott. A megosztott, csak olvasható bemenetek, amelyeket a workerek érintenek, a megosztott sztringtábla és a cellXf map-ek, teljesek a B fázis kezdete előtt, és soha nem íródnak közben

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;

A ParallelParse False-ra állítása az Open előtt ugyanazt a feladat-eljárást küldi el, egyetlen szálas számmal, és a RunParallelJobs egy egyszerű ciklussá degradálódik a hívó szálon. Ezt két okból érdemes tudni: ez az egysoros válasz, ha valaha szálkezelési probléma merül fel a terepen, és azt jelenti, hogy a soros és párhuzamos útvonalak egyetlen elemzőkód-testet osztanak meg ahelyett, hogy szétválnának. A worker kivételek elkapásra kerülnek, a legalacsonyabb feladatindex nyer, a hiba pedig újradobásra kerül a hívó szálon, miután minden worker csatlakozott, így egy sérült munkalap még mindig egyetlen kivételként jelenik meg a várt helyen. A körülötte lévő megnyitási útvonal általános hangolását a nagy munkafüzet teljesítmény útmutatója Delphiben tárgyalja

Az itt leírt olvasási kapu, a párhuzamos megnyitási fázis és a streamelő bejegyzés-hozzáférés a standard HotXLS Excel component részeként érkeznek Delphihez és C++Builderhez, teljes forráskóddal; a termékoldal hordozza a teljes TXLSXWorkbook referenciát, beleértve a párhuzamos megnyitási tulajdonságokat is