Műszaki cikk

HotXLS, zlib-ng CRC32, és veremtúlcsordulás Delphi szálakban

A HotXLS elfoglalhat, semmilyen elkapható kivétel nélkül, egy Delphi munkásszálat, amikor egyetlen hívásban ellenőrzőösszeget képez egy nagy munkalap-XML-részhez: a zlib-ng nagyjából 119 KB bemenet fölött átvált a Chorba algoritmusára, és annak az algoritmusnak a generikus-C változata egy elég nagy segédtömböt allokál ahhoz, hogy átfújjon az alapértelmezett 1 MB-os szálvermen. A Delphinek soha nincs esélye reagálni, mert a veremtúlcsordulás nem az a fajta kivétel, amit a try/except elkapni készült

A HotXLS egy natív Delphi és C++Builder könyvtár Excel-munkafüzetek olvasásához és írásához, és az összeomlás visszavezethető volt a munkalap-írójára. Az első jel egy támogatási jegy volt: egy éjszakai exportfeladat nagyjából hetente kétszer omlott össze, mindig futás közben, semmilyen Delphi kivétel-párbeszédablak és semmilyen naplózott hiba nélkül, csak egy folyamat, amely eltűnt, és egy Windows Error Reporting bejegyzés, amely sehová nem mutatott hasznosan. Ennek asztali reprodukálása egészen más ügy volt. A kis munkafüzetek jól mentődtek. A nagy munkafüzetek is jól mentődtek, amíg a mentés a fő szálon futott, egy már csatolt debuggerrel. Egy tényleges köteg termelési méretű fájlra volt szükség, amely a valódi, több szálas exportútvonalon futott át, hogy hazahozza az összeomlást, mire a lemez I/O, a memóriaterhelés, és egy gyanús sablon már mindegyike ki lett zárva

Hogyan válik egy munkalap-mentés egyetlen óriási CRC32-hívássá

Az XLSX-fájlok ZIP-konténerek, és a ZIP-formátum CRC-32 ellenőrzőösszeget igényel minden bejegyzéshez, rögzítve mind a helyi fájlfejlécben, mind a központi könyvtárban. A HotXLS úgy számítja ki ezt az ellenőrzőösszeget, hogy meghív egy kis burkolót, a ZLibCRC32 nevűt, amely aztán meghívja a zlib-ng saját crc32 rutinját, miután a SaveAs befejezte egy munkalap XML-jének memóriabeli összeállítását, és sokáig ez a hívás a teljes tömörítetlen puffert egyetlen meghívásban hordozta. Ez ésszerű tervezés egy kis munkalaphoz. Egyetlen, nagyon nagy hívássá válik abban a pillanatban, amikor egy lap az a fajta, amit a HotXLS nagy munkafüzetek teljesítményéről szóló útmutatónk tárgyal, ahol egy egyetlen lap XML-je rutinszerűen néhány száz kilobájt fölé fut, mielőtt valaha is tömörítenék

Miért van szüksége a zlib-ng-nek egy óriási verempufferre a CRC32-höz?

A zlib-ng nem egy CRC-32 megvalósítást használ minden híváshoz. Egy méretküszöb alatt táblakeresésekkel és hajtogatási trükkökkel járja be a puffert, amelyek nem igényelnek jelentős extra memóriát, és e küszöb fölött, nagyjából 119 KB-nál, pontosan 118 960 bájtnál abban a buildben, amellyel a HotXLS linkel, átvált egy specializált gyors algoritmusra, a Chorbára. Annak az útvonalnak a generikus-C megvalósítása memóriát cserél sebességre: egy segédtömböt allokál a veremen a kupac helyett, olyan méretűre, hogy az algoritmus belső ciklusa gyors legyen, nem pedig hogy kényelmesen beleférjen abba a verem-költségvetésbe, amit a hívó szál éppen hordoz. Mindebből semmi nem látható a hívó oldaláról. Egy ellenőrzőösszeg-függvény normál esetben levélhívás, olvass néhány bájtot, adj vissza egy számot, semmilyen allokáció, amiről érdemes lenne beszélni, és ez a feltevés a zlib-ng-be irányuló hívások túlnyomó többségénél megáll egészen addig, amíg egy elég nagy puffer, amely átlépi a Chorba-küszöböt, be nem sétál egyikbe

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;

Miért látták a munkásszálak, és miért nem az interaktív debuggolás soha

Ennek az összeomlásnak a kiváltása két feltételt igényel egyszerre: egy munkalap XML-részt, amely elég nagy ahhoz, hogy átlépje a zlib-ng Chorba-küszöbét, és egy szálat, amelynek csak a szokásos alapértelmezett verme van, nem valami tágasabb. A termelési exportfeladatok mindkettőt eltalálják. Ezek szerver-oldali kötegelt feladatokként futnak, amelyek szétterítik a HotXLS-írásokat egy munkásszál-készleten, mindegyik az alapértelmezett 1 MB-os vermet hordozva, amit a Windows fenntart, hacsak egy hívó nem kér többet, és mindegyikük elég nagy ügyfél-munkafüzeteket dolgoz fel ahhoz, hogy számítson. Az asztali debuggolás megbízhatóan egyik feltételt sem találta el: a minta-fájlok általában kisebbek voltak a küszöbnél, és az egyenkénti lépésű futtatások általában a fő szálon történtek, nem egy frissen indított munkásszálon belül, így az a két feltétel, amelynek termelésben egybe kellett esnie, szinte soha nem esett egybe egy fejlesztő asztalán

Egy összeomlás üldözése, amely a rossz függvényt hibáztatta

Az összeomlási jelentések, amiket a csapat meg tudott szerezni, egy helyre mutattak a zlib-ng deflate függvényén belül, nem a HotXLS semmilyen kódjára, és nem nyilvánvalóan a CRC-32 kódra sem. Ez az egyetlen részlet a vizsgálat első átfutását a tömörítési útvonal felé küldte: a deflate-nek átadott pufferméretek, ablakbitek, tömörítési szint, minden szokásos gyanúsított egy natív összeomláshoz, amely egy kodekből jön. Egyik sem állt meg

Egy félrevezető felső keret

Egy veremtúlcsordulás furcsa fajta összeomlás, amit szimbolizálni kell, mert mire jelentésre kerül, a verempointer már túlfutott azon a helyen, amit fenntartottak neki. Bármi is állította elő azt az összeomlási jelentést, legvalószínűbb, hogy a hibázó címet a legközelebbi szimbólumra oldotta fel, amit még talált, és a legközelebbi exportált belépési pont, amely a valódi tettes mellett ült, véletlenül a deflate volt. A tényleges hiba a Chorba segédpuffer-allokációjában ült a CRC-32 útvonalon belül, ugyanabba a könyvtárba fordítva, elég közel a binárisban ahhoz, hogy összekeverjék azzal a függvénnyel, amely ténylegesen futott

Bisekció időbélyegekkel debugger helyett

Egy összeomlás, amely az egész folyamatot leviszi, semmit nem hagy egy normál Delphi debugger-munkamenetnek elkapni, így a csapat visszaesett GetTickCount ellenőrzőpontokra, amelyeket minden gyanús hívás köré dobtak, és egy kézi bisekcióra a mentési útvonalon át, leszűkítve, melyik művelet volt folyamatban abban a pillanatban, amikor a folyamat meghalt. Emellett egy ismert-jó alapvonal-build futtatta ugyanazokat a termelési fájlokat egymás mellett az aktuálissal, kifejezetten azért, hogy kizárja azt a lehetőséget, hogy az adott kör saját változásaiban van egy regresszió, mielőtt tovább néznének feljebb az upstream-ben. Csak miután mindkét ellenőrzés tisztán tért vissza, telepedett le a vizsgálat egy harmadik féltől származó függőségen, amely valami váratlant csinál egy tökéletesen érvényes bemenettel

Miért nem sikerül a try/except-nek elkapnia egy veremtúlcsordulást?

Egy veremtúlcsordulás nem olyan kivétel, amit a Delphi-kód valaha is szándékosan dob, és nem is úgy kerül kézbesítésre, ahogyan a Windows egy hozzáférési hibát vagy egy nullával osztást kézbesít. Hardveres őrlap-hibaként (guard-page fault) jelenik meg, ugyanazon a strukturált kivételkezelési mechanizmuson keresztül jelentve, amire a Delphi try/exceptje épül, de abban a pontos pillanatban, amikor kiváltódik, normál esetben nincs verem-hely, hogy egy kezelőt futtasson, takarítási kódot tekerjen le, vagy akár tisztán befejezze a hiba jelentését. Egy munkásszálon, amely csak az alapértelmezett 1 MB-os fenntartást hordozza, egy olyan méretű segédpufferrel, amely már elfogyasztotta a maradék nagy részét, semmi nem marad, amivel a futtatókörnyezet dolgozhatna

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;

Az az except-blokk biztonsági hálónak néz ki, és a legtöbb hiba ellen az is, de itt semmit nem tesz. A csapat ezt gyakorlatban is megerősítette: a try/except semmit nem kapott el, a finally blokknak sem volt megbízható esélye lefutni, és az üzemeltető egy halott folyamatot látott, egyetlen alkalmazás-szintű naplóbejegyzés nélkül, pontosan azt, amit az eredeti támogatási jegy leírt

A javítás: CRC32 táplálása 64 KB-os szeletekben egyetlen óriási hívás helyett

A javítás, amit a HotXLS kiszállított, semmit nem változtat meg magán a zlib-ng-n, és semmit a munkafüzet írásához használt tömörítési szinten. A ZLibCRC32 most fix 64 KB-os szeletekben járja be a bemenetet, egyenként 65536 bájtban, szeletenként egyszer hívva meg a zlib-ng crc32-jét, és a futó ellenőrzőösszeg-értéket egyik hívásból a másikba fűzve. A CRC-32 felépítésénél fogva inkrementális algoritmus, így egy több szeleten át felépített ellenőrzőösszeg bájtról bájtra azonos azzal, amit egyetlen hívásban számítanánk ugyanazon bájtok fölött: a javítás azt változtatja meg, hogyan van felosztva a munka, nem azt, mit számít

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;

Semminek nem kellett megváltoznia a környező SaveAs hívásban ahhoz, hogy ez működjön, és semmi nem változott a ZIP-bejegyzésekben sem, amiket a HotXLS ír: a CRC-32 érték, amely a helyi fájlfejlécben és a központi könyvtárban végzi, pontosan az az érték, amit egyetlen óriási hívás előállított volna, csak kisebb darabokból összeállítva. A zlib-ng leértékelése, vagy egy lassabb, allokáció-könnyű CRC-32 megvalósításra való visszaesés is elkerülte volna az összeomlást, de valódi költséggel minden fájlnál, amely soha nem került közel a küszöbhöz eleve, ezért egyik sem került kiszállításra

Mit jelent ez, ha a zlib-ng-t hívod a saját munkásszálaidból

Az itt leírt veremtúlcsordulás-hibamódnak semmi köze kifejezetten a táblázatokhoz. Bármely alkalmazás, amely egy nagy puffert ad át a zlib-ng-nek, akár tömörítéshez, dekompresszióhoz, vagy egy ellenőrzőösszeghez, egy olyan szálról, amely csak a platform alapértelmezett vermét hordozza, ugyanolyan fajta falba ütközhet, mert a könyvtár a bemenet mérete alapján választja ki algoritmusát, és néhány ezen algoritmusok közül feltételezi, hogy van felesleges verem. Két védekezés működik anélkül, hogy magához a zlib-ng-hez nyúlnánk: nagy puffereket fix darabokban táplálni a méret-érzékeny rutinokba teljesen eltávolítja a kiváltó feltételt bármely algoritmusnál, amely eleve inkrementális, és ahol a darabolás nem opció, a hívó szál nagyobb vermet adása a platform alapértelmezettjénél a másik kar. Bármelyik is olcsóbb, mint egy dokumentálatlan méretküszöbről egy termelési összeomlási jelentésből megtudni, amely a rossz függvényt hibáztatja

Ez a konkrét küszöb láthatatlan maradt, amíg egy elég nagy termelési munkafüzet át nem lépte a rossz fajta szálon, ami pontosan az a fajta hiba, amely csak akkor mutatkozik meg, amikor a kód valódi fájlok ellen fut, nem kis rögzítők ellen. A darabolt CRC-32 útvonal most a szabványos írási csővezeték részeként érkezik a Delphihez és C++Builderhez készült HotXLS Excel Komponensben, semmi konfigurálandó a hívó számára, és semmilyen tulajdonság, ami be- vagy kikapcsolná