HotXLS beobachtet eine Arbeitsmappe auf der Platte mit TXLSFileMonitor und feuert OnFileChanged, wenn ein anderer Prozess über die Datei speichert. Monitor.Active := False aus diesem Handler heraus zu setzen, hing früher endlos, weil der Worker-Thread noch darauf blockierte, dass der Callback, den er gerade ausgeliefert hatte, zurückkehrt. Der Fix ist, dem Thread-Objekt die Identität des Callbacks mitzugeben, den es gerade ausführt
Warum hängt das Stoppen eines Dateimonitors in seinem eigenen Handler?
Weil der Deaktivierungsaufruf und der Callback auf zwei verschiedenen Threads laufen und jeder auf das Ende des anderen wartet. TXLSFileMonitor kommt mit SynchronizeEvent auf True, was bedeutet, dass der Polling-Thread Ihren Handler nicht selbst aufruft. Er ruft TThread.Synchronize, übergibt die Methode dem Hauptthread und blockiert dann, bis der Hauptthread sie ausgeführt und den Abschluss signalisiert hat. Genau das wollen Sie für einen Handler, der ein Grid neu lädt oder ein Formular neu zeichnet, und genau das macht den naheliegenden Deaktivierungsaufruf zur Falle
procedure TMainForm.HandleFileChanged(Sender: TObject);
begin
// Wir sind hier auf dem Hauptthread, dank TThread.Synchronize
FBook.LoadFromFile(FMonitor.FileName);
RefreshGrid;
// Der Benutzer wollte nur ein Reload, also das Beobachten stoppen
FMonitor.Active := False; // kehrte früher nie zurück
end;
Verfolgen Sie die zwei Stacks, und der Zyklus ist offensichtlich. Der Hauptthread ist in SetActive(False), was den Worker beendet und dann Worker.WaitFor aufruft, um sicher zu sein, dass der Thread wirklich beendet ist, bevor das Objekt freigegeben wird. Der Worker-Thread steht derweil in TThread.Synchronize geparkt und wartet darauf, dass der Hauptthread aus genau dem Handler zurückkehrt, der SetActive aufruft. Keine Seite kann sich bewegen. Die Anwendung crasht nicht, sie raised nicht, sie hört nur auf, neu zu zeichnen, die schlimmste Diagnose-Signatur, die ein Bug haben kann
Derselbe Deadlock trägt ein zweites Gesicht
Das Ausschalten der Synchronisation vermeidet das Problem nicht, es verlagert es nur. Mit SynchronizeEvent auf False, die sinnvolle Wahl in einem Service oder einem Konsolen-Tool ohne Message-Loop, ruft TXLSFileMonitor den Handler direkt auf dem Polling-Thread auf. Ihr Handler läuft jetzt auf dem Worker, also erreicht ein Monitor.Active := False von innen Worker.WaitFor auf dem Worker-Thread selbst. Ein Thread kann nicht auf seinen eigenen Exit warten, also blockiert er, bis etwas außerhalb den Prozess killt. Beide Varianten sind derselbe Bug: SetActive(False) nahm an, immer von irgendwo außerhalb des Callbacks aufgerufen zu werden. Diese Annahme hält für einen Close-Button oder einen Formular-Destruktor und scheitert an genau der einen Aufrufstelle, die man zuerst erreicht, weshalb es so lange überlebte. Jede Komponente, die einen Thread besitzt und Benutzercode daraus ausliefert, erbt dieselbe Gefahr, und die Auflösung ist in jedem Fall dieselbe, der Teardown-Pfad muss erkennen können, ob er aus einem Callback heraus läuft, den der Thread, den er joinen will, noch hält
Dem Worker-Thread die Callback-Identität mitgeben
HotXLS löst es, indem es auf dem Thread-Objekt selbst festhält, welcher OS-Thread gerade den Handler ausführt. TXLSFileMonitorThread.FireEvent stempelt FCallbackThreadID unmittelbar vor dem Handleraufruf mit GetCurrentThreadId und löscht es danach in einem finally-Block. Dieses eine Feld reicht dem Deaktivierungspfad, um »von unabhängigem Code aufgerufen« von »aus meinem eigenen Callback aufgerufen« zu unterscheiden, und es funktioniert für beide Dispatch-Modi identisch, denn im synchronisierten Fall meldet GetCurrentThreadId in FireEvent bereits den Hauptthread
procedure TXLSFileMonitorThread.FireEvent;
var
Owner: TXLSFileMonitor;
Handler: TNotifyEvent;
begin
// Ein gequeuter Callback kann von WaitFor während der Deaktivierung ausgeliefert werden
if Terminated then
Exit;
Owner := FOwner;
Handler := Owner.FOnFileChanged;
if not Assigned(Handler) then
Exit;
FCallbackThreadID := GetCurrentThreadId;
try
Handler(Owner);
finally
// Der Handler darf Owner zerstört haben, aber dieser Thread lebt weiter
FCallbackThreadID := 0;
end;
end;
Der Deaktivierungszweig von SetActive macht dann drei Dinge in einer bewussten Reihenfolge. Er löst den Thread vom Monitor, indem er zuerst FThread leert, also meldet GetActive von diesem Moment an False, und ein reentrantes Active := False im selben Handler sofort rausgeht, statt den Thread zweimal abzureißen. Er ruft Terminate. Erst dann entscheidet er, wie er das Thread-Objekt zurückholt
Worker := TXLSFileMonitorThread(FThread);
FThread := nil; // der Monitor ist von diesem Punkt an inaktiv
Worker.Terminate;
FHasBaseline := False;
if Worker.FCallbackThreadID = GetCurrentThreadId then
// Sowohl direkte als auch synchronisierte Handler müssen zurückkehren,
// bevor der Worker enden kann; er gibt sich frei, nachdem der Callback abgewickelt ist
Worker.FreeOnTerminate := True
else
begin
Worker.WaitFor;
Worker.Free;
end;
Wenn die Identität matcht, wartet HotXLS nie. Es setzt FreeOnTerminate := True und kehrt zurück, lässt den Handler auslaufen, den Synchronize-Aufruf abschließen, Execute Terminated beobachten und die RTL das Thread-Objekt freigeben, sobald der Callback-Stack vollständig abgewickelt ist. Wenn die Identität nicht matcht, ein Close-Button, ein Destruktor, ein Hintergrundjob, ist das klassische WaitFor plus Free weiterhin richtig, denn es gibt Ihnen die harte Garantie, dass kein weiterer Callback feuern kann, nachdem Active := False zurückgekehrt ist. Zwei Exit-Pfade, ein Feld zur Wahl. Eine Konsequenz verdient die Ausformulierung: Auf dem Selbstfreigabe-Pfad überlebt der Worker den Handler. Nichts im Rückkehrpfad von FireEvent darf das Monitor-Objekt wieder anfassen, denn der Handler darf es freigegeben haben. Deshalb cached FireEvent Owner und Handler vorab in Lokale und fasst in der finally-Klausel nur sein eigenes Feld an. Ein verirrtes FOwner.Something nach dem Handleraufruf würde aus einem gefixten Deadlock ein Use-after-Free machen, ein streng genommen schlechteres Ergebnis
Warum muss FireEvent Terminated prüfen, bevor es irgendetwas tut?
Weil WaitFor kein passives Warten ist. Auf dem Hauptthread entleert die RTL-Implementierung von TThread.WaitFor während des Blockierens weiter die Synchronisations-Queue, genau damit ein in Synchronize geparkter Thread abschließen und enden kann, statt gegen das Herunterfahren zu verklemmt. Das ist ein Feature, und es bedeutet, dass ein Callback, der Mikrosekunden vor Ihrem Active := False von einem Close-Button gequeued wurde, während des Teardowns noch ausgeliefert werden kann, nachdem der Monitor seine FThread-Referenz schon fallen gelassen hat. Die Wache oben in FireEvent macht diese Auslieferung zu einem No-op. Ohne sie kann eine Anwendung, die ihr Hauptformular schließt, während ein Save auf der beobachteten Datei landet, einen Reload-Handler gegen halb entsorgten Zustand laufen lassen, und die resultierende Access Violation sieht nach gar nichts wie ein Shutdown-Reihenfolge-Problem aus. Wenn Ihre eigene Komponente Arbeit durch Synchronize ausliefert, verallgemeinert sich die Regel: Jede synchronisierte Methode braucht einen Terminated-Check als erste Anweisung, nicht weil der Thread weg sein könnte, sondern weil die Queue die Entscheidung zu stoppen überlebt
Dieselbe Bug-Form im CSV-Importpfad
Das Audit, das den Monitor-Fix hervorbrachte, förderte einen Geschwisterdefekt im Text-Importer zutage, und er lohnt das Nebeneinander-Lesen, weil der zugrunde liegende Fehler identisch ist: ein Stück Konfiguration, das konsumiert wird, als wäre es Scratch-Zustand. TXLSCSVLoader legt eine SkipRows-Property für die Bannerzeilen offen, die in exportierten Reports über dem echten Header sitzen. Der Parser braucht einen Countdown, während er diese Zeilen durcharbeitet, und die ursprüngliche Implementierung zählte auf dem Property-Backing-Feld selbst herunter. Importieren Sie eine Datei, und SkipRows wurde stillschweigend null; nutzen Sie denselben Loader für eine zweite Datei, und die Bannerzeilen landeten im Grid
Loader := TXLSCSVLoader.Create;
try
Loader.SkipRows := 2; // zwei Bannerzeilen über dem Header
Loader.ParseFile('jan-export.csv');
ReadGrid(Loader);
// SkipRows ist hier immer noch 2: FSkipRemaining wird pro Parse zurückgesetzt
Loader.ParseFile('feb-export.csv');
ReadGrid(Loader);
finally
Loader.Free;
end;
HotXLS hält jetzt FSkipRows als deklarierte Einstellung und setzt einen separaten FSkipRemaining-Zähler am Anfang jedes Parses zurück, sowohl im delimited- als auch im fixed-width-Pfad. Die allgemeine Regel ist billig anzuwenden und zahlt sich aus: Wenn eine Schleife es dekrementiert, ist es kein Property-Backing-Feld. Dasselbe Batch zog auch XlsCsvTryParseISODateTime an, das bereit gewesen war, ein parsebares Präfix als ganzes Datum zu akzeptieren. Es validiert jetzt das gesamte Feld, Länge, Trennstellen und jede Ziffer, sodass ein Wert wie eine Auftragsreferenz, die nur mit etwas Datumartigem beginnt, Text bleibt, statt ein falsches TDateTime zu werden. Stille Typkoerzision beim Import ist flussabwärts weit teurer zu debuggen als ein abgelehntes Feld, ein Thema, das auch durch den Schutz einer Arbeitsmappe gegen parallele Leser und Schreiber in HotXLS läuft
Was dieser Fix gibt und was nicht
Seien Sie präzise bei der Garantie. Die Thread-Ownership-Änderung macht genau ein zuvor fatales Muster sicher: das Deaktivieren oder Freigeben eines TXLSFileMonitor aus seinem eigenen OnFileChanged-Handler heraus, in beiden Dispatch-Modi. Es ist kein allgemeines Concurrency-Design, und es macht die Komponente nicht sicher, von beliebigen Threads aus gesteuert zu werden
- Property-Writes sind immer noch nicht threadsicher. Setzen Sie
FileName,IntervalMsundSynchronizeEventvon einem Thread, normalerweise dem, der den Monitor erzeugt hat IntervalMsgreift ab der nächsten Aktivierung, und der Worker schläft in kurzen Scheiben, damitTerminateauch bei langer Poll-Periode zeitnah geehrt wird- Erkannt wird über Last-Write-Zeit plus Größe, also ist eine Änderung, die beides erhält, unsichtbar; eine Datei, die mitten im Save verschwindet, feuert nicht, und das Ereignis kommt, sobald der neue Inhalt an Ort und Stelle ist
- Mit
SynchronizeEventaufFalseläuft Ihr Handler auf dem Worker-Thread, und jede Regel über das Anfassen von VCL-Controls aus einem Hintergrundthread gilt für alles, was Sie darin tun - Das Neuladen einer Arbeitsmappe aus dem Handler braucht weiterhin seine eigene Disziplin, besonders wenn anderer Code eine Referenz auf das ersetzte Buch hält
In der Praxis ist der Reload-Handler der Teil, der die meiste Sorgfalt verdient, nicht der Monitor. Ein Änderungsereignis kommt meist, während der schreibende Prozess noch in Bewegung ist, also kann ein Load, der die Datei sofort öffnet, einem halb committeten Save begegnen; einmal mit kurzer Verzögerung erneut zu versuchen, ist robuster, als dem ersten Stempel zu trauen. Wenn Ihr eigener Code der Schreiber der beobachteten Datei ist, gibt die in der crash-sicheren gestuften Temp-Datei-Speicherung in HotXLS beschriebene Staged-Temp-Datei und atomare Umbenennung dem Monitor auch ein sauberes Einzel-Transitions-Ereignis zum Beobachten statt eines Stroms von Zwischengrößen. Und wenn mehrere Watcher dieselbe Reload-Routine füttern, sind die Serialisierungsfragen in dem Read-Gate um die gleichzeitige ZIP-Inflation das Nächste zum Lesen. Die breitere Lektion überlebt die konkrete Komponente. Jede Klasse, die einen Thread besitzt und in Benutzercode zurückruft, braucht einen Teardown-Pfad, der ihren eigenen Callback-Frame erkennen kann, denn der Handler ist der natürlichste Ort der Welt, an dem ein Benutzer Stopp sagt. Das Aufzeichnen der Callback-Thread-ID kostet ein DWORD und verwandelt einen unerklärbaren Hang in eine gewöhnliche Objektlebensdauer-Frage. Dateimonitoring, atomare Saves und Textimport fahren alle im HotXLS Delphi Component für Delphi und C++Builder, ohne zusätzliche Konfiguration, um etwas davon zu aktivieren