Net-Base Magazin

15.05.2026

TThread und Synchronize ohne UI-Deadlocks: Entscheidungspfad und ein Dispatcher-Muster für VCL-Legacy

Entscheidungspfad für VCL und Legacy: Wann Queue reicht, wann synchron nötig ist – und wie ein Queue+Event-Dispatcher mit Timeout, Shutdown-Gate und Lock-Regeln UI-Deadlocks praktisch entschärft.

15.05.2026

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.

  1. Nur Anzeige (Progress, Logzeile, Ampel, Enable/Disable): Der Worker braucht kein Ergebnis vom UI-Thread.
  2. Ergebnis rendern (DTO/Records in Grid, Labels, Status): Der Worker liefert Daten, UI stellt dar. Keine Rückkopplung notwendig.
  3. 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. in OnClose oder 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
Delphi
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/TMonitor auf 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:

  1. Früh Shutdown signalisieren: z. B. im Hauptformular OnCloseQuery zuerst TUiDispatcher.BeginShutdown setzen.
  2. Kooperativ stoppen: Worker prüfen ein Cancel-Event/Flag und verlassen Schleifen/Waits zeitnah. Keine harten Kills.
  3. Main Thread nicht blockieren: kein WaitFor im UI-Thread, solange noch UI-Übergaben möglich sind.
  4. „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:

  1. Inventar der Waits: Suche nach WaitFor, TEvent.WaitFor, Sleep-Schleifen, unendlichen Waits. Markieren, ob sie im Main Thread auftreten.
  2. UI-Übergaben zählen: Loggen Sie „queued/post“, „call begin“, „call done“, inkl. Thread-ID und Correlation-ID des Jobs.
  3. Lock-Haltezeiten messen: Im Debug-Build bei Enter/Leave Zeit 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.

Beitrag teilen

Diesen Beitrag direkt weitergeben

LinkedIn, X, XING, Facebook, WhatsApp und E-Mail sind sofort verfügbar. für Instagram bereiten wir Link und Kurztext direkt vor.

E-Mail

Instagram oeffnet in einem neuen Tab. Link und Kurztext werden vorher in die Zwischenablage kopiert.