Technischer Artikel

HotXLS-Dateimonitor-Callback-Deadlock in Delphi

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

Zwei Deadlock-Gesichter des HotXLS-Dateimonitors: mit SynchronizeEvent True blockiert der Hauptthread in Worker.WaitFor, während der Worker in TThread.Synchronize blockiert, und mit False läuft der Handler auf dem Polling-Thread, sodass SetActive False auf den Thread selbst wartet und OnFileChanged nicht zurückkehren kann
Mit SynchronizeEvent an wartet der Hauptthread in Worker.WaitFor, während der Worker in Synchronize wartet; mit aus läuft der Handler auf dem Polling-Thread und wartet auf seinen eigenen Exit, ein geschlossener Zyklus, zwei Dispatch-Modi
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

Wie HotXLS den Dateimonitor-Teardown auflöst: TXLSFileMonitorThread.FireEvent stempelt FCallbackThreadID vor dem Handleraufruf mit GetCurrentThreadId und löscht es in einem finally-Block, sodass SetActive FThread lösen, Terminate aufrufen und zwischen FreeOnTerminate-Selbstfreigabe in einem Callback und dem klassischen WaitFor plus Free wählen kann
FireEvent stempelt FCallbackThreadID vor dem Handleraufruf und löscht es in einem finally-Block; SetActive wählt dann zwischen FreeOnTerminate, wenn die Identität matcht, und dem klassischen WaitFor plus Free, wenn der Aufrufer unabhängig ist
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

Der CSV-Loader-Defekt hinter dem HotXLS-Audit: das Herunterzählen von SkipRows auf dem Property-Backing-Feld nullte es stillschweigend nach einem Parse, also leckte eine zweite Datei Bannerzeilen ins Grid, während das korrigierte Modell FSkipRows als deklarierte Einstellung behält und einen separaten FSkipRemaining-Zähler am Anfang jedes ParseFile-Aufrufs zurücksetzt
Das Herunterzählen auf dem Property-Backing-Feld macht aus Konfiguration Scratch-Zustand: der erste Parse nullt SkipRows, während das korrigierte Modell FSkipRows deklariert hält und FSkipRemaining am Anfang jedes Parses zurücksetzt

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, IntervalMs und SynchronizeEvent von einem Thread, normalerweise dem, der den Monitor erzeugt hat
  • IntervalMs greift ab der nächsten Aktivierung, und der Worker schläft in kurzen Scheiben, damit Terminate auch 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 SynchronizeEvent auf False lä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