HotXLS poate face să se prăbușească un thread worker Delphi fără nicio excepție care poate fi prinsă atunci când calculează suma de control a unei părți XML mari de foaie de calcul într-un singur apel: zlib-ng trece la algoritmul său Chorba peste aproximativ 119 KB de date de intrare, iar varianta C generică a acelui algoritm alocă un tablou de lucru suficient de mare încât să depășească stiva implicită de 1 MB a thread-ului. Delphi nu are nicio șansă să reacționeze, pentru că o depășire de stivă nu este genul de excepție pentru care a fost construit try/except
HotXLS este o bibliotecă nativă Delphi și C++Builder pentru citirea și scrierea registrelor de lucru Excel, iar accidentul a fost urmărit înapoi la scriitorul său de foi de calcul. Primul semn de probleme a fost un tichet de suport: un job de export peste noapte se prăbușea aproximativ de două ori pe săptămână, întotdeauna la mijlocul rulării, fără niciun dialog de excepție Delphi și fără nicio eroare înregistrată, doar un proces care dispărea și o intrare Windows Error Reporting care nu indica nimic util. Reproducerea la birou a fost cu totul altă poveste. Registrele de lucru mici se salvau bine. Registrele de lucru mari se salvau de asemenea bine, atâta timp cât salvarea rula pe thread-ul principal cu un debugger deja atașat. A fost nevoie de un lot real de fișiere de dimensiune de producție care rulau prin calea reală de export multi-thread pentru a aduce accidentul acasă, moment în care I/O-ul de disc, presiunea de memorie și un șablon suspect fuseseră deja fiecare excluse
Cum se transformă o salvare de foaie de calcul într-un singur apel CRC32 gigantic
Fișierele XLSX sunt containere ZIP, iar formatul ZIP cere o sumă de control CRC-32 pentru fiecare intrare, înregistrată atât în antetul local al fișierului, cât și în directorul central. HotXLS calculează acea sumă de control apelând un mic wrapper numit ZLibCRC32, care la rândul său apelează propria rutină crc32 a zlib-ng odată ce SaveAs a terminat de asamblat XML-ul unei foi de calcul în memorie, iar timp îndelungat acel apel a purtat întregul buffer necomprimat într-o singură invocare. Acesta este un design rezonabil pentru o foaie de calcul mică. Devine un apel foarte mare în momentul în care o foaie este genul acoperit în ghidul nostru despre performanța registrelor de lucru mari în HotXLS, unde XML-ul unei singure foi trece de obicei de câteva sute de kilobytes înainte de a fi vreodată comprimat
De ce are nevoie zlib-ng de un buffer de stivă gigantic pentru CRC32?
zlib-ng nu folosește o singură implementare CRC-32 pentru fiecare apel. Sub un prag de dimensiune, parcurge bufferul cu căutări în tabel și trucuri de pliere care nu au nevoie de memorie suplimentară semnificativă, iar peste acel prag, aproximativ 119 KB, precis 118.960 de octeți în build-ul cu care se leagă HotXLS, trece la un algoritm rapid specializat numit Chorba. Implementarea C generică a acelei căi schimbă memorie pentru viteză: alocă un tablou de lucru pe stivă, nu pe heap, dimensionat pentru a face bucla interioară a algoritmului rapidă, nu pentru a se încadra confortabil în orice buget de stivă poartă întâmplător thread-ul apelant. Nimic din toate acestea nu este vizibil de pe partea apelantului. O funcție de sumă de control este în mod normal un apel frunză, citește câțiva octeți, returnează un număr, nicio alocare demnă de discutat, iar această presupunere se menține pentru marea majoritate a apelurilor în zlib-ng chiar până când un buffer suficient de mare încât să depășească pragul Chorba pășește în unul
function BuildWorksheetPartCrc(const XmlBytes: TBytes): LongWord;
begin
// One call over the whole worksheet XML buffer: fine for a small
// sheet, but a large enough input pushes zlib-ng onto its Chorba
// fast path and that path's stack-hungry scratch buffer
Result := ZLibCRC32(0, XmlBytes[0], Length(XmlBytes));
end;
De ce l-au văzut thread-urile worker și depanarea interactivă niciodată
Declanșarea acestui accident necesită două condiții simultan: o parte XML de foaie de calcul suficient de mare încât să depășească pragul Chorba al zlib-ng, și un thread care are doar stiva obișnuită implicită, nu ceva mai spațios. Joburile de export de producție ating ambele. Rulează ca joburi de lot pe partea de server care distribuie scrierile HotXLS pe un pool de thread-uri worker, fiecare purtând stiva implicită de 1 MB pe care Windows o rezervă decât dacă un apelant cere mai mult, și fiecare procesând registre de lucru ale clienților suficient de mari încât să conteze. Depanarea la birou nu atingea niciuna din condiții în mod fiabil: fișierele de exemplu erau de obicei mai mici decât pragul, iar rulările pas-cu-pas tindeau să se întâmple pe thread-ul principal, nu în interiorul unui worker proaspăt generat, așa că cele două condiții care trebuiau să se alinieze în producție aproape niciodată nu se aliniau la biroul unui dezvoltator
Urmărirea unui accident care a acuzat funcția greșită
Rapoartele de accident pe care echipa a putut pune mâna indicau o locație în interiorul funcției deflate a zlib-ng, nu în niciun cod HotXLS, și nici evident în codul CRC-32 nici el. Acel singur detaliu a trimis prima trecere a investigației spre calea de compresie: dimensiunile de buffer transmise către deflate, biții de fereastră, nivelul de compresie, toți suspecții obișnuiți pentru un accident nativ ieșind dintr-un codec. Niciunul din ei nu s-a confirmat
Un cadru de top înșelător
O depășire de stivă este un tip ciudat de accident de simbolizat, pentru că până când este raportat, indicatorul de stivă a trecut deja de spațiul care i-a fost rezervat. Orice a produs acel raport de accident cel mai probabil a rezolvat adresa care a cauzat eroarea la cel mai apropiat simbol pe care încă îl putea găsi, iar cel mai apropiat punct de intrare exportat aflat lângă adevăratul vinovat s-a întâmplat să fie deflate. Eroarea efectivă stătea în alocarea bufferului de lucru Chorba din interiorul căii CRC-32, compilată în aceeași bibliotecă, suficient de aproape în binar încât să fie confundată cu funcția care rula efectiv
Bisectarea cu marcaje de timp în loc de un debugger
Un accident care doboară întregul proces nu lasă nimic pentru o sesiune normală de debugger Delphi de prins, așa că echipa a recurs la puncte de control GetTickCount plasate în jurul fiecărui apel suspect și o bisectare manuală pe calea de salvare, restrângând care operație era în curs de desfășurare în momentul în care procesul a murit. Alături de asta, un build de referință cunoscut ca bun a rulat aceleași fișiere de producție paralel cu cel curent, specific pentru a exclude o regresie în propriile schimbări ale acelei runde înainte de a privi mai departe în amonte. Doar după ce ambele verificări au revenit curate, investigația s-a stabilit pe o dependență terță care făcea ceva neașteptat cu o intrare perfect validă
De ce eșuează try/except să prindă o depășire de stivă?
O depășire de stivă nu este o excepție pe care codul Delphi o ridică vreodată intenționat, și nu este nici livrată în modul în care Windows livrează o încălcare de acces sau o împărțire la zero. Apare ca o eroare hardware de pagină de gardă, raportată prin același mecanism de gestionare structurată a excepțiilor pe care este construit try/except-ul Delphi, dar în momentul exact în care se declanșează nu mai există în mod normal spațiu de stivă rămas pentru a rula un handler, a derula codul de curățare, sau chiar a termina raportarea erorii curat. Pe un thread worker care poartă doar rezervarea implicită de 1 MB, cu un buffer de lucru de acea dimensiune care deja a consumat majoritatea a ce a mai rămas, nu mai rămâne nimic cu care runtime-ul să lucreze
procedure TExportWorker.Execute;
var
Workbook: TXLSXWorkbook;
begin
Workbook := TXLSXWorkbook.Create;
try
try
BuildWorksheet(Workbook);
Workbook.SaveAs(FTargetFile); // crashes the process here on a
// large enough sheet: try/except
// never gets a chance to run
except
on E: Exception do
LogError('Export failed: ' + E.Message);
end;
finally
Workbook.Free;
end;
end;
Acel bloc except arată ca o plasă de siguranță, și împotriva majorității eșecurilor chiar este una, dar aici nu face nimic. Echipa a confirmat asta în practică: try/except nu a prins nimic, blocul finally nu a primit niciodată o șansă fiabilă de a rula nici el, iar operatorul a văzut un proces mort fără nicio intrare de jurnal la nivel de aplicație deloc, exact ce descria tichetul de suport original
Soluția: alimentați CRC32 în felii de 64 KB în loc de un singur apel gigantic
Soluția pe care HotXLS a livrat-o nu schimbă nimic despre zlib-ng însuși și nimic despre nivelul de compresie folosit pentru a scrie registrul de lucru. ZLibCRC32 acum parcurge intrarea în felii fixe de 64 KB, câte 65536 de octeți fiecare, apelând crc32-ul zlib-ng o dată per felie și transmițând valoarea de sumă de control în curs de la un apel la următorul. CRC-32 este un algoritm incremental prin construcție, așa că o sumă de control construită peste mai multe felii este identică bit-cu-bit cu una calculată într-un singur apel peste aceiași octeți: soluția schimbă cum este împărțită munca, nu ce calculează
function ZLibCRC32(crc: LongWord; const buffer; count: Longint): LongWord;
const
// 64 KB keeps every call comfortably under the Chorba threshold
CrcChunkSize = 65536;
var
Cursor: PByte;
ThisChunk: Longint;
begin
Result := crc;
Cursor := PByte(@buffer);
while count > 0 do
begin
ThisChunk := count;
if ThisChunk > CrcChunkSize then
ThisChunk := CrcChunkSize;
Result := zng_crc32(Result, Cursor, Cardinal(ThisChunk));
Inc(Cursor, ThisChunk);
Dec(count, ThisChunk);
end;
end;
Nimic despre apelul SaveAs din jur nu a trebuit să se schimbe pentru ca asta să funcționeze, și nici nimic despre intrările ZIP pe care le scrie HotXLS nu s-a schimbat: valoarea CRC-32 care ajunge în antetul local al fișierului și în directorul central este exact valoarea pe care un singur apel gigantic ar fi produs-o, doar asamblată din bucăți mai mici. Retrogradarea zlib-ng sau revenirea la o implementare CRC-32 mai lentă, ușoară la alocări, ar fi evitat de asemenea accidentul, dar cu un cost real pentru fiecare fișier care nu s-a apropiat niciodată de prag în primul rând, motiv pentru care niciuna nu a fost livrată
Ce înseamnă asta dacă apelați zlib-ng din propriile dvs. thread-uri worker
Modul de eșec prin depășire de stivă descris aici nu are nimic de-a face cu foile de calcul specific. Orice aplicație care predă zlib-ng un buffer mare, fie pentru compresie, decompresie, sau o sumă de control, dintr-un thread care poartă doar stiva implicită a platformei poate atinge același gen de zid, pentru că biblioteca își alege algoritmul după dimensiunea de intrare, iar unii din acei algoritmi presupun că există stivă de rezervă. Două apărări funcționează fără a atinge zlib-ng însuși: alimentarea buffer-elor mari în rutine sensibile la dimensiune în bucăți fixe elimină complet condiția declanșatoare pentru orice algoritm care este natural incremental, iar acolo unde împărțirea în bucăți nu este o opțiune, oferirea thread-ului apelant a unei stive mai mari decât implicitul platformei este cealaltă pârghie. Oricare este mai ieftină decât a afla despre un prag de dimensiune nedocumentat dintr-un raport de accident de producție care acuză funcția greșită
Acest prag particular a rămas invizibil până când un registru de lucru de producție suficient de mare l-a depășit pe tipul greșit de thread, ceea ce este exact genul de eșec care apare doar odată ce codul rulează pe fișiere reale, nu pe fixtures mici. Calea CRC-32 în bucăți acum este livrată ca parte a conductei de scriere standard din componenta Excel HotXLS pentru Delphi și C++Builder, fără nimic de configurat pentru un apelant și fără nicio proprietate care o pornește sau o oprește