Technischer Artikel

Gleichzeitiges ZIP-Inflate in Delphi: HotXLS Read Gate

HotXLS, die native Excel-Komponentenbibliothek für Delphi und C++Builder, inflatet mehrere XLSX-Arbeitsblätter gleichzeitig aus einem einzigen offenen ZIP-Paket. Der Mechanismus heißt TZipReadGate, eine kleine Klasse in lxZipArchive.pas, die den Paket-Stream plus einen kritischen Abschnitt hält und genau eine Methode bereitstellt. Sie serialisiert das Paar aus Seek und Read. Alles oberhalb dieses Paars läuft nebenläufig

Das Problem, das dieses Design erzwang, kennt jeder Delphi-Entwickler, der schon einmal eine große Arbeitsmappe geöffnet hat. Eine 80-MB-xlsx-Datei besteht aus 80 MB deflatetem XML, und die darin enthaltenen Arbeitsblatt-Teile expandieren etwa auf das Fünf- bis Zehnfache. Extrahiert der eigene Öffnungspfad jedes Arbeitsblatt vor dem Parsen in einen Speicher-Stream, zahlt man für die inflateten Bytes zusätzlich zu der Arbeitsmappe, die gerade aufgebaut wird, und das Maximum liegt an, bevor auch nur eine Zelle erzeugt wurde. Dieser Artikel behandelt die Nebenläufigkeit auf Paketebene, die diesen Zwischenschritt entfernt. Die darüberliegende Allocator-Obergrenze wird in dem Artikel zu parallelem XLSX-Parsing und dem Speicher-Manager behandelt, und die Read-once-, nie-materialisieren-API wird in der Durchsicht des Streaming Direct Reader behandelt

Warum staute der alte Öffnungspfad jedes Arbeitsblatt im RAM

Das ursprüngliche parallele Öffnen in HotXLS war eine dreiphasige Pipeline, und nur die mittlere Phase lief auf Worker-Threads. Phase A durchlief die Blattliste seriell, erstellte jedes Arbeitsblatt, las dessen Relationship-Teil und kopierte das gesamte inflatete Arbeitsblatt-XML in einen privaten TMemoryStream. Phase B fächerte ParseWorksheetXml über den Pool auf. Phase C ging auf dem aufrufenden Thread zurück zum Archiv für die kleinen Nebenteile: Kommentare, Threaded Comments, Zeichnungen, Diagramme, Tabellen. Diese Form wurde aus einem konkreten Grund gewählt. Der Kopfkommentar in lxParallelParse.pas besagte früher unmissverständlich, dass das ZIP-Archiv und sein Inflate-Status nicht thread-sicher sind, und die internen Notizen gingen weiter: Es lohnt sich nicht, das Archiv zu sperren, weil eine Sperre nichts bringt, sobald der Inflate-Zustandsautomat ohnehin pro Eintrag serialisiert ist. Phase A existierte, um jede Archivberührung auf einem Thread zu halten. Der Preis dafür war, dass eine Arbeitsmappe mit acht ausgelasteten Blättern gleichzeitig acht vollständig inflatete Arbeitsblatt-XML-Puffer im Speicher hielt, und diese Puffer sind die größten transienten Objekte im gesamten Öffnungspfad

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

Ja, und die alte Einschätzung war auf eine konkrete, auffindbare Weise falsch: Sie hat zwei unterschiedliche Zustandsarten in einem Satz zusammengefasst. Der Inflate-Status ist tatsächlich nicht gemeinsam nutzbar. Ein zlib-z_stream trägt das Sliding Window, die Huffman-Tabellen und die Bitposition für ein komprimiertes Mitglied, und wenn zwei Threads Bytes durch denselben schieben, entsteht Datenmüll. Die zugrunde liegende Byte-Quelle ist eine ganz andere Frage, und die Antwort dort lautet, dass ein File-Stream genau einen schützenswerten gemeinsamen veränderlichen Zustand hat, seinen Positionszeiger

Der ZIP-Container macht diese Trennung legal. Jedes Mitglied in einem ZIP-Archiv wird unabhängig komprimiert: mit eigenem lokalem Dateiheader, eigenem Deflate-Bitstream an eigenem DataOffset, eigenem CRC32 und eigenen Größenangaben im zentralen Verzeichnis. Es gibt kein über Mitglieder hinweg gemeinsames Wörterbuch, wie es ein solider 7z-Block hätte, sodass Eintrag N inflatet werden kann, ohne Eintrag M anzurühren. Gibt man jedem Worker seinen eigenen z_stream über seinen eigenen Byte-Bereich, kollidieren sie nur beim Seek. Genau diese Kollision beseitigt TZipReadGate, und die gesamte Klasse passt bequem auf einen Bildschirm

type
  TZipReadGate = class
  private
    FBaseStream: TStream;
    FLock: TRTLCriticalSection;
  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;
  EnterCriticalSection(FLock);
  try
    FBaseStream.Position := AOffset;
    Result := FBaseStream.Read(Buffer, Count);
  finally
    LeaveCriticalSection(FLock);
  end;
end;

Was TZipReadGate schützt und was bewusst nicht

TZipReadGate.ReadAt sichert genau eine unteilbare Operation ab, das Positionieren des gemeinsamen Streams und das Lesen daraus, und sonst nichts. TZipArchive.OpenArchive baut das Gate über FInputStream auf, sobald das zentrale Verzeichnis erfolgreich geparst wurde, und TZipArchive.Close gibt es wieder frei. Zum Schreiben geöffnete Archive erhalten nie eines. Jeder Lesevorgang, den ein Worker am Paket durchführt, läuft daher durch einen einzigen kritischen Abschnitt, der für die Dauer eines gepufferten Lesevorgangs gehalten wird

Alles Übrige bleibt außerhalb der Sperre, weil es bereits privat oder bereits unveränderlich ist. TZipSubStream führt seine eigene FPosition, sodass jeder Worker seine eigene Position in seinem eigenen Eintrag verfolgt. Der TZLibStream, den TZipEntry.GetStream über diesen Sub-Stream baut, ist pro Eintrag, mit windowBits von -15 für rohes Deflate erzeugt, und wird nie geteilt. Das zentrale Verzeichnis ist vollständig geparst, bevor irgendein Worker startet, einschließlich jedes lokalen Headers, sodass GetEntryByName zu Beginn der Nebenläufigkeit eine reine Lese-Hash-Lookup ist. Das Routing selbst besteht aus drei Zeilen in TZipSubStream.Read, und der Zweig ohne Gate ist es, der jeden bestehenden Single-Thread-Aufrufer auf dem alten Codepfad hält

function TZipSubStream.Read(var buffer; Count: longint): longint;
var
  rest: Int64;
  rc: longint;
begin
  rest := FSize - FPosition;
  if (Count > rest) then
    Count := rest;
  if FReadGate <> nil then
    rc := FReadGate.ReadAt(FOffset + FPosition, buffer, Count)
  else
  begin
    FBaseStream.Position := FOffset + FPosition;
    rc := FBaseStream.Read(buffer, Count);
  end;
  FPosition := FPosition + rc;
  Result := rc;
end;

Wie teuer ist das Gate bei Konkurrenz?

Weniger als der Ausdruck "globale Sperre auf dem Archiv" vermuten lässt, wegen der Granularität, die TZLibStream zufällig verwendet. Sein Eingabepuffer ist BufferSize, definiert als $4000, sodass ReadInputBuffer pro Nachfüllung 16 KB komprimierte Bytes zieht und an zng_inflate übergibt. Ein Sperren-Erwerb deckt also 16 KB Deflate-Eingabe ab, was für Arbeitsblatt-XML zu etwa 100 KB Markup expandiert, das der Worker anschließend dekodiert und parst, ohne irgendetwas zu halten. Die Sperre wird für einen positionierten Lesevorgang gegen den Cache des Betriebssystems gehalten; die dadurch abgesicherte Arbeit wird in Millisekunden gemessen

Die ehrliche Grenze liegt dort, wo sich dieses Verhältnis umkehrt. Nicht deflatete, sondern nur gespeicherte Einträge laufen eins zu eins durch das Gate, ohne dass Inflate-Arbeit die Latenz verdeckt, sodass ein Paket voller gespeicherter Mitglieder deutlich stärker serialisiert. Eine kalte Datei auf langsamem Medium weitet den kritischen Abschnitt aus, weil der Lesevorgang darin nun ein echter Plattenzugriff statt eines Cache-Treffers ist. Und ab einer Handvoll Worker ist ohnehin nicht das Gate der erste Engpass: Das Parsen von Arbeitsblättern ist allokationslastig, und der Delphi-Speicher-Manager serialisiert Allokationen über Threads hinweg schon, bevor das Read Gate zur Einschränkung wird. Deshalb hat TXLSXWorkbook.ParallelParseThreads standardmäßig eine automatische Obergrenze statt eines Threads pro Kern

Der Worker-Rumpf und die Drain-Schleife, die man leicht vergisst

Mit dem Gate an Ort und Stelle hat HotXLS die Staging-Phase A vollständig gestrichen. Der Worker öffnet nun seinen eigenen Eintrags-Stream und füttert ihn direkt an den Parser. Zwei transiente Felder tragen die Eingaben: FParZip hält das Archiv für die Dauer der parallelen Phase, FParSheetPartNames hält die Teilnamen, und beide werden im finally-Block geleert, damit kein veralteter Zeiger ein fehlgeschlagenes Öffnen überlebt. Der Stream, der von TZipArchive.OpenFile zurückkommt, ist ein TZipVerifiedStream, der einen TZLibStream umschließt, der wiederum einen TZipSubStream umschließt, und das Freigeben des äußeren gibt die ganze Kette frei

procedure TXLSXWorkbook.ParseSheetJob(AIndex: Integer);
var
  Stream: TStream;
  DrainBuffer: array [0..32767] of Byte;
  PartName: WideString;
begin
  PartName := WideString(FParSheetPartNames[AIndex]);
  Stream := FParZip.OpenFile(PartName);
  if Stream = nil then
    Exit;
  try
    ParseWorksheetXml(Stream, FSheets.ByPos[AIndex], FParSst,
      FParRels[AIndex], FParFontMap, FParFillMap, FParBorderMap,
      FParNumFmtMap, FParAlignMap, FParProtMap, FParDateMap);
    // Consume any trailing bytes so the ZIP entry size and CRC are verified.
    while Stream.Read(DrainBuffer, SizeOf(DrainBuffer)) > 0 do
      ;
  finally
    Stream.Free;
  end;
end;

Die Drain-Schleife ist das Detail, das eine 1:1-Portierung des alten Codes fallen lassen würde, und dieses stillschweigende Fallenlassen deaktiviert die Integritätsprüfung. TZipVerifiedStream akkumuliert eine laufende CRC32, während Bytes hindurchfließen, und ruft VerifyComplete erst auf, wenn seine Position die im zentralen Verzeichnis vermerkte unkomprimierte Größe erreicht; genau daher stammen die Größenabweichungs- und CRC32-Abweichungs-Exceptions, dazu ein einzelner Sondier-Lesevorgang von einem Byte, der einen längeren als deklarierten Eintrag erfasst. Ein XML-Reader stoppt am schließenden Element und lässt üblicherweise einen Zeilenumbruch oder ein paar Bytes nachfolgenden Leerraum ungelesen, sodass die Position ohne die Drain-Schleife die deklarierte Größe nie erreicht und die Prüfungen nie auslösen. Den Rest in einen Zwischenpuffer zu lesen kostet nichts und stellt sie wieder her. Als die Staging-Streams noch existierten, erledigte XlsxCopyStreamAll das zufällig mit

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

Phase A überlebt, minus die Extraktion. Sie erstellt weiterhin jedes Arbeitsblatt und liest dessen Relationships auf dem aufrufenden Thread, was dafür sorgt, dass jede gemeinsam genutzte Map unveränderlich ist, sobald die Worker starten. Phase C durchläuft die Blätter danach weiterhin seriell für Kommentare, Zeichnungen, Diagramme und Tabellen, und ihre Absicherung wechselte von einer Null-Prüfung auf das alte Staging-Array zu zip.Exists gegen den Teilnamen. Die gemeinsam genutzten, schreibgeschützten Eingaben, die die Worker berühren, die gemeinsame Zeichenkettentabelle und die cellXf-Maps, sind vollständig, bevor Phase B beginnt, und werden während Phase B nie beschrieben

var
  Wb: TXLSXWorkbook;
begin
  Wb := TXLSXWorkbook.Create;
  try
    Wb.ParallelParse := True;      // default; False forces one sheet at a time
    Wb.ParallelParseThreads := 4;  // 0 selects the automatic cap
    Wb.Open('quarterly-consolidation.xlsx');
    // ... workbook is identical either way ...
  finally
    Wb.Free;
  end;
end;

ParallelParse vor Open auf False zu setzen, stößt dieselbe Job-Prozedur mit einer Thread-Anzahl von eins an, und RunParallelJobs degeneriert zu einer schlichten Schleife auf dem aufrufenden Thread. Das ist aus zwei Gründen wissenswert: Es ist die einzeilige Antwort, falls jemals ein Threading-Problem im Feld auftaucht, und es bedeutet, dass sich serieller und paralleler Pfad einen einzigen Parsing-Codekörper teilen, statt auseinanderzulaufen. Worker-Exceptions werden aufgefangen, der niedrigste Job-Index gewinnt, und der Fehler wird auf dem aufrufenden Thread erneut ausgelöst, nachdem jeder Worker beigetreten ist, sodass ein defektes Arbeitsblatt weiterhin als eine einzige Exception an der erwarteten Stelle sichtbar wird. Allgemeines Tuning des umgebenden Öffnungspfads wird in dem Leitfaden zur Performance großer Arbeitsmappen in Delphi behandelt

Das Read Gate, die parallele Öffnungsphase und der hier beschriebene streamende Eintragszugriff sind Teil der Standardausstattung der HotXLS-Excel-Komponente für Delphi und C++Builder, mit vollständigem Quellcode; die Produktseite enthält die vollständige TXLSXWorkbook-Referenz einschließlich der Eigenschaften für paralleles Öffnen