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