Vom Magazinthema zur Projektpraxis
Passende Leistungs- und Technikseiten zum Beitrag
Die Szene ist typisch für gewachsene Business-Software: Ein Import läuft in einem Worker-Thread, die UI zeigt Fortschritt, beim Schließen „wartet man kurz auf den Thread“ – und genau dann hängt die Anwendung. Nicht immer. Nicht reproduzierbar im Debugger. Aber zuverlässig dann, wenn Betrieb und Support gerade keine Zeit dafür haben. Der Reflex ist oft: „TThread.Synchronize ist gefährlich, wir nehmen überall Queue.“ Das hilft, aber es ist keine vollständige Antwort. Der Kern ist die Frage, wie Sie TThread und Synchronize ohne UI-Deadlocks in VCL- und Legacy-Code so einsetzen, dass Wartezyklen, Lock-Inversionen und Shutdown-Kanten kontrollierbar werden.
Dieser Beitrag ist als Entscheidungspfad aufgebaut: Sie können von Ihrer aktuellen Situation aus gezielt zum passenden Muster springen. Ein ungewöhnlicher, aber in Randfällen belastbarer Dispatcher (Queue + Event + Timeout) ist als Source-Schnipsel enthalten.
Entscheidungspfad: Welche Art UI-Übergabe ist es wirklich?
Bevor Sie Code anfassen, klassifizieren Sie jede UI-Übergabe aus einem Worker. Diese Einteilung entscheidet, ob Synchronize überhaupt gerechtfertigt ist.
- Nur Anzeige (Progress, Logzeile, Ampel, Enable/Disable): Der Worker braucht kein Ergebnis vom UI-Thread.
- Ergebnis rendern (DTO/Records in Grid, Labels, Status): Der Worker liefert Daten, UI stellt dar. Keine Rückkopplung notwendig.
- UI muss entscheiden (Benutzerabfrage, Credentials, Dateidialog, Zugriff auf UI-gebundene Komponente): Der Worker braucht ein Ergebnis aus dem Main Thread.
Nur Kategorie 3 rechtfertigt synchrone Semantik. Alles andere sollte asynchron sein, sonst bauen Sie unnötig Blockadekanten in Ihren Durchsatzpfad.
Wenn es nur Anzeige ist: sofort auf TThread.Queue umstellen
Für Statusupdates ist TThread.Queue der Default. Der Worker blockiert nicht, und Sie reduzieren das Risiko eines WaitFor Deadlock (UI wartet auf Worker, Worker wartet auf UI) drastisch.
Der wichtigste operative Punkt: In VCL wird queued Code nur abgearbeitet, wenn die Message Loop läuft (die Windows-Nachrichtenverarbeitung des UI-Threads). Blockieren Sie den Main Thread durch lange Handler, werden queued Updates zwar gesammelt, aber nicht ausgeführt. Das ist kein Deadlock, aber es sieht für Nutzer ähnlich aus („UI hängt“).
Wenn der Worker ein UI-Ergebnis braucht: vermeiden, sonst kontrolliert synchronisieren
Kategorie 3 ist die echte Deadlock-Zone. Zwei häufige Legacy-Beispiele:
- Worker lädt Daten, merkt „Login abgelaufen“ und will im UI Credentials abfragen.
- Worker verarbeitet Dateien und muss einen UI-basierten Pfad/Dateidialog öffnen (historisch so gewachsen).
Architektonisch sauberer ist eine Zustandsmaschine: Worker signalisiert „NeedUserInput“, pausiert kooperativ, UI sammelt Input und setzt den Job fort. Wenn das kurzfristig nicht machbar ist, brauchen Sie synchrone Semantik – aber dann bitte so, dass Sie nicht an Synchronize als Blackbox hängen und beim Shutdown kontrolliert abbrechen können.
TThread und Synchronize ohne UI-Deadlocks: warum WaitFor im Main Thread kippt
Der klassische Zyklus ist simpel:
- Main Thread:
Thread.WaitFor(z. B. inOnCloseoder Progress-Dialog) - Worker:
Synchronize(oder ein synchroner UI-Call), wartet auf Main Thread
Damit ist der Deadlock deterministisch. Er tritt „sporadisch“ auf, weil nur bestimmte Codepfade im Worker (z. B. Fehlerfall, Progress-Update, Rückfrage) den UI-Call auslösen.
Zur Mechanik: TThread.Synchronize ist per Design blockierend, bis der Main Thread den Callback ausführt. Der Main Thread kann das aber nur, wenn er nicht selbst blockiert. In der Delphi-Doku ist das Grundprinzip beschrieben, inklusive der Rolle von Queue als nicht-blockierender Alternative; siehe Embarcadero-Doku zu TThread.Synchronize und TThread.Queue.
Source-Schnipsel: UI-Dispatcher (Queue + Event + Timeout + Shutdown)
Das Muster unten ist für Legacy-Situationen gedacht, in denen Sie synchrone UI-Übergaben nicht sofort wegrefaktorieren können. Es ersetzt nicht alle Threading-Probleme, aber es macht die kritische Kante messbar und betrieblich beherrschbar:
- Post: asynchron (Queue), geeignet für Anzeige/Rendering
- Call: synchron mit Timeout (Queue +
TEvent) - Shutdown-Gate: keine neuen UI-Jobs mehr annehmen; queued Jobs prüfen Flag
- Exception-Rücktransport: Exception aus UI-Callback wird im Worker neu geworfen
unit UiDispatch;
interface
uses
System.SysUtils,
System.Classes,
System.SyncObjs;
type
EUiDispatchTimeout = class(Exception);
EUiDispatchShuttingDown = class(Exception);
/// <summary>
/// UI-Übergaben aus Worker-Threads kapseln.
/// Post: asynchron über TThread.Queue.
/// Call: synchron mit Timeout via Queue + TEvent.
/// </summary>
TUiDispatcher = class
strict private
class var FShuttingDown: Integer;
public
class procedure BeginShutdown; static;
class function IsShuttingDown: Boolean; static;
class procedure Post(const AProc: TProc); static;
class procedure Call(const AProc: TProc; ATimeoutMs: Cardinal = 5000); static;
end;
implementation
class procedure TUiDispatcher.BeginShutdown;
begin
TInterlocked.Exchange(FShuttingDown, 1);
end;
class function TUiDispatcher.IsShuttingDown: Boolean;
begin
Result := TInterlocked.CompareExchange(FShuttingDown, 0, 0) = 1;
end;
class procedure TUiDispatcher.Post(const AProc: TProc);
begin
if not Assigned(AProc) then
Exit;
if IsShuttingDown then
Exit;
TThread.Queue(nil,
procedure
begin
if IsShuttingDown then
Exit;
AProc();
end);
end;
class procedure TUiDispatcher.Call(const AProc: TProc; ATimeoutMs: Cardinal);
var
DoneEvent: TEvent;
RaisedObj: TObject;
begin
if not Assigned(AProc) then
Exit;
if IsShuttingDown then
raise EUiDispatchShuttingDown.Create('UI-Dispatcher ist im Shutdown.');
DoneEvent := TEvent.Create(nil, True, False, '');
try
RaisedObj := nil;
TThread.Queue(nil,
procedure
begin
try
if not IsShuttingDown then
AProc();
except
// Exception-Objekt sichern und im Worker erneut werfen.
RaisedObj := AcquireExceptionObject;
end;
DoneEvent.SetEvent;
end);
case DoneEvent.WaitFor(ATimeoutMs) of
wrSignaled:
if Assigned(RaisedObj) then
raise Exception(RaisedObj);
wrTimeout:
raise EUiDispatchTimeout.CreateFmt(
'Timeout nach %d ms: Main Thread hat UI-Aufruf nicht abgearbeitet.',
[ATimeoutMs]);
else
raise Exception.Create('Unerwarteter WaitFor-Status im UI-Dispatcher.');
end;
finally
DoneEvent.Free;
end;
end;
end.Zweck und Einordnung
Die ungewöhnliche Stelle ist nicht TEvent, sondern die bewusste Entscheidung, synchrone Semantik über Queue zu bauen. Damit bekommen Sie:
- eine definierte Timeout-Politik (statt „hängt halt“),
- eine klare Shutdown-Semantik (abweisen statt abstürzen),
- und eine Stelle, an der Sie Telemetry aufsetzen können (Metrik: wie oft timeoutet es, wie lange warten Worker auf UI).
Randbedingungen und Stolperfallen
- Timeout ist Schutz, keine Lösung: Wenn der Main Thread blockiert (lange Handler, modale Ketten, COM/STA-Aufrufe), wird das Timeout auslösen. Das ist gewollt: lieber kontrollierter Fehlerpfad als Ressourcenstau (offene DB-Transaktionen, halb fertige Jobs).
- Lebensdauer von UI-Objekten: Der Shutdown-Flag reduziert Zugriffe auf zerstörte Controls, ersetzt aber keine saubere Ownership. In VCL ist es sinnvoll, UI-Callbacks defensiv zu halten (z. B.
if (Form=nil) or Form.ComponentState*[csDestroying]<>[] then Exit;), je nach Codebasis. - Exception-Re-Throw: Der Stacktrace entspricht nicht dem Ursprung. Für Support ist aber oft entscheidend, dass der Worker-Job scheitert und der Fehler im Worker-Kontext geloggt werden kann.
- Speichersichtbarkeit: Das Event bildet die Synchronisationskante. Vermeiden Sie dennoch, globale Variablen als Rückkanal zu nutzen; halten Sie Rückgaben lokal pro Call.
Lock-Inversion: die häufigere Ursache als „Synchronize an sich“
In Legacy-Anwendungen ist der Deadlock-Auslöser oft eine Lock-Inversion (umgedrehte Lock-Reihenfolge). Muster:
- Worker hält
TCriticalSection/TMonitorauf Modell/Datenzugriff - Worker ruft UI-Callback (Synchronize oder Dispatcher)
- UI-Callback greift indirekt wieder auf dieselbe Ressource zu (Caching, Logging, Singleton, Datenmodell)
Die „saubere“ Reparatur ist banal, aber in Reviews konsequent durchzusetzen:
| Regel | Warum sie Deadlocks reduziert | Praktische Umsetzung |
|---|---|---|
| Keine Locks über UI-Übergaben halten | Verhindert zyklische Abhängigkeiten zwischen Worker und UI | Lock-Bereich verkürzen; Snapshot erstellen; erst dann UI posten/callen |
| UI rendert Snapshots statt Live-Objekte | UI wird reentrant-sicherer, weniger versteckte Zugriffe | DTO/Records/Strings übergeben; keine Referenzen auf Worker-Collections |
| Logging nicht im UI-Callback blockierend | Logger haben oft eigene Locks/Queues und werden Teil des Zyklus | Asynchron loggen oder Logging im UI-Callback minimal halten |
Entscheidungsknoten: Darf der Main Thread überhaupt „warten“?
In der Praxis kippt vieles nicht beim Synchronize selbst, sondern bei einer stillschweigenden Annahme: „Warten ist doch okay, wir warten ja nur kurz.“ Das ist bei VCL gefährlich, weil Warten oft gleichbedeutend ist mit „Message Loop steht“ – und damit stehen auch queued Callbacks, Timer, Datenbindung und teilweise COM-Nachrichten (STA) still.
Ein pragmatischer Entscheidungsknoten, den Sie in Reviews direkt anwenden können:
- Warten im Main Thread ist erlaubt, wenn Sie währenddessen weiterhin Nachrichten pumpen (z. B. über eine saubere Progress-Dialog-Architektur, die nicht blockiert) und garantiert keine Worker-UI-Übergaben mehr offen sind.
- Warten im Main Thread ist nicht erlaubt, wenn der Worker noch UI-Arbeit „auf dem Zettel“ haben kann (Synchronize/Queue, indirekter UI-Zugriff, COM-Callbacks, Datenbindung). Dann ist der Deadlock nur eine Frage des Timings.
Wenn Sie heute aus Legacy-Gründen nicht um „warten bis fertig“ herumkommen, ist das eine Stelle, an der ein Timeout (wie im Dispatcher) als Betriebsbremse sinnvoll ist: Sie bekommen einen Fehlerpfad, Logging und einen reproduzierbaren Marker statt eines Hängers ohne Spuren.
Variante für Modal-Dialoge: UI-Call kapseln, aber Job-Logik entkoppeln
Ein häufiger Sonderfall ist der modale Dialog (Passwort, Konfliktauflösung). Der Dialog selbst blockiert zwar den restlichen UI-Fluss, aber die Message Loop läuft weiter. Das reduziert das Risiko, löst es aber nicht automatisch: Wenn der Dialog im UI-Callback wiederum auf Worker-Ressourcen zugreift (Lock-Inversion), sind Sie wieder im Kreis.
Als Zwischenstufe hat sich bewährt: Der Worker entscheidet nicht, wie der Dialog aussieht, sondern fordert nur eine Entscheidung an (z. B. Record/DTO mit Kontext). Das UI führt den Dialog aus und schreibt das Ergebnis in einen klaren Rückgabekanal (lokale Variable im Dispatcher-Call oder separate State-Objekte). Das ist noch keine vollständige Zustandsmaschine, aber es reduziert Koppelung.
Shutdown-Entscheidung: Was tun, wenn das UI schließen will?
Hier entscheidet sich, ob Ihre Anwendung „sauber“ wirkt. Der Pfad ist in der Praxis effektiver als jeder einzelne Thread-Fix:
- Früh Shutdown signalisieren: z. B. im Hauptformular
OnCloseQueryzuerstTUiDispatcher.BeginShutdownsetzen. - Kooperativ stoppen: Worker prüfen ein Cancel-Event/Flag und verlassen Schleifen/Waits zeitnah. Keine harten Kills.
- Main Thread nicht blockieren: kein
WaitForim UI-Thread, solange noch UI-Übergaben möglich sind. - „UI lebt noch?“ als Gate: queued Jobs müssen damit rechnen, dass Fenster/Handles verschwinden.
Eine redaktionelle Einschätzung (bewusst nur einmal)
In VCL-Legacy-Projekten ist „Synchronize verbieten“ als Teamregel selten erfolgreich, weil es echte Fälle mit UI-Thread-Bindung gibt. Erfolgreicher ist es, synchrone UI-Übergaben zu zentralisieren (Dispatcher) und jede synchrone Stelle zu begründen. Das erzeugt Reibung an der richtigen Stelle: in Code-Reviews und Architekturentscheidungen, nicht im Betrieb.
Debugging-Pfad: Wie Sie den Zyklus nachweisen
Wenn es hängt, wollen Sie nicht raten, sondern belegen. Ein kurzer Debugging-Plan, der in Bestandsanwendungen ohne große Umbauten funktioniert:
- Inventar der Waits: Suche nach
WaitFor,TEvent.WaitFor,Sleep-Schleifen, unendlichen Waits. Markieren, ob sie im Main Thread auftreten. - UI-Übergaben zählen: Loggen Sie „queued/post“, „call begin“, „call done“, inkl. Thread-ID und Correlation-ID des Jobs.
- Lock-Haltezeiten messen: Im Debug-Build bei
Enter/LeaveZeit messen und Thread-ID protokollieren. Lange Lock-Halts sind ein starker Hinweis auf Inversion.
Wenn Sie zusätzlich UI-Blockaden vermuten: Prüfen Sie, ob lange OnClick/OnTimer-Handler existieren, die Datenzugriff oder REST machen. Das ist kein Threading-Bug, aber es blockiert die Message Loop und verstärkt alle Symptome.
Fazit: Wo sich der Ansatz lohnt – und wo die Grenzen sind
TThread und Synchronize ohne UI-Deadlocks erreichen Sie nicht durch einen einzelnen Trick, sondern durch kontrollierte Übergaben: asynchron für Anzeige/Rendering, synchrone Semantik nur in begründeten Ausnahmefällen, keine Locks über UI-Kanten, definierter Shutdown. Der gezeigte Dispatcher ist besonders dann nützlich, wenn Sie in einer gewachsenen Delphi-Anwendung schnell Stabilität nachrüsten müssen, ohne alle Workflows sofort auf Zustandsmaschinen umzubauen.
Grenzen: Wenn der Main Thread dauerhaft blockiert (z. B. lange UI-Handler, modale Dialogketten, COM/STA-Engstellen), dann kann ein Timeout nur verhindern, dass Worker ewig warten. Die nachhaltige Lösung ist dann Entkopplung: UI-Thread entlasten, Verantwortlichkeiten trennen, synchrone UI-Abhängigkeiten zurückbauen. Wenn Sie dafür einen strukturierten Blick auf eine konkrete Codebasis brauchen, lässt sich das am schnellsten im direkten Austausch klären: Projekt oder Modernisierungsvorhaben mit Net-Base besprechen.
Für dieses Thema sind auch Delphi Multithreading und Synchronize Deadlock wichtig. Der Beitrag ordnet diese Aspekte verständlich ein und zeigt, worauf es im Alltag ankommt.
Projekt oder Modernisierungsvorhaben mit Net-Base besprechen.
nächster Schritt
Wenn aus dem Thema ein reales Projekt wird, sollten Architektur, Bestand und Betrieb früh zusammen betrachtet werden.
Wir unterstuetzen nicht nur bei Einzelfragen, sondern auch dann, wenn aus Source-Schnipseln, Legacy-Themen oder Portalideen ein belastbares Unternehmensprojekt werden soll.
- Bestand, Zielbild und technische Risiken werden zusammen bewertet.
- REST, Datenzugriff, Portale und Rollout werden nicht als Spätfolgen verschoben.
- Sie sehen früh, welcher Weg wirtschaftlich und betrieblich tragfähig ist.