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
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
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
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