Technischer Artikel

HotXLS, zlib-ng CRC32 und Stack-Überlauf in Delphi-Threads

HotXLS kann einen Delphi-Worker-Thread ohne abfangbare Exception zum Absturz bringen, wenn es ein großes Arbeitsblatt-XML-Teil in einem einzigen Aufruf prüfsummt: zlib-ng wechselt oberhalb von etwa 119 KB Eingabe zu seinem Chorba-Algorithmus, und die generische C-Variante dieses Algorithmus alloziert ein Scratch-Array, das groß genug ist, um durch den Standard-1-MB-Thread-Stack hindurchzuschießen. Delphi bekommt nie die Chance zu reagieren, weil ein Stack-Überlauf nicht die Art von Exception ist, für deren Abfangen try/except gebaut wurde

HotXLS ist eine native Delphi- und C++Builder-Bibliothek zum Lesen und Schreiben von Excel-Arbeitsmappen, und der Absturz ließ sich auf ihren Arbeitsblatt-Writer zurückverfolgen. Das erste Anzeichen von Ärger war ein Support-Ticket: Ein nächtlicher Export-Job stürzte etwa zweimal pro Woche ab, immer mitten im Lauf, ohne Delphi-Exception-Dialog und ohne protokollierten Fehler, nur ein Prozess, der verschwand, und ein Windows-Error-Reporting-Eintrag, der ins Leere zeigte. Es am Schreibtisch zu reproduzieren war eine völlig andere Sache. Kleine Arbeitsmappen speicherten problemlos. Große Arbeitsmappen speicherten ebenfalls problemlos, solange der Speichervorgang auf dem Hauptthread mit bereits angehängtem Debugger lief. Es brauchte einen echten Batch produktionsgroßer Dateien, die durch den echten Multi-Thread-Export-Pfad liefen, um den Absturz nach Hause zu bringen, und zu diesem Zeitpunkt waren Festplatten-E/A, Speicherdruck und eine verdächtige Vorlage bereits jeweils ausgeschlossen worden

Wie ein Arbeitsblatt-Speichern zu einem einzigen riesigen CRC32-Aufruf wird

XLSX-Dateien sind ZIP-Container, und das ZIP-Format verlangt für jeden Eintrag eine CRC-32-Prüfsumme, protokolliert sowohl im lokalen Datei-Header als auch im zentralen Verzeichnis. HotXLS berechnet diese Prüfsumme, indem es einen kleinen Wrapper namens ZLibCRC32 aufruft, der wiederum zlib-ngs eigene crc32-Routine aufruft, sobald SaveAs den Aufbau des XML eines Arbeitsblatts im Speicher abgeschlossen hat, und lange Zeit trug dieser Aufruf den gesamten unkomprimierten Puffer in einem einzigen Aufruf. Das ist ein vernünftiges Design für ein kleines Arbeitsblatt. Es wird zu einem sehr großen Aufruf in dem Moment, in dem ein Blatt der Art ist, die in unserem Leitfaden zur Leistung großer Arbeitsmappen in HotXLS behandelt wird, wo das XML eines einzelnen Blatts routinemäßig weit über ein paar Hundert Kilobyte hinausgeht, bevor es überhaupt komprimiert wird

Warum braucht zlib-ng einen riesigen Stack-Puffer für CRC32?

zlib-ng verwendet nicht eine einzige CRC-32-Implementierung für jeden Aufruf. Unterhalb einer Größenschwelle durchläuft es den Puffer mit Tabellen-Lookups und Folding-Tricks, die keinen nennenswerten zusätzlichen Speicher brauchen, und oberhalb dieser Schwelle, etwa 119 KB, genau 118.960 Bytes im Build, gegen das HotXLS linkt, wechselt es zu einem spezialisierten schnellen Algorithmus namens Chorba. Die generische-C-Implementierung dieses Pfads tauscht Speicher gegen Geschwindigkeit: Sie alloziert ein Scratch-Array auf dem Stack statt auf dem Heap, dimensioniert, um die innere Schleife des Algorithmus schnell zu machen, nicht um bequem in welches Stack-Budget auch immer zu passen, das der aufrufende Thread gerade trägt. Nichts davon ist von der Aufruferseite aus sichtbar. Eine Prüfsummen-Funktion ist normalerweise ein Blattaufruf, ein paar Bytes lesen, eine Zahl zurückgeben, keine erwähnenswerte Allokation, und diese Annahme hält für die überwältigende Mehrheit der Aufrufe in zlib-ng, genau bis ein Puffer, groß genug, um die Chorba-Schwelle zu überschreiten, hineinläuft

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;

Warum Worker-Threads es sahen und interaktives Debugging nie

Diesen Absturz auszulösen braucht zwei Bedingungen gleichzeitig: ein Arbeitsblatt-XML-Teil, groß genug, um zlib-ngs Chorba-Schwelle zu überschreiten, und einen Thread, der nur den gewöhnlichen Standard-Stack hat statt etwas Geräumigerem. Produktions-Export-Jobs erfüllten beide. Sie laufen als serverseitige Batch-Jobs, die HotXLS-Schreibvorgänge über einen Pool von Worker-Threads verteilen, jeder mit dem Standard-1-MB-Stack, den Windows reserviert, sofern ein Aufrufer nicht mehr anfordert, und jeder verarbeitet Kunden-Arbeitsmappen, die groß genug sind, um zu zählen. Debugging am Schreibtisch erfüllte zuverlässig keine der beiden Bedingungen: Beispieldateien waren meist kleiner als die Schwelle, und Einzelschritt-Läufe fanden eher auf dem Hauptthread statt als in einem frisch erzeugten Worker, sodass die zwei Bedingungen, die in der Produktion zusammentreffen mussten, am Schreibtisch eines Entwicklers fast nie zusammentrafen

Der Verfolgung eines Absturzes, der die falsche Funktion beschuldigte

Die Absturzberichte, die das Team in die Hände bekommen konnte, zeigten auf eine Stelle innerhalb von zlib-ngs deflate-Funktion, nicht auf irgendeinen HotXLS-Code, und auch nicht offensichtlich auf den CRC-32-Code. Dieses eine Detail lenkte den ersten Durchgang der Untersuchung zum Kompressionspfad: Puffergrößen, die an deflate übergeben werden, Window-Bits, Kompressionsstufe, all die üblichen Verdächtigen für einen nativen Absturz aus einem Codec heraus. Keiner davon hielt stand

Ein irreführender oberster Frame

Ein Stack-Überlauf ist eine seltsame Art von Absturz zu symbolisieren, denn zu dem Zeitpunkt, an dem er gemeldet wird, ist der Stack-Zeiger bereits über den für ihn reservierten Platz hinausgelaufen. Was auch immer diesen Absturzbericht erzeugt hat, löste die fehlerhafte Adresse höchstwahrscheinlich auf das nächstgelegene Symbol auf, das es noch finden konnte, und der nächstgelegene exportierte Einstiegspunkt neben dem echten Übeltäter war zufällig deflate. Der tatsächliche Fehler saß in der Chorba-Scratch-Puffer-Allokation innerhalb des CRC-32-Pfads, in dieselbe Bibliothek kompiliert, nah genug in der Binärdatei, um mit der Funktion verwechselt zu werden, die tatsächlich lief

Bisektion mit Zeitstempeln statt einem Debugger

Ein Absturz, der den ganzen Prozess mit sich reißt, hinterlässt nichts, was eine normale Delphi-Debugger-Sitzung abfangen könnte, sodass das Team auf GetTickCount-Checkpoints zurückgriff, verteilt um jeden verdächtigen Aufruf, und eine manuelle Bisektion über den Speicherpfad durchführte, um einzugrenzen, welche Operation im Moment des Prozesstods im Gange war. Daneben lief ein bekannt-gutes Basis-Build dieselben Produktionsdateien Seite an Seite mit dem aktuellen, speziell um eine Regression in den Änderungen dieser Runde selbst auszuschließen, bevor weiter stromaufwärts gesucht wurde. Erst nachdem beide Prüfungen sauber zurückkamen, setzte sich die Untersuchung auf eine Drittanbieter-Abhängigkeit fest, die mit einer vollkommen gültigen Eingabe etwas Unerwartetes tat

Warum fängt try/except einen Stack-Überlauf nicht ab?

Ein Stack-Überlauf ist keine Exception, die Delphi-Code je absichtlich auslöst, und er wird auch nicht so zugestellt, wie Windows eine Zugriffsverletzung oder eine Division durch null zustellt. Er tritt als Hardware-Guard-Page-Fault zutage, gemeldet über denselben Structured-Exception-Handling-Mechanismus, auf dem Delphis try/except aufbaut, aber in dem genauen Moment, in dem er auslöst, ist normalerweise kein Stack-Platz mehr übrig, um einen Handler auszuführen, Cleanup-Code abzuwickeln oder auch nur die Fehlermeldung sauber abzuschließen. Auf einem Worker-Thread, der nur die Standard-1-MB-Reservierung trägt, wobei ein Scratch-Puffer dieser Größe bereits das meiste davon aufgebraucht hat, bleibt der Laufzeitumgebung nichts mehr zum Arbeiten übrig

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;

Dieser except-Block sieht wie ein Sicherheitsnetz aus, und gegen die meisten Fehlschläge ist er eines, aber hier bewirkt er nichts. Das Team bestätigte das in der Praxis: try/except fing nichts ab, der finally-Block bekam ebenfalls nie zuverlässig die Chance zu laufen, und der Operator sah einen toten Prozess ganz ohne Log-Eintrag auf Anwendungsebene, genau das, was das ursprüngliche Support-Ticket beschrieb

Die Lösung: CRC32 in 64-KB-Häppchen statt einem riesigen Aufruf zuführen

Die Lösung, die HotXLS auslieferte, ändert nichts an zlib-ng selbst und nichts an der Kompressionsstufe, mit der die Arbeitsmappe geschrieben wird. ZLibCRC32 durchläuft die Eingabe jetzt in festen 64-KB-Häppchen, je 65536 Bytes, ruft zlib-ngs crc32 einmal pro Häppchen auf und reicht den laufenden Prüfsummenwert von einem Aufruf zum nächsten durch. CRC-32 ist konstruktionsbedingt ein inkrementeller Algorithmus, sodass eine über mehrere Häppchen aufgebaute Prüfsumme Bit für Bit identisch mit einer ist, die in einem einzigen Aufruf über dieselben Bytes berechnet wurde: Die Lösung ändert, wie die Arbeit aufgeteilt wird, nicht was sie berechnet

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;

Nichts am umgebenden SaveAs-Aufruf musste sich dafür ändern, und nichts an den ZIP-Einträgen, die HotXLS schreibt, änderte sich ebenfalls: Der CRC-32-Wert, der im lokalen Datei-Header und im zentralen Verzeichnis landet, ist genau der Wert, den ein einziger riesiger Aufruf erzeugt hätte, nur aus kleineren Stücken zusammengesetzt. zlib-ng herunterzustufen oder auf eine langsamere, allokationsarme CRC-32-Implementierung zurückzufallen hätte den Absturz ebenfalls vermieden, aber zu echten Kosten für jede Datei, die der Schwelle von vornherein nie nahekam, weshalb keines von beiden ausgeliefert wurde

Was das bedeutet, falls Sie zlib-ng aus Ihren eigenen Worker-Threads aufrufen

Der hier beschriebene Stack-Überlauf-Fehlermodus hat nichts speziell mit Tabellenkalkulationen zu tun. Jede Anwendung, die zlib-ng einen großen Puffer übergibt, sei es zur Kompression, Dekompression oder für eine Prüfsumme, aus einem Thread heraus, der nur den Plattform-Standard-Stack trägt, kann auf dieselbe Art Wand treffen, weil die Bibliothek ihren Algorithmus nach Eingabegröße wählt und einige dieser Algorithmen annehmen, dass Stack übrig ist. Zwei Verteidigungen funktionieren, ohne zlib-ng selbst anzufassen: große Puffer in festen Häppchen an größenempfindliche Routinen zu füttern entfernt die Auslösebedingung rundweg für jeden Algorithmus, der natürlich inkrementell ist, und wo Häppchenbildung keine Option ist, dem aufrufenden Thread einen größeren Stack als den Plattform-Standard zu geben ist der andere Hebel. Beides ist billiger, als von einer undokumentierten Größenschwelle über einen Produktions-Absturzbericht zu erfahren, der die falsche Funktion beschuldigt

Diese besondere Schwelle blieb unsichtbar, bis eine ausreichend große Produktions-Arbeitsmappe sie auf der falschen Art Thread überschritt, genau die Sorte Fehlschlag, die sich erst zeigt, sobald Code gegen echte Dateien statt kleine Testfixtures läuft. Der Chunked-CRC-32-Pfad ist jetzt Teil der Standard-Schreib-Pipeline in der HotXLS-Excel-Komponente für Delphi und C++Builder, ohne dass ein Aufrufer etwas konfigurieren müsste, und ohne Eigenschaft, die ihn ein- oder ausschaltet