HotXLS, nativna biblioteka Excel komponenti za Delphi i C++Builder, napumpava nekoliko XLSX radnih listova istovremeno iz jednog otvorenog ZIP paketa. Mehanizam je TZipReadGate, mala klasa u lxZipArchive.pas koja drži tok paketa plus jednu kritičnu sekciju i izlaže tačno jednu metodu. Serijalizuje par seek-i-read. Sve iznad tog para radi konkurentno
Problem koji je iznudio ovaj dizajn je onaj koji je svaki Delphi programer koji je otvorio veliku radnu svesku sreo. Xlsx od 80 MB je 80 MB deflate-ovanog XML-a, a delovi radnog lista unutar njega se šire otprilike pet do deset puta. Ako vaša putanja otvaranja izvlači svaki radni list u tok u memoriji pre parsiranja, plaćate za napumpane bajtove pored radne sveske koju gradite, a pik stiže pre nego što je ijedna ćelija napravljena. Ovaj članak se bavi konkurentnošću na nivou paketa koja uklanja taj korak pripreme. Tavanica alokatora koja sedi iznad toga pokrivena je u članku o paralelnom XLSX parsiranju i memory manager-u, a API čitaj-jednom-nikad-ne-materijalizuj pokriven je u pregledu streaming direktnog čitača
Zašto je stara putanja otvaranja pripremala svaki radni list u RAM-u
Originalno paralelno otvaranje u HotXLS bilo je pipeline od tri faze, i srednja faza je bila jedina koja je radila na worker-ima. Faza A je serijski obilazila listu listova, kreirala svaki radni list, čitala njegov deo relacija, i kopirala ceo napumpan XML radnog lista u privatan TMemoryStream. Faza B je razgranala ParseWorksheetXml preko pool-a. Faza C se vraćala u arhivu na pozivajućoj niti za male satelitske delove: komentare, threaded komentare, crteže, grafikone, tabele. Taj oblik je izabran iz navedenog razloga. Komentar u zaglavlju na lxParallelParse.pas je nekada rečima govorio da zip arhiva i njeno inflate stanje nisu thread-safe, a interne beleške su išle dalje: nema smisla zaključavati arhivu, jer čim se stanje inflate-a serijalizuje po unosu, brava ne kupuje ništa. Faza A je postojala da drži svaki dodir arhive na jednoj niti. Cena je bila da je radna sveska sa osam zauzetih listova držala osam potpuno napumpanih bafera XML-a radnog lista u memoriji istovremeno, a ti baferi su najveći prolazni objekti u čitavoj putanji otvaranja
Mogu li dve niti da napumpaju iz jednog ZIP toka?
Mogu, i stara procena je bila pogrešna na specifičan, lociv način: sabila je dva različita komada stanja u jednu rečenicu. Stanje inflate-a zaista nije deljivo. zlib z_stream nosi klizeći prozor, Huffman tabele i bitsku poziciju za jedan komprimovan član, a dve niti koje guraju bajtove kroz isti proizvode smeće. Osnovni izvor bajtova je potpuno drugačije pitanje, a odgovor tamo je da tok fajla ima tačno jedan komad promenljivog deljenog stanja vrednog zaštite, svoj kursor pozicije
ZIP kontejner čini razdvajanje legalnim. Svaki član u ZIP arhivi je komprimovan nezavisno: sopstveno lokalno zaglavlje fajla, sopstveni deflate bit tok na sopstvenom DataOffset, sopstveni CRC32 i veličine u central directory-u. Nema deljenog rečnika koji se prostire preko članova onako kako to ima blok solid 7z, tako da se unos N može napumpati bez dodirivanja unosa M. Dajte svakom worker-u sopstveni z_stream preko sopstvenog opsega bajtova i jedino na čemu se sudaraju je seek. Taj sudar je ono što TZipReadGate uklanja, a cela klasa je dovoljno kratka da se pročita 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;
Šta TZipReadGate štiti, a šta namerno ne štiti
TZipReadGate.ReadAt čuva jednu nedeljivu operaciju, pozicioniranje deljenog toka i čitanje iz njega, i ništa drugo. TZipArchive.OpenArchive konstruiše kapiju nad FInputStream čim se central directory uspešno parsira, a TZipArchive.Close je oslobađa. Arhive otvorene za pisanje je nikad ne dobijaju. Svako čitanje koje worker izvrši na paketu zato se sliva kroz jednu kritičnu sekciju koja se drži tokom jednog baferovanog čitanja
Sve ostalo ostaje van brave jer je već privatno ili već nepromenljivo. TZipSubStream drži sopstveni FPosition, tako da svaki worker prati sopstveno mesto u sopstvenom unosu. TZLibStream koji TZipEntry.GetStream gradi nad tim sub-tokom je po unosu, napravljen sa windowBits od -15 za sirovi deflate, i nikad deljen. Central directory je u potpunosti parsiran pre nego što ijedan worker počne, uključujući svako lokalno zaglavlje, tako da je GetEntryByName pretraga hash-a samo za čitanje do trenutka kad konkurentnost počne. Samo usmeravanje su tri reda u TZipSubStream.Read, a grana bez kapije je ono što drži svakog postojećeg pozivaoca sa jednom niti na staroj putanji 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 košta kapija pod konkurencijom?
Manje nego što fraza "globalna brava na arhivi" sugeriše, zbog granularnosti koju TZLibStream slučajno koristi. Njegov ulazni bafer je BufferSize, definisan kao $4000, tako da ReadInputBuffer povlači 16 KB komprimovanih bajtova po dopuni i predaje ih zng_inflate. Jedno preuzimanje brave zato pokriva 16 KB deflate ulaza, što se za XML radnog lista širi u nešto reda veličine 100 KB markup-a koji worker zatim dekoduje i parsira bez ičega da drži. Brava se drži za pozicionirano čitanje naspram keša operativnog sistema; posao koji ograničava meri se u milisekundama
Iskrena granica je gde se taj odnos okreće. Unosi skladišteni umesto deflate-ovani čitaju kroz kapiju jedan-na-jedan bez posla inflate-a da sakriju latenciju, tako da bi paket pun skladištenih članova serijalizovao mnogo teže. Hladan fajl na sporom medijumu širi kritičnu sekciju, jer je čitanje unutar nje sada pravi transfer diska, a ne pogodak keša. I posle šačice worker-a kapija svakako nije prvo na šta udarite: parsiranje radnog lista je teško alokacijama, a Delphi memory manager serijalizuje alokacije kroz niti mnogo pre nego što read gate postane ograničenje. Zato je TXLSXWorkbook.ParallelParseThreads podrazumevano automatska tavanica umesto jedne niti po jezgru
Telo worker-a, i petlja pražnjenja koju je lako zaboraviti
Sa kapijom na mestu, HotXLS je u potpunosti obrisao pripremu Faze A. Worker sada otvara sopstveni tok unosa i hrani ga direktno parseru. Dva prolazna polja nose ulaze: FParZip drži arhivu za trajanje paralelne faze, FParSheetPartNames drži imena delova, i oba se čiste u finally bloku tako da nijedan zastareo pokazivač ne preživi neuspelo otvaranje. Tok koji se vraća iz TZipArchive.OpenFile je TZipVerifiedStream koji obavija TZLibStream koji obavija TZipSubStream, a oslobađanje spoljašnjeg 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 pražnjenja je detalj koji bi direktan port starog koda ispustio, a ispuštanje ga tiho isključuje proveru integriteta. TZipVerifiedStream akumulira tekući CRC32 dok bajtovi prolaze i poziva VerifyComplete samo kad njena pozicija dosegne nekomprimovanu veličinu zabeleženu u central directory-u; tu dolaze izuzeci neslaganja veličine i CRC32, plus čitanje probe od jednog bajta koje hvata unos duži od deklarisanog. XML čitač se zaustavlja na zatvarajućem elementu i obično ostavlja novi red ili nekoliko bajtova pratećeg razmaka nepročitanim, tako da bez pražnjenja pozicija nikad ne dosegne deklarisanu veličinu i provere se nikad ne aktiviraju. Čitanje ostatka u privremeni bafer ne košta ništa i vraća ih. Kad su postojali baferi pripreme, XlsxCopyStreamAll je ovo radio slučajno
Šta i dalje radi serijski, i zastavica koja sve to isključuje
Faza A preživljava, minus ekstrakcija. I dalje kreira svaki radni list i čita njegove relacije na pozivajućoj niti, što je ono što ostavlja svaku deljenu mapu nepromenljivom čim worker-i počnu. Faza C i dalje serijski obilazi listove posle toga za komentare, crteže, grafikone i tabele, a njena straža se promenila iz null provere na starom nizu pripreme u zip.Exists naspram imena dela. Deljeni ulazi samo za čitanje koje worker-i dodiruju, deljena tabela stringova i mape cellXf, su kompletni pre nego što Faza B počne i nikad se ne pišu tokom 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 pre Open šalje istu proceduru posla sa brojem niti jedan, i RunParallelJobs degeneriše u običnu petlju na pozivajućoj niti. Vredi to znati iz dva razloga: to je jednoredni odgovor ako se ikad na terenu pojavi briga oko threading-a, i znači da serijska i paralelna putanja dele jedno telo koda parsiranja umesto da se razilaze. Izuzeci worker-a se hvataju, pobeđuje najniži indeks posla, a greška se ponovo izaziva na pozivajućoj niti pošto se svaki worker pridruži, tako da se pokvaren radni list i dalje pojavljuje kao jedan izuzetak na očekivanom mestu. Opšte podešavanje okolne putanje otvaranja pokriveno je u vodiču za performanse velikih radnih svezaka u Delphi
Read gate, paralelna faza otvaranja i streaming pristup unosima opisani ovde isporučuju se kao deo standardne HotXLS Excel komponente za Delphi i C++Builder, sa punim izvornim kodom; stranica proizvoda nosi kompletnu TXLSXWorkbook referencu uključujući svojstva paralelnog otvaranja