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