Technischer Artikel

HotXLS Read Leases und Write Guards für Delphi-Workbooks

Ein Hintergrund-Thread exportierte einen 40.000-Zeilen-Bericht, als der UI-Thread eine Zelle setzte, und die Datei, die auf der Platte landete, entsprach keiner Arbeitsmappe, die je existiert hatte. HotXLS behandelt diese Bug-Klasse in lxWorkbookView.pas, wo IXLSWorkbookViewCore O(1)-Read-Leases und Fail-fast-Write-Guards ausstellt: Solange ein Lease offen ist, wirft jeder Mutations-Einsprungpunkt, statt zu schreiben

Das Versagen, das ohne Stack Trace ankommt

Eine Arbeitsmappe zu lesen ist nie eine atomare Operation. Ein Berichtsdurchlauf sind Zehntausende einzelne Zelllesevorgänge verteilt über Sekunden, und ein einzelnes SetValue, das zwischen zweien davon landet, reicht, um zu verändern, was der Rest des Durchlaufs sieht. Die classic Engine macht das konkret: TXLSCellRef.SetValue kann FSST.Remove aufrufen, um einen Shared-String-Eintrag zu entfernen, FValueType zurücksetzen und einen Formel-Cache-State invalidieren — alles, während ein anderer Thread mittendrin ist, genau diese Strukturen zu dereferenzieren. Nichts crasht auf der Stelle. Sie bekommen einen Bericht, dessen Zwischensummen nicht aufgehen, oder einen Export, der stillschweigend einen String-Index liest, der jetzt woandershin zeigt

HotXLS löst das bewusst nicht, indem es Writer warten lässt. Ein Reader kann eine Arbeitsmappe mehrere Sekunden halten, und in einer VCL-Anwendung ist der Writer oft ein UI-Callback oder ein Event-Handler im Haupt-Thread — diesen Thread zu blockieren, bis ein Hintergrund-Export fertig ist, ist ein schlechteres Ergebnis als die Bearbeitung scheitern zu lassen. Also wirft der Koordinationskern EXLSWorkbookWriteGuardUnavailable in dem Moment, in dem ein Schreibversuch gegen einen offenen Lease unternommen wird, bevor ein einziges Feld berührt wurde, und der Aufrufer entscheidet, ob er die Bearbeitung einreiht, es erneut versucht oder dem Nutzer Bescheid gibt. Fail-fast-Konflikte, nicht eingereihte

Eine HotXLS-Koordinationsmatrix, die zeigt, dass Read-Leases frei koexistieren, dass ein Schreibversuch gegen einen offenen Lease EXLSWorkbookWriteGuardUnavailable wirft, dass ein Lease innerhalb einer Write-Transaction EXLSWorkbookReadLeaseUnavailable wirft, und dass zwei Writer-Threads einander nie ausschließen
Reader koexistieren, und Writer scheitern fail-fast an ihnen, aber der Kern schließt nie einen Writer-Thread von einem anderen aus

Ist eine Arbeitsmappe sicher aus zwei Threads lesbar?

Ja, vorausgesetzt beide Reader halten einen Lease und niemand schreibt. IXLSWorkbookViewCore.AcquireReadLease nimmt eine TCriticalSection, inkrementiert einen Zähler, snapshotet die aktuelle Generation und gibt eine IXLSWorkbookReadLease zurück — konstante Zeit, egal ob die Arbeitsmappe tausend Zellen oder eine Million hält. Beliebig viele Leases koexistieren, sie dürfen in beliebiger Reihenfolge freigegeben werden, und jeder hält den Kern über seine eigene Interface-Referenz am Leben, sodass ein Lease, der das Objekt überlebt, das ihn erzeugt hat, sicher ist statt ein hängender Zeiger. Beide Engines machen mit: TXLSWorkbook in lxHandle.pas und TXLSXWorkbook in lxHandleX.pas bauen jeweils einen Kern in ihrem Konstruktor und exponieren _AcquireReadLease und _AcquireWriteGuard

Ebenso wichtig ist, was der Lease dem Lesepfad nicht hinzufügt. Die Critical Section deckt Lease-Erwerb, Lease-Freigabe und Write-Transaction-Grenzen ab — sonst nichts. Der gewöhnliche Lesevorgang pro Zelle tritt nie in einen Lock, einen Monitor oder einen atomaren Zähler, also kostet das Halten eines Leases einen Erwerb und eine Freigabe für den ganzen Scan, nicht einen pro Zelle. Das ist derselbe Design-Instinkt hinter der parallelen XLSX-Parsing- und Memory-Allocator-Arbeit: Bezahle Koordination an der Grenze, nie in der inneren Schleife. Die symmetrische Regel gilt auch — AcquireReadLease wirft EXLSWorkbookReadLeaseUnavailable, wann immer WriteDepth ungleich null ist, also können Sie keinen Lease von innerhalb einer Write-Transaction öffnen, nicht einmal im schreibenden Thread

HotXLS bezahlt Koordination an der Grenze eines Scans: Die Critical Section deckt nur Lease-Erwerb, Freigabe und Write-Transaction-Grenzen ab, während der Write-Guard innerhalb von TXLSCellRef.SetValue erworben wird, sodass jede Convenience-API darüber einmal gated ist
Ein Erwerb und eine Freigabe decken einen 50.000-Zellen-Scan ab, und ein einziger Guard innerhalb von TXLSCellRef.SetValue deckt jeden öffentlichen Schreibpfad darüber ab
uses
  lxHandle, lxWorkbookView;

procedure TReportThread.Execute;
var
  Lease: IXLSWorkbookReadLease;
  Sheet: TXLSWorksheet;
  Row: Integer;
  Total: Double;
begin
  // Wirft EXLSWorkbookReadLeaseUnavailable, wenn ein Schreibvorgang läuft
  Lease := FWorkbook._AcquireReadLease;
  Sheet := FWorkbook.Sheets[1];
  Total := 0;
  for Row := 1 to 50000 do
    Total := Total + Sheet.Cells[Row, 3].Value;
  FTotal := Total;
  // Der Lease verlässt hier den Scope: Sein Reference Count fällt auf null,
  // ReleaseReadLease läuft, und Writer werden wieder möglich
end;

Wo sitzt der Write-Guard wirklich?

Auf der niedrigsten veränderbaren Schicht, nie an der Convenience-API darüber. _AcquireWriteGuard wird von innerhalb von TXLSCellRef.SetValue selbst aufgerufen, was bedeutet, dass jeder öffentliche Pfad, der in ihn hineinläuft — Range.Value, Worksheet-Textzuweisung, Zelle-für-Zelle-Kopie, Einfügen — einmal gated ist, statt dass jeder Wrapper eine Prüfung wiederholt, die ein künftiger Wrapper vergessen wird. Die Abdeckung ist bewusst breit: 55 Guard-Erwerbe in lxHandle.pas und 37 in lxHandleX.pas zum Stand des Batches, der den Kern einführte

Die gated Oberfläche spannt Zellwerte und Zellformatierung, TXLSWorkbook.Open, Kopieren und Einfügen, definierte Namen (Add, Umbenennen, RefersTo, Visible, IsMacro, Comment, Delete), Worksheet-Metadaten wie Name, Zoom, Visible, StandardHeight, FreezePanes, Protect und Activate, Seiteneinrichtung, Seitenumbrüche und Calculate. Die Platzierung ist der ganze Punkt: Der Guard wird erworben, bevor das erste Feld geschrieben wird, nicht nachträglich von einem Notification-Hook validiert, sodass eine abgelehnte Mutation das Modell byteidentisch lässt. Die Regression-Suite behauptet genau das und liest Sheet-Name, Zoom, Sichtbarkeit, Standardhöhe, Ränder, Ausrichtung und Seitenumbruch-Zählungen nach jedem verweigerten Aufruf neu. Lade-Pfade bekommen dieselbe Behandlung eine Schicht tiefer, wo das ZIP-Read-Gate das gleichzeitige Inflate koordiniert für Paketformate

procedure TXLSWorksheet.Activate;
var
  WriteGuard: IXLSWorkbookWriteGuard;
begin
  // Erworben, bevor das erste Feld berührt wird, nie danach
  WriteGuard := FWorkbook._AcquireWriteGuard;
  if not FSelected then
  begin
    FWorkbook.FWorkSheets.Deselect;
    FSelected := True;
  end;
  FWorkbook.FWorkSheets.FActiveSheet := Self;
  // Nur ein vollendeter äußerster Guard rückt die Generation vor
  WriteGuard.Complete;
end;

Warum rückt ein verschachtelter Schreibvorgang die Generation nur einmal vor?

Weil eine Write-Transaction durch den äußersten Guard eines Threads definiert ist, nicht durch jeden Guard einzeln. Der Kern hält einen Writer-State pro Thread mit Thread-Id, Tiefe und Completion-Flag. Ein zweiter AcquireWriteGuard im selben Thread findet diesen State und inkrementiert Depth, statt eine neue Transaction zu erzeugen, und erst wenn Depth auf null zurückfällt — wobei der äußerste Guard als Complete markiert wurde — rückt FGeneration vor. Das ist es, was einer High-Level-Operation wie Calculate oder Open erlaubt, darunter zehn guarded Primitives aufzurufen und trotzdem als eine Änderung zu registrieren. Innere Complete-Aufrufe werden registriert, bewegen den Zähler aber nicht von sich aus, und die Guards dürfen außer der Reihe freigegeben werden, ohne die Buchhaltung zu brechen

Die Fail-Richtung ist ebenso explizit. Wird ein Guard ohne Complete freigegeben — die gewöhnliche Folge einer Exception, die die Interface-Referenz abwickelt — rückt die Generation nicht vor, denn die Write-Transaction hat nie Erfolg beansprucht. Sehen Sie klar, was das bedeutet: HotXLS rollt die Teiledition nicht zurück. Der Zähler registriert, dass keine erfolgreiche Transaction vollendet wurde, was genau das Signal ist, das ein Cache braucht, aber das Modell in seinen vorherigen Zustand zurückzuversetzen ist nichts, was ein reference-counted Guard für Sie tun kann. Wenn ein Fehler mittendrin die Arbeitsmappe in eine Form bringen kann, die Sie nicht ausliefern können, behalten Sie die Quelldatei und öffnen sie neu, statt dem In-Memory-Objekt zu vertrauen

Zwei HotXLS-Write-Transaction-Zeitlinien verglichen: Verschachtelte Guards in einem Thread heben die Tiefe und rücken den Generation-Zähler nur vor, wenn der äußerste Guard vollendet, während eine Exception, die die Guards ohne Complete abwickelt, die Generation unverändert lässt und die Teiledition in place
Die Tiefe verfolgt die Verschachtelung, aber nur eine vollendete äußerste Transaction rückt die Generation vor, und eine abgebrochene lässt sowohl den Zähler als auch die Teiledition genau dort, wo sie waren

Was der Generation-Zähler Ihnen einbringt

Billige Veraltetkeitserkennung ohne Scannen. Generation ist ein UInt64, der bei 1 beginnt und beim Überlauf 0 überspringt, also ist 0 nie ein Wert, den der Kern ausgibt, und funktioniert als verlässlicher „nie beobachtet“-Sentinel. Zwei Invarianten machen ihn brauchbar: Die Generation kann sich nicht bewegen, solange irgendein Read-Lease existiert, und jede erfolgreiche Write-Transaction inkrementiert sie genau einmal. Also ist IXLSWorkbookReadLease.Generation ein Snapshot, der über die ganze Lebensdauer des Leases konstant bleibt, und IXLSWorkbookWriteGuard.StartGeneration sagt einem Writer, wie das Modell aussah, als seine Transaction sich öffnete. Ein Grid, eine Druckvorschau oder ein abgeleiteter Index kann einen Integer vergleichen, statt Zeilen zu diffen

var
  Lease: IXLSWorkbookReadLease;
begin
  Lease := FWorkbook._AcquireReadLease;
  if Lease.Generation <> FCachedGeneration then
  begin
    FCachedGeneration := Lease.Generation;
    RebuildRowHeightCache;
  end;
  PaintVisibleRows;
  // FCachedGeneration beginnt bei 0, einem Wert, den der Kern nie ausgibt,
  // also baut der allererste Durchlauf immer neu
end;

Was diese Koordination nicht verspricht

Drei Grenzen lohnen sich klar auszusprechen, denn das Gegenteil anzunehmen ist der Weg, auf dem der Mechanismus missbraucht wird. Erstens ist ein Write-Guard keine wechselseitige Ausschließung zwischen Writern: Der Kern schließt Reader gegen Writer aus, und zwei verschiedene Threads können gleichzeitig jeweils einen Write-Guard halten, jeder rückt die Generation unabhängig vor — ein Regressionstest behauptet genau dieses Verhalten. Ihre eigenen Writer-Threads zu serialisieren bleibt Ihre Aufgabe. Zweitens ist nichts davon ein Datei-Lock oder ein Cross-Process-Mutex; er koordiniert Threads in einem Prozess gegen eine Workbook-Instanz, und zwei Prozesse, die dieselbe .xlsx öffnen, wissen nichts voneinander. Drittens erreicht die Garantie nur Aufrufer, die tatsächlich einen Lease nehmen — ein ungeleaster Lesevorgang läuft weiter über einen ungesperrten Hot Path, der schnell und völlig ungeschützt ist. Das ist ein Koordinationskern, keine transaktionale Datenbank

Innerhalb dieser Grenzen benutzt ist er ein kleines, ehrliches Primitiv: Neun dedizierte Regressionstests decken mehrere Reader, beide Konfliktrichtungen, Reentranz, Freigabe außer der Reihe, abgebrochene Transactions sowie Cross-Thread-Read/Write- und Write/Write-Races ab, innerhalb einer Suite von 1.328 Tests, die auf Win32 und Win64 bestehen. Paaren Sie ihn mit dem crash-safen gestuften Temp-Datei-Speicherpfad, und ein Hintergrund-Export wird zu etwas, das Sie Ende zu Ende durchdenken können — konsistent beim Lesen, atomar beim Schreiben. Read-Leases, Write-Guards und der Generation-Zähler kommen als Teil der classic- und Paket-Engines in der HotXLS Delphi Component für Delphi und C++Builder, ohne dass eine Konfiguration nötig ist, um sie zu aktivieren