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