Technisch artikel

Gelijktijdig ZIP Inflaten in Delphi: HotXLS Read Gate

Twee threads delen één TFileStream over een .xlsx-package. Thread A zet Position op de eerste gecomprimeerde byte van sheet1.xml, thread B zet hem een moment later op de eerste byte van sheet2.xml, en de Read van A geeft een buffer terug die aan het andere lid toebehoort. De inflater van A decodeert nu het midden van iemand anders zijn deflate-stream; zlib stopt meestal met Z_DATA_ERROR na een paar blokken, en als hij toevallig output produceert, zal de CRC32 van het lid niet matchen. Deflate is hier niet het probleem. De race zit volledig in de positiecursor van de stream, en één lock rond het seek-en-read-paar is genoeg om veel workers tegelijk leden van hetzelfde package te laten inflaten

De reden dat iemand dat wil is geheugen. Stel dat een werkmap 80 MB op schijf is en zijn werkblad-XML vijf tot tien keer uitdijt bij het inflaten. Een openpad dat elk werkblad in een buffer extraheert voordat hij het parseert, betaalt die geïnflateerde bytes bovenop de werkmap die hij aan het bouwen is, en de piek komt voordat er één enkele cel bestaat. Dit artikel behandelt de concurrency op package-niveau die de extractiestap wegneemt. Het allocatoreplafon erboven staat in het artikel over parallelle XLSX-parsing en de geheugenbeheerder, en de read-once-API die helemaal geen werkmap bouwt staat in de walkthrough van de streaming direct reader

Het staging-ontwerp, en wat het in geheugen houdt

De voorzichtige manier om een XLSX-open te parallelliseren is een driefasenpijplijn waarbij alleen de middelste fase op workers draait. Fase A loopt de bladlijst af op de aanroepende thread, maakt elk werkblad aan, leest zijn relationship-part en kopieert de hele geïnflateerde werkblad-XML in een privé-TMemoryStream. Fase B waaiert de XML-parsing uit over een threadpool. Fase C keert terug naar het archief, nog steeds op de aanroepende thread, voor de kleine satellietdelen: commentaar, threaded comments, drawings, grafieken en tabellen

Het argument voor die vorm klinkt waterdicht. Een ZIP-archief en zijn inflatetoestand zijn niet thread-safe, dus houd elke archiefaanraking op één thread en sla locking helemaal over. De rekening is een werkmap met acht drukke bladen die op hetzelfde moment acht volledig geïnflateerde werkbladbuffers vasthoudt, en die buffers zijn de grootste tijdelijke objecten waar ook in het openpad. Met het hierboven veronderstelde package van 80 MB is dat een paar honderd megabyte XML-tekst die alleen bestaat zodat een parser hem één keer kan lezen

Diagram van een gestadieerde XLSX-openroute in Delphi waarbij Fase A elke geïnflateerde werkblad-XML in het geheugen kopieert voordat Fase B de bladen parallel parseert
Fase A kopieert elk geïnflateerd werkblad in een privé-memorystream op één thread, dus een werkmap met acht drukke bladen houdt op de piek acht grote buffers vast

HotXLS houdt fase A en C aan maar laat de kopie vallen. Elke worker in fase B opent zijn eigen entrystream op het gedeelde package en geeft hem rechtstreeks aan de XML-lezer, dus het geïnflateerde werkblad bestaat nooit als één blok. Dat kan alleen omdat het argument "niet thread-safe" over twee verschillende dingen blijkt te gaan

Kunnen twee threads van één ZIP-stream inflaten?

Ja, mits elke thread zijn eigen inflater bezit en de gedeelde bytebron op de read wordt geserialiseerd. Het voorzichtige argument vouwt twee soorten toestand in één zin. Inflatetoestand kan echt niet worden gedeeld: een zlib-z_stream draagt het schuifvenster van 32 KB dat RFC 1951 definieert, de actuele Huffman-tabellen en de bitpositie binnen één gecomprimeerd lid, en twee threads die bytes door dezelfde duwen produceren rommel. De bytebron is iets anders. Een bestandsstream heeft precies één stuk muteerbare toestand dat bescherming waard is: zijn positie

De ZIP-container maakt de splitsing legaal. PKWARE's APPNOTE (sectie 4.3) legt een package neer als onafhankelijke leden, elk met zijn eigen local file header, zijn eigen gecomprimeerde data op zijn eigen offset, en zijn eigen CRC32 en groottes opgetekend in de central directory. Geen enkel dictionary overbrugt leden zoals een solid 7z-blok dat doet, dus entry N kan worden geïnflateerd zonder één byte van entry M te lezen. Geef elke worker een privé-inflater over zijn eigen bytebereik en de enige botsing die overblijft is de seek. In HotXLS wordt die botsing afgehandeld door TZipReadGate in lxZipArchive.pas, en de hele class past op één scherm

type
  TZipReadGate = class
  private
    FBaseStream: TStream;
    FLock: TXLSCriticalSection;   // lxPlatform-wrapper over een 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;

Wat de read gate lockt, en wat buiten hem blijft

TZipReadGate.ReadAt bewaakt één ondeelbare operatie: de gedeelde stream positioneren en eruit lezen. TZipArchive.OpenArchive bouwt de gate zodra de central directory is geparseerd, voor zowel de bestandsnaam- als de TStream-overload, en het vrijgeven van het archief geeft hem vrij. Archieven die voor schrijven zijn gemaakt krijgen er nooit een, want niemand leest er gelijktijdig uit

Alles andere blijft buiten de lock omdat het al privé of al bevroren is. Elke entrystream die TZipArchive.OpenFile teruggeeft houdt zijn eigen positie binnen zijn eigen lid bij en zet die pas om naar een absolute package-offset op het moment dat hij ReadAt aanroept. De inflater eromheen wordt per entry aangemaakt met een venstergrootte van -15, wat zlib leest als rauwe deflate zonder zlib-header, en wordt nooit aan een andere thread gegeven. De central directory, local headers incluis, is volledig geparseerd voordat een worker start, dus een entry op naam opzoeken is een read-only hash-opzoek tegen de tijd dat de concurrency begint. Opzoekingen zijn hoofdletterongevoelig en weigeren namen met ..-segmenten

Architectuurdiagram van de HotXLS read gate in Delphi waarbij gelijktijdige werkbladworkers privé-bytebereiken inflaten terwijl één critical section alleen de gedeelde stream seek en read serialiseert
Elke worker bezit zijn sub-stream-positie en z_stream, dus de enige gedeelde toestand is de packagecursor en TZipReadGate houdt de lock voor één enkele gepositioneerde read

Eén consequentie telt als u TXLSXWorkbook.Open(Stream) aanroept met uw eigen stream. De gate wordt om die stream gewikkeld, en zijn lock coördineert de workers van de bibliotheek met elkaar. Hij weet niets van uw code. Als een andere thread in uw applicatie dezelfde TStream leest of verplaatst terwijl Open draait, is de cursorrace uit de eerste alinea terug, dus geef de stream uit handen en laat hem met rust tot Open terugkomt

Hoeveel kost één lock onder contention?

Minder dan "een globale lock op het archief" doet vermoeden, vanwege hoeveel werk elke acquisitie dekt. De inflater vult zijn invoer aan in chunks van 64 KB gecomprimeerde bytes, dus één trip door de gate voedt een worker met 64 KB deflate-data die hij daarna decodeert en parseert zonder iets vast te houden. Bij de eerder veronderstelde uitbreiding van vijf tot tien keer wordt die aanvulling ruwweg 320 tot 640 KB werkblad-opmaak. De lock zelf dekt een gepositioneerde read die meestal uit de bestandscache van het besturingssysteem wordt geserveerd

De verhouding keert om op drie plekken, allemaal over de invoer of de machine. Leden die zonder compressie zijn opgeslagen gaan per read-aanroep één keer door de gate zonder inflatiewerk tussen de acquisities; de meeste schrijvers deflaten werkbladonderdelen, maar een package van een tool die alles opslaat serialiseert harder. Een koud bestand op trage of netwerkopslag verbreedt de critical section, want de read erin wordt een echte overdracht. En boven een handjevol workers is de gate toch niet de eerste muur: werkbladparsing alloceert zwaar, en de Delphi-geheugenbeheerder serialiseert toewijzingen over threads heen ruim voordat de read gate verzadigt

Dat laatste punt beslist het threadaantal. TXLSXWorkbook.ParallelParseThreads staat standaard op 0, wat één worker per logische processor start, nooit meer dan het aantal bladen. Op een laptop met vier tot acht logische processoren is die standaard prima. Op een buildserver met 32 threads die werkmappen met tientallen bladen opent, zet een expliciet klein plafon zoals 4: de extra workers staan vooral in de rij bij de allocator en elk houdt zijn eigen inflater en buffers vast. Om het juiste getal voor uw bestanden te vinden, open één representatieve werkmap in een lus met de property op 1, 2, 4 en 8, en vergelijk wandkloktijd met piek-working set in Task Manager of Process Explorer

Een entry tot het einde lezen is wat de CRC-controle laat afgaan

Elke entrystream die het archief teruggeeft controleert zichzelf, maar alleen aan het einde. Hij houdt een lopende CRC32 bij terwijl bytes passeren, en als zijn positie de ongecomprimeerde grootte uit de central directory bereikt, vergelijkt hij beide waarden en peilt hij één extra byte. De fouten komen naar boven als exceptions met letterlijke berichten: 'ZIP entry uncompressed size mismatch', 'ZIP entry CRC32 mismatch', 'ZIP entry exceeds its declared size', en 'ZIP entry ended before its declared size' voor een lid dat vroeg opraakt

Een XML-lezer stopt bij de afsluitende </worksheet>-tag. Alles erna, vaak alleen een afsluitende newline, blijft ongelezen, de positie bereikt nooit de opgegeven grootte en de vergelijking draait nooit. Het staging-ontwerp kreeg de controle gratis, want een entry in een buffer kopiëren leest elke byte. Een parser die de entrystream rechtstreeks leest niet, dus elke HotXLS-worker ledigt wat de parser achterlaat met een scratch-buffer van 32 KB voordat hij de stream vrijgeeft, en het vrijgeven van de buitenste stream geeft de inflater en de sub-stream eronder vrij

Diagram van de HotXLS-worker-streamketen in Delphi van het gedeelde package via de read gate naar de geverifieerde stream, met de drain-lus die de size- en CRC32-controles laat afgaan
De drain-lus duwt de positie naar de opgegeven entrygrootte zodat de size- en CRC32-vergelijking kan draaien, en vangt corruptie die een XML-lezer ongelezen zou laten

Dezelfde regel geldt als uw eigen code TZipArchive uit lxZipArchive gebruikt, bijvoorbeeld om een binnenkomend package te sanity-checken voordat het naar de werkmaploader gaat. Een functie die alleen de eerste paar kilobytes van elk onderdeel leest om zijn rootelement te snuffelen bewijst niets over integriteit. Lees tot het einde:

uses
  Classes, SysUtils, lxZipArchive;

// Leest één package-onderdeel tot zijn opgegeven einde zodat de size- en CRC32-controles draaien.
// PartIsIntact is een hulpfunctie die u zelf schrijft; hij hoort niet bij de bibliotheek
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 als het onderdeel niet bestaat
  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;          // bijv. '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;

De tweede valkuil in die code is het terugspoelen. Entrystreams zijn alleen vooruit: Size lezen werkt, want de opgegeven grootte staat vooraf vast, maar na een kijkje Position := 0 zetten gooit 'ZIP entry stream is forward-only'. Als een component per se wil seeken, open de entry dan een tweede keer met OpenFile, wat goedkoop is en u een verse stream op offset nul geeft. Verschillende threads mogen elk OpenFile aanroepen op één archief dat voor lezen is geopend, want dat is precies wat de werkmaploader doet, zolang niets het archief vrijgeeft terwijl hun streams leven

Wat nog serieel draait, en de schakelaar die het uit zet

Fase A draait nog steeds, minus de extractie. Hij maakt elk werkblad aan en leest zijn relationships op de aanroepende thread, wat elke gedeelde map onveranderlijk laat zodra workers starten. De shared string table en de celindelingstabellen zijn compleet voordat fase B begint en worden er niet in geschreven. Fase C loopt daarna nog steeds de bladen in volgorde af voor commentaar, drawings, grafieken en tabellen, en slaat elk blad over waarvan het onderdeel ontbreekt in het package

uses
  SysUtils, lxHandleX;

var
  Wb: TXLSXWorkbook;
begin
  Wb := TXLSXWorkbook.Create;
  try
    Wb.ParallelParse := True;        // standaard; False parseert één blad per keer
    Wb.ParallelParseThreads := 4;    // 0 = één worker per logische processor
    Wb.SelectedSheets.Add('Q3 Detail');  // optioneel: cellen alleen voor dit blad parsen
    if Wb.Open('quarterly-consolidation.xlsx') <> 1 then
      raise Exception.Create('Workbook open failed');
    // De geladen werkmap is identiek, welk pad hem ook parseerde
  finally
    Wb.Free;
  end;
end;

ParallelParse op False zetten vóór Open draait dezelfde per-blad-taak met een threadaantal van één, en de pool degeneert tot een kale lus op de aanroepende thread. Een werkmap met één werkblad neemt dat pad hoe dan ook. Houd de schakelaar in gedachten bij het diagnosticeren van een openprobleem: hij haalt threading in één regel uit beeld, en beide paden draaien dezelfde parsingcode, dus de werkmap die u terugkrijgt hangt niet van de instelling af

Fouten houden ook de seriële vorm. Als een worker gooit, worden er geen verdere bladen uitgezonden, joinen alle workers en wordt de exception van de laagste bladindex opnieuw gegooid op de aanroepende thread, dus een corrupt sheet3.xml komt nog steeds naar boven als één exception uit Open. Met RecoveryMode op True wordt een werkblad dat niet parseert overgeslagen en als waarschuwing opgetekend in de werkmapdiagnostiek. Bladen die buiten SelectedSheets vallen openen hun entrystream helemaal niet, en MetadataOnly slaat de parallelle fase helemaal over. Bredere afstelling van het openpad staat in de gids voor prestaties bij grote werkmappen in Delphi

De read gate, de parallelle openfase en de entryniveaubottoeg tot ZIP die hier worden getoond, maken deel uit van het HotXLS Delphi Excel component voor Delphi en C++Builder, dat met volledige bron wordt geleverd, zodat u lxZipArchive.pas kunt lezen naast de TXLSXWorkbook-openproperties die op de productpagina worden beschreven