Technischer Artikel

Gleichzeitiges ZIP-Inflate in Delphi: HotXLS Read Gate

Zwei Threads teilen sich einen TFileStream über ein .xlsx-Paket. Thread A setzt Position auf das erste komprimierte Byte von sheet1.xml, Thread B setzt sie einen Moment später auf das erste Byte von sheet2.xml, und der Read von A liefert einen Puffer, der zum anderen Mitglied gehört. Der Inflater von A dekodiert jetzt die Mitte des Deflate-Streams eines anderen; zlib bricht meist ein paar Blöcke später mit Z_DATA_ERROR ab, und falls doch etwas herauskommt, passt die CRC32 des Mitglieds nicht. Deflate ist hier nicht das Problem. Das Rennen läuft vollständig über den Positionszeiger des Streams, und eine einzige Sperre um das Seek-and-Read-Paar genügt, um viele Worker gleichzeitig Mitglieder desselben Pakets inflaten zu lassen

Der Grund für das Ganze ist der Speicher. Angenommen, eine Arbeitsmappe ist 80 MB groß auf der Platte und ihre Arbeitsblatt-XML expandiert beim Inflaten um das Fünf- bis Zehnfache. Ein Öffnungspfad, der jedes Arbeitsblatt vor dem Parsen in einen Puffer extrahiert, zahlt diese inflateten Bytes zusätzlich zu der Arbeitsmappe, die er gerade aufbaut, und der Peak liegt, bevor auch nur eine Zelle existiert. Dieser Artikel behandelt die Nebenläufigkeit auf Paketebene, die den Extraktionsschritt entfernt. Die darüberliegende Allokator-Obergrenze behandelt der Artikel zu parallelem XLSX-Parsing und dem Speicher-Manager, und die Read-once-API, die gar keine Arbeitsmappe aufbaut, steht in der Durchsicht des Streaming Direct Reader

Das Staging-Design und was es im Speicher hält

Der vorsichtige Weg, ein XLSX-Öffnen zu parallelisieren, ist eine dreiphasige Pipeline, in der nur die mittlere Phase auf Workern läuft. Phase A geht die Blattliste auf dem aufrufenden Thread durch, erstellt jedes Arbeitsblatt, liest seinen Relationship-Teil und kopiert die gesamte inflatete Arbeitsblatt-XML in einen privaten TMemoryStream. Phase B verteilt das XML-Parsing über einen Thread-Pool. Phase C kehrt auf dem aufrufenden Thread ins Archiv zurück, für die kleinen Satellitenteile: Kommentare, Threaded Comments, Zeichnungen, Diagramme und Tabellen

Das Argument für diese Form klingt wasserdicht. Ein ZIP-Archiv und sein Inflate-Zustand sind nicht threadsicher, also fasst man das Archiv auf einem Thread an und spart sich die Sperre ganz. Die Rechnung ist eine Arbeitsmappe mit acht beschäftigten Blättern, die im selben Moment acht vollständig inflatete Arbeitsblatt-Puffer hält, und diese Puffer sind die größten transienten Objekte im gesamten Öffnungspfad. Beim oben angenommenen 80-MB-Paket sind das einige hundert Megabyte XML-Text, die nur existieren, damit ein Parser sie einmal lesen kann

Diagramm eines gestuften XLSX-Öffnungspfads in Delphi, bei dem Phase A jede inflatete Arbeitsblatt-XML in den Speicher kopiert, bevor Phase B die Blätter parallel parst
Phase A kopiert jedes inflatete Arbeitsblatt in einen privaten Memory-Stream auf einem Thread, sodass eine Arbeitsmappe mit acht beschäftigten Blättern acht große Puffer im Peak hält

HotXLS behält Phase A und C, streicht aber die Kopie. Jeder Worker in Phase B öffnet seinen eigenen Eintrags-Stream auf dem gemeinsamen Paket und reicht ihn direkt an den XML-Leser weiter, das inflatete Arbeitsblatt existiert also nie als ein Block. Das funktioniert nur, weil sich das "nicht threadsicher"-Argument als Aussage über zwei verschiedene Dinge entpuppt

Können zwei Threads aus einem ZIP-Stream inflaten?

Ja, sofern jeder Thread seinen eigenen Inflater besitzt und die gemeinsame Byte-Quelle beim Lesen serialisiert wird. Das vorsichtige Argument faltete zwei Zustandsarten in einen Satz. Der Inflate-Zustand ist tatsächlich nicht gemeinsam nutzbar: Ein zlib-z_stream trägt das 32-KB-Sliding Window, das RFC 1951 definiert, die aktuellen Huffman-Tabellen und die Bitposition innerhalb eines komprimierten Mitglieds, und zwei Threads, die Bytes durch denselben schieben, produzieren Datenmüll. Die Byte-Quelle ist eine andere Sache. Ein File-Stream hat genau einen schützenswerten veränderlichen Zustand, seine Position

Der ZIP-Container macht diese Trennung legal. PKWAREs APPNOTE (Abschnitt 4.3) legt ein Paket als unabhängige Mitglieder an, jedes mit eigenem lokalem Dateiheader, eigenen komprimierten Daten an eigenem Offset und eigenem CRC32 samt Größen im zentralen Verzeichnis. Kein Wörterbuch spannt sich über Mitglieder hinweg wie bei einem soliden 7z-Block, Eintrag N lässt sich also inflaten, ohne ein Byte von Eintrag M zu lesen. Gibt man jedem Worker einen privaten Inflater über seinen eigenen Byte-Bereich, bleibt als einzige Kollision der Seek. In HotXLS fängt TZipReadGate in lxZipArchive.pas diese Kollision ab, und die gesamte Klasse passt auf einen Bildschirm

type
  TZipReadGate = class
  private
    FBaseStream: TStream;
    FLock: TXLSCriticalSection;   // lxPlatform-Wrapper über eine Critical Section
  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;
  XLSEnterCriticalSection(FLock);
  try
    FBaseStream.Position := AOffset;
    Result := FBaseStream.Read(Buffer, Count);
  finally
    XLSLeaveCriticalSection(FLock);
  end;
end;

Was das Read Gate sperrt, und was außerhalb bleibt

TZipReadGate.ReadAt sichert genau eine unteilbare Operation ab, das Positionieren des gemeinsamen Streams und das Lesen daraus. TZipArchive.OpenArchive baut das Gate auf, sobald das zentrale Verzeichnis geparst ist, bei der Dateinamen- wie bei der TStream-Überladung, und das Freigeben des Archivs gibt es wieder frei. Zum Schreiben angelegte Archive erhalten nie eines, denn nichts liest aus ihnen gleichzeitig

Alles Übrige bleibt außerhalb der Sperre, weil es bereits privat oder bereits eingefroren ist. Jeder Eintrags-Stream, den TZipArchive.OpenFile zurückgibt, führt seine eigene Position innerhalb seines eigenen Mitglieds und rechnet sie erst in dem Moment in einen absoluten Paket-Offset um, in dem er ReadAt aufruft. Der Inflater darum ist pro Eintrag mit einer Fenstergröße von -15 erzeugt, was zlib als rohes Deflate ohne zlib-Header liest, und wird nie an einen anderen Thread weitergereicht. Das zentrale Verzeichnis einschließlich der lokalen Header ist vollständig geparst, bevor irgendein Worker startet, ein Eintrag ist nach Namen also bereits zu Beginn der Nebenläufigkeit ein rein lesender Hash-Lookup. Lookups sind case-insensitive und weisen Namen mit ..-Segmenten zurück

Architekturdiagramm des HotXLS-Lesetors in Delphi: gleichzeitige Arbeitsblatt-Worker blähen private Bytebereiche auf, während eine einzige Critical Section nur den gemeinsamen Stream-Seek und -Lesezugriff serialisiert
Jeder Worker besitzt seine Sub-Stream-Position und sein z_stream, daher ist der einzige geteilte Zustand der Paket-Cursor, und TZipReadGate hält die Sperre für eine einzige positionierte Lesung

Eine Folge davon wird relevant, wenn Sie TXLSXWorkbook.Open(Stream) mit Ihrem eigenen Stream aufrufen. Das Gate wird um diesen Stream gelegt, und seine Sperre koordiniert die Worker der Bibliothek untereinander. Von Ihrem Code weiß es nichts. Liest oder repositioniert ein anderer Thread Ihrer Anwendung denselben TStream, während Open läuft, ist das Cursor-Rennen aus dem ersten Absatz zurück; geben Sie den Stream also her und fassen Sie ihn an, bis Open zurückkehrt

Wie teuer ist das Gate bei Konkurrenz?

Weniger als "eine globale Sperre auf dem Archiv" vermuten lässt, denn jeder Sperren-Erwerb deckt eine Menge Arbeit ab. Der Inflater füllt seinen Eingabepuffer in 64-KB-Häppchen komprimierter Bytes nach, ein Gang durch das Gate füttert also 64 KB Deflate-Daten an einen Worker, der sie danach dekodiert und parst, ohne etwas zu halten. Bei der oben angenommenen Fünf- bis Zehnfach-Expansion werden aus dieser Nachfüllung rund 320 bis 640 KB Arbeitsblatt-Markup. Die Sperre selbst umspannt einen positionierten Lesevorgang, der meist aus dem Datei-Cache des Betriebssystems bedient wird

Das Verhältnis kippt an drei Stellen, und alle betreffen die Eingabe oder die Maschine. Nur gespeicherte, nicht deflatete Mitglieder laufen pro Read-Aufruf einmal durch das Gate, ohne dass Inflate-Arbeit die Erwerbe überlagert; die meisten Writer deflaten Arbeitsblatt-Teile, aber ein Paket aus einem Tool, das alles speichert, serialisiert stärker. Eine kalte Datei auf langsamem oder Netzwerk-Speicher weitet den kritischen Abschnitt, weil der Lesevorgang darin zu einer echten Übertragung wird. Und ab einer Handvoll Worker ist das Gate ohnehin nicht die erste Wand: Das Arbeitsblatt-Parsing allokaliert kräftig, und der Delphi-Speicher-Manager serialisiert Allokationen über Threads hinweg, lange bevor das Read Gate gesättigt ist

Dieser letzte Punkt entscheidet die Thread-Anzahl. TXLSXWorkbook.ParallelParseThreads steht standardmäßig auf 0, was einen Worker pro logischem Prozessor startet, nie mehr als die Anzahl der Blätter. Auf einem Laptop mit vier bis acht logischen Prozessoren ist dieser Standard in Ordnung. Auf einem 32-Thread-Buildserver, der Arbeitsmappen mit Dutzenden Blättern öffnet, setzt man eine explizite kleine Obergrenze wie 4: Die zusätzlichen Worker stehen meist beim Allokator Schlange, und jeder hält seinen eigenen Inflater und seine eigenen Puffer. Um die richtige Zahl für Ihre Dateien zu finden, öffnen Sie eine repräsentative Arbeitsmappe in einer Schleife mit der Eigenschaft auf 1, 2, 4 und 8, und vergleichen Sie die Wandzeit gegen das Peak-Working-Set im Task-Manager oder Process Explorer

Erst das Lesen bis zum Ende löst die CRC-Prüfung aus

Jeder Eintrags-Stream, den das Archiv zurückgibt, prüft sich selbst, aber nur am Ende. Er akkumuliert eine laufende CRC32, während Bytes durchlaufen, und wenn seine Position die unkomprimierte Größe aus dem zentralen Verzeichnis erreicht, vergleicht er beide Werte und tastet nach einem zusätzlichen Byte. Die Fehler kommen als Exceptions mit wörtlichen Meldungen heraus: 'ZIP entry uncompressed size mismatch', 'ZIP entry CRC32 mismatch', 'ZIP entry exceeds its declared size' und 'ZIP entry ended before its declared size' für ein Mitglied, das früher ausgeht

Ein XML-Leser hält am schließenden </worksheet>-Tag an. Alles danach, oft nur ein Zeilenumbruch, bleibt ungelesen, die Position erreicht nie die deklarierte Größe, und der Vergleich läuft nie. Das Staging-Design bekam diese Prüfung geschenkt, denn das Kopieren eines Eintrags in einen Puffer liest jedes Byte. Ein Parser, der den Eintrags-Stream direkt liest, bekommt sie nicht geschenkt, also leert jeder HotXLS-Worker, was der Parser übrig lässt, mit einem 32-KB-Scratch-Puffer, bevor er den Stream freigibt, und das Freigeben des äußersten Streams gibt den Inflater und den Sub-Stream darunter frei

Diagramm der HotXLS-Worker-Stream-Kette in Delphi: vom gemeinsamen Paket über den Lesetor zum verifizierten Stream, mit der Abfluss-Schleife, die die Größen- und CRC32-Prüfungen auslösen lässt
Die Drain-Schleife schiebt die Position bis zur deklarierten Eintragsgröße, sodass VerifyComplete die CRC32 vergleichen kann und eine Verderbnis fängt, die ein XML-Leser stillschweigend ungelesen ließe

Dieselbe Regel gilt, wenn Ihr eigener Code TZipArchive aus lxZipArchive verwendet, etwa um ein eingehendes Paket zu prüfen, bevor es an den Arbeitsmappen-Loader geht. Eine Funktion, die von jedem Teil nur die ersten paar Kilobytes liest, um das Wurzelelement zu erschnüffeln, beweist nichts über die Integrität. Bis zum Ende lesen:

uses
  Classes, SysUtils, lxZipArchive;

// Liest einen Paketteil bis zu seinem deklarierten Ende, damit die Größen- und CRC32-Prüfungen laufen.
// PartIsIntact ist eine selbst geschriebene Hilfsroutine; sie ist nicht Teil der Bibliothek
function PartIsIntact(Zip: TZipArchive; const PartName: WideString;
  out Problem: string): Boolean;
var
  Stream: TStream;
  Buffer: array [0..32767] of Byte;
begin
  Result := False;
  Problem := '';
  Stream := Zip.OpenFile(PartName);   // nil, wenn der Teil nicht existiert
  if Stream = nil then
  begin
    Problem := 'part not found';
    Exit;
  end;
  try
    try
      while Stream.Read(Buffer, SizeOf(Buffer)) > 0 do
        ;
      Result := True;
    except
      on E: Exception do
        Problem := E.Message;          // z. B. 'ZIP entry CRC32 mismatch'
    end;
  finally
    Stream.Free;
  end;
end;

var
  Zip: TZipArchive;
  Problem: string;
begin
  Zip := TZipArchive.Create;
  try
    Zip.OpenArchive('quarterly-consolidation.xlsx');
    if not PartIsIntact(Zip, 'xl/worksheets/sheet1.xml', Problem) then
      Writeln('sheet1.xml: ', Problem);
  finally
    Zip.Free;
  end;
end;

Die zweite Falle in dem Code ist das Zurückspulen. Eintrags-Streams sind nur vorwärts lesbar: Size zu lesen funktioniert, weil die deklarierte Größe von vorn bekannt ist, aber Position := 0 nach einem vorauseilenden Lesezugriff löst 'ZIP entry stream is forward-only' aus. Besteht eine Komponente auf Seeken, öffnet man den Eintrag mit OpenFile ein zweites Mal, was billig ist und einen frischen Stream an Offset null liefert. Mehrere Threads dürfen OpenFile jeweils an einem zum Lesen geöffneten Archiv aufrufen, genau das tut der Arbeitsmappen-Loader, solange nichts das Archiv freigibt, während deren Streams leben

Was weiterhin seriell läuft, und der Schalter, der alles abschaltet

Phase A läuft weiterhin, minus der Extraktion. Sie erstellt jedes Arbeitsblatt und liest seine Relationships auf dem aufrufenden Thread, was dafür sorgt, dass jede geteilte Map ab dem Start der Worker unveränderlich ist. Die gemeinsame String-Tabelle und die Zellformat-Tabellen sind vollständig, bevor Phase B beginnt, und werden währenddessen nie beschrieben. Phase C geht danach weiterhin der Reihe nach durch die Blätter, für Kommentare, Zeichnungen, Diagramme und Tabellen, und überspringt jedes Blatt, dessen Teil im Paket fehlt

uses
  SysUtils, lxHandleX;

var
  Wb: TXLSXWorkbook;
begin
  Wb := TXLSXWorkbook.Create;
  try
    Wb.ParallelParse := True;        // Standard; False parst ein Blatt nach dem anderen
    Wb.ParallelParseThreads := 4;    // 0 = ein Worker pro logischem Prozessor
    Wb.SelectedSheets.Add('Q3 Detail');  // optional: Zellen nur für dieses Blatt parsen
    if Wb.Open('quarterly-consolidation.xlsx') <> 1 then
      raise Exception.Create('Workbook open failed');
    // Die geladene Arbeitsmappe ist identisch, egal welcher Pfad sie geparst hat
  finally
    Wb.Free;
  end;
end;

ParallelParse vor Open auf False zu setzen stößt denselben Pro-Blatt-Job mit einer Thread-Anzahl von eins an, und der Pool degeneriert zu einer schlichten Schleife auf dem aufrufenden Thread. Eine Arbeitsmappe mit einem einzigen Arbeitsblatt nimmt diesen Weg ohnehin. Den Schalter im Kopf zu behalten hilft bei der Fehlersuche an einem Öffnungsproblem: Er nimmt das Threading in einer Zeile aus der Gleichung, und beide Pfade fahren denselben Parsing-Code, die Arbeitsmappe, die zurückkommt, hängt also nicht von der Einstellung ab

Fehler behalten ebenfalls die serielle Form. Wenn ein Worker eine Exception auslöst, werden keine weiteren Blätter verteilt, es wird auf alle Worker gewartet, und die Exception vom niedrigsten Blattindex wird auf dem aufrufenden Thread erneut ausgelöst, sodass ein korruptes sheet3.xml weiterhin als eine einzige Exception aus Open heraus sichtbar wird. Mit RecoveryMode auf True wird ein Arbeitsblatt, das sich nicht parsen lässt, stattdessen übersprungen und als Warnung in der Arbeitsmappen-Diagnostik vermerkt. Blätter, die nicht in SelectedSheets stehen, öffnen ihren Eintrags-Stream gar nicht erst, und MetadataOnly überspringt die parallele Phase komplett. Das breitere Tuning des Öffnungspfads behandelt der Leitfaden zur Performance großer Arbeitsmappen in Delphi

Das Read Gate, die parallele Öffnungsphase und der hier gezeigte Eintrags-Zugriff auf ZIP-Ebene sind Teil der HotXLS Delphi Excel-Komponente für Delphi und C++Builder, die mit vollständigem Quellcode kommt, sodass Sie lxZipArchive.pas lesen können, neben den TXLSXWorkbook-Öffnungseigenschaften, die die Produktseite beschreibt