Odborný článok

HotXLS, zlib-ng CRC32 a pretečenie zásobníka vo vláknach Delphi

HotXLS dokáže spadnúť s pracovným vláknom v Delphi bez akejkoľvek chytateľnej výnimky, keď v jednom volaní kontroluje kontrolný súčet veľkej časti XML hárka: zlib-ng nad zhruba 119 KB vstupu prepne na svoj algoritmus Chorba, a generická variantia tohto algoritmu v C alokuje pomocné pole dosť veľké na to, aby prerazilo predvolený 1 MB zásobník vlákna. Delphi nikdy nedostane šancu zareagovať, pretože pretečenie zásobníka nie je taký druh výnimky, na aký bol try/except postavený

HotXLS je natívna knižnica pre Delphi a C++Builder na čítanie a zápis zošitov Excelu, a pád sa vystopoval späť k jej zapisovaču hárkov. Prvým znakom problému bol tiket podpory: nočná exportná úloha padala zhruba dvakrát do týždňa, vždy uprostred behu, bez dialógu výnimky Delphi a bez zalogovanej chyby, iba proces, ktorý zmizol, a záznam Windows Error Reporting, ktorý nikam neviedol. Reprodukovať to za stolom bola úplne iná záležitosť. Malé zošity sa ukladali v poriadku. Veľké zošity sa tiež ukladali v poriadku, pokým ukladanie bežalo na hlavnom vlákne s už pripojeným debuggerom. Trvalo skutočnú dávku súborov produkčnej veľkosti bežiacich cez skutočnú viacvláknovú exportnú cestu, aby sa pád priniesol domov, do tej chvíle už boli vylúčené vstupno-výstupné operácie disku, tlak na pamäť, aj podozrivá šablóna

Ako sa uloženie hárka zmení na jedno obrovské volanie CRC32

Súbory XLSX sú kontajnery ZIP, a formát ZIP vyžaduje kontrolný súčet CRC-32 pre každú položku, zaznamenaný ako v lokálnej hlavičke súboru, tak v centrálnom adresári. HotXLS počíta tento kontrolný súčet volaním malého obalu nazvaného ZLibCRC32, ktorý zase volá vlastnú rutinu crc32 z zlib-ng vo chvíli, keď SaveAs dokončí zostavenie XML hárka v pamäti, a dlhý čas toto volanie nieslo celý nekomprimovaný buffer v jedinom volaní. To je rozumný návrh pre malý hárok. Stáva sa jedným veľmi veľkým volaním vo chvíli, keď je hárok toho druhu, ktorý pokrýva náš sprievodca výkonom veľkých zošitov v HotXLS, kde XML jediného hárka bežne prekročí niekoľko stoviek kilobajtov ešte pred tým, než sa vôbec skomprimuje

Prečo zlib-ng potrebuje pre CRC32 obrovský buffer na zásobníku?

zlib-ng nepoužíva jednu implementáciu CRC-32 pre každé volanie. Pod istou prahovou veľkosťou prechádza buffer vyhľadávaniami v tabuľke a trikmi so skladaním, ktoré nepotrebujú žiadnu zmysluplnú dodatočnú pamäť, a nad touto prahovou hodnotou, zhruba 119 KB, presne 118 960 bajtov v builde, s ktorým HotXLS linkuje, prepne na špecializovaný rýchly algoritmus zvaný Chorba. Generická implementácia tejto cesty v C mení pamäť za rýchlosť: alokuje pomocné pole na zásobníku namiesto na halde, veľkostne stanovené tak, aby vnútorná slučka algoritmu bola rýchla, nie aby sa pohodlne zmestilo do akéhokoľvek rozpočtu zásobníka, aký volajúce vlákno náhodou nesie. Nič z toho nie je zo strany volajúceho viditeľné. Funkcia kontrolného súčtu je normálne listové volanie, prečítať pár bajtov, vrátiť číslo, žiadna alokácia hodná diskusie, a tento predpoklad platí pre drvivú väčšinu volaní do zlib-ng až do chvíle, keď doň vstúpi buffer dosť veľký na to, aby prekročil prah Chorba

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;

Prečo to videli pracovné vlákna a interaktívne ladenie nikdy

Vyvolanie tohto pádu vyžaduje dve podmienky naraz: časť XML hárka dosť veľkú na to, aby prekročila prah Chorba v zlib-ng, a vlákno, ktoré má iba obyčajný predvolený zásobník namiesto niečoho priestrannejšieho. Produkčné exportné úlohy trafia obe. Bežia ako dávkové úlohy na strane servera, ktoré rozptyľujú zápisy HotXLS naprieč fondom pracovných vlákien, každé s predvoleným 1 MB zásobníkom, ktorý Windows rezervuje, pokým volajúci nepožiada o viac, a každé spracúva zošity zákazníkov dosť veľké na to, aby na tom záležalo. Ladenie za stolom spoľahlivo netrafilo ani jednu z podmienok: vzorové súbory boli zvyčajne menšie než prah, a behy krok-po-kroku sa zvykli diať na hlavnom vlákne namiesto vnútri čerstvo vytvoreného pracovného, takže tie dve podmienky, ktoré sa v produkcii museli zosúladiť, sa na stole vývojára takmer nikdy nezosúladili

Naháňanie pádu, ktorý obviňoval nesprávnu funkciu

Správy o páde, ktoré sa tímu podarilo získať, ukazovali na miesto vnútri deflate funkcie zlib-ng, nie na žiadny kód HotXLS, a ani zjavne na kód CRC-32. Práve tento jeden detail nasmeroval prvý prechod vyšetrovania na kompresnú cestu: veľkosti bufferov odovzdávané deflate, bity okna, úroveň kompresie, všetci obvyklí podozriví pre natívny pád vychádzajúci z kodeku. Žiadny z nich neobstál

Klamlivý vrchný rám

Pretečenie zásobníka je zvláštny druh pádu na symbolizovanie, pretože v čase, keď sa nahlási, ukazovateľ zásobníka už prebehol priestor, ktorý preň bol vyhradený. Čokoľvek túto správu o páde vyprodukovalo, s najväčšou pravdepodobnosťou vyriešilo chybujúcu adresu na najbližší symbol, aký ešte dokázalo nájsť, a najbližší exportovaný vstupný bod sediaci vedľa skutočného vinníka náhodou bola deflate. Skutočná chyba sedela v alokácii pomocného bufferu Chorba vnútri cesty CRC-32, skompilovanej do tej istej knižnice, dosť blízko v binárke na to, aby ju bolo možné zameniť za funkciu, ktorá v skutočnosti bežala

Bisekcia s časovými značkami namiesto debuggera

Pád, ktorý zoberie so sebou celý proces, nenechá bežnej relácii debuggera Delphi nič na zachytenie, takže tím sa vrátil ku kontrolným bodom GetTickCount vloženým okolo každého podozrivého volania a k ručnej bisekcii naprieč ukladacou cestou, zužujúc, ktorá operácia bola v behu v okamihu, keď proces zomrel. Popri tom rovnaké produkčné súbory spustil vedľa seba aj známy dobrý základný build oproti aktuálnemu, konkrétne aby sa vylúčila regresia vo vlastných zmenách tohto kola ešte pred ďalším pátraním smerom vyššie. Až po tom, čo obe kontroly vyšli čisto, sa vyšetrovanie usadilo na závislosti tretej strany, ktorá robila niečo neočakávané s dokonale platným vstupom

Prečo try/except nedokáže zachytiť pretečenie zásobníka?

Pretečenie zásobníka nie je výnimka, ktorú by kód Delphi niekedy zámerne vyvolal, a nedoručuje sa ani tak, ako Windows doručuje access violation alebo delenie nulou. Prejaví sa ako hardvérová chyba stránky stráže, hlásená cez ten istý mechanizmus štruktúrovaného ošetrenia výnimiek, na ktorom je postavený try/except v Delphi, no v presnom okamihu, keď sa spustí, normálne nezostáva žiadny priestor zásobníka na spustenie handlera, rozvinutie čistiaceho kódu, alebo hoci len čisté dokončenie hlásenia chyby. Na pracovnom vlákne nesúcom iba predvolenú rezerváciu 1 MB, kde pomocný buffer tejto veľkosti už spotreboval väčšinu z toho, čo zostávalo, runtime nemá s čím pracovať

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;

Tento blok except vyzerá ako záchranná sieť, a voči väčšine zlyhaní ňou aj je, no tu nerobí nič. Tím to v praxi potvrdil: try/except nezachytilo nič, blok finally tiež nikdy nedostal spoľahlivú šancu spustiť sa, a operátor videl mŕtvy proces bez akéhokoľvek záznamu v logu na úrovni aplikácie, presne to, čo opisoval pôvodný tiket podpory

Oprava: podávať CRC32 v 64 KB výrezoch namiesto jedného obrovského volania

Oprava, ktorú HotXLS vydal, nemení nič na samotnom zlib-ng ani na úrovni kompresie použitej na zápis zošita. ZLibCRC32 teraz prechádza vstup v pevných 64 KB výrezoch, po 65536 bajtoch, volajúc crc32 z zlib-ng raz na výrez a preplietajúc bežiacu hodnotu kontrolného súčtu z jedného volania do druhého. CRC-32 je svojou konštrukciou inkrementálny algoritmus, takže kontrolný súčet zostavený cez niekoľko výrezov je bit po bite identický s tým, čo by sa vypočítalo v jedinom volaní nad tými istými bajtami: oprava mení to, ako sa práca rozdeľuje, nie to, čo počíta

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;

Nič na okolitom volaní SaveAs sa kvôli tomu nemuselo zmeniť, a nič na položkách ZIP, ktoré HotXLS zapisuje, sa tiež nezmenilo: hodnota CRC-32, ktorá skončí v lokálnej hlavičke súboru a centrálnom adresári, je presne tá hodnota, akú by vyprodukovalo jediné obrovské volanie, iba zostavená z menších kúskov. Downgrade zlib-ng alebo návrat k pomalšej implementácii CRC-32 s nízkymi nárokmi na alokáciu by tiež zabránili pádu, no za skutočnú cenu pre každý súbor, ktorý sa k prahu nikdy ani nepriblížil, a preto ani jedno z toho nevyšlo

Čo to znamená, ak voláte zlib-ng z vlastných pracovných vlákien

Tento režim zlyhania pretečením zásobníka nemá nič spoločné konkrétne s tabuľkami. Akákoľvek aplikácia, ktorá odovzdá zlib-ng veľký buffer, či už na kompresiu, dekompresiu, alebo kontrolný súčet, z vlákna, ktoré nesie iba predvolený zásobník platformy, môže naraziť na tú istú stenu, pretože knižnica vyberá svoj algoritmus podľa veľkosti vstupu a niektoré z týchto algoritmov predpokladajú, že je zásobníka nazvyš. Dve obrany fungujú bez toho, aby sa dotkli samotného zlib-ng: podávanie veľkých bufferov do na veľkosť citlivých rutín v pevných blokoch úplne odstráni spúšťaciu podmienku pre akýkoľvek algoritmus, ktorý je prirodzene inkrementálny, a tam, kde delenie na bloky nie je možnosťou, dať volajúcemu vláknu väčší zásobník, než je predvolený na platforme, je druhá páka. Ktorákoľvek z nich je lacnejšia, než sa o nezdokumentovanom prahu veľkosti dozvedieť zo správy o produkčnom páde, ktorá obviňuje nesprávnu funkciu

Tento konkrétny prah zostal neviditeľný, kým ho na nesprávnom druhu vlákna neprekročil dosť veľký produkčný zošit, čo je presne ten druh zlyhania, ktorý sa prejaví iba vtedy, keď kód beží voči skutočným súborom namiesto malých testovacích fixtúr. Cesta CRC-32 rozdelená na bloky sa teraz dodáva ako súčasť štandardnej zápisovej pipeline v komponente HotXLS Excel pre Delphi a C++Builder, bez čohokoľvek na konfiguráciu zo strany volajúceho a bez akejkoľvek vlastnosti, ktorá by to zapínala alebo vypínala