Net-Base Magazin

15.05.2026

TThread und Synchronize ohne UI-Deadlocks: robuste Muster für VCL und Legacy-Code

Wie Sie mit TThread, Synchronize und Queue zuverlässig arbeiten, ohne dass die UI hängt: typische Deadlock-Ursachen, ein praxistaugliches UI-Dispatcher-Muster (inkl. Timeout), Shutdown-Schutz, Lock-Strategien und Debugging-Checks für gewachsene Delphi-Anwendungen.

15.05.2026

Wer in Delphi mit Threads arbeitet, landet früher oder später bei TThread.Synchronize. Und genau dort passieren die unangenehmen Dinge: sporadische Hänger, „UI reagiert nicht“, scheinbar zufällige Deadlocks beim Beenden oder beim Öffnen eines Dialogs. Der Kern ist selten „Delphi ist kaputt“, sondern fast immer ein ungünstiger Mix aus Synchronize, blockierenden Warteoperationen und einem UI-Thread, der seine Message Loop (die Ereignisverarbeitung der VCL) nicht mehr sauber abarbeitet. Dieser Beitrag zeigt robuste, im Legacy-Kontext praktikable Muster für TThread und Synchronize ohne UI-Deadlocks – inklusive Timeout-Variante, sauberer Fehlerweitergabe, Shutdown-Regeln und Debugging-Hinweisen, die in echten Bestandsanwendungen helfen.

Warum Deadlocks rund um Synchronize in der Praxis entstehen

Synchronize bedeutet: Ein Worker-Thread stellt eine Prozedur in eine Warteschlange, die im Main Thread ausgeführt wird, und wartet typischerweise, bis diese Prozedur fertig ist. In VCL-Anwendungen ist der Main Thread gleichzeitig der UI-Thread (Fenster, Controls, Ereignisse). Zusätzlich laufen in vielen Installationen dort COM-Objekte im STA-Modell (Single-Threaded Apartment: COM-Aufrufe müssen im selben Thread verarbeitet werden), was die Abhängigkeit von einer funktionierenden Message Loop noch verstärkt.

Deadlocks entstehen typischerweise durch eine dieser Konstellationen:

  • WaitFor im Main Thread: Der UI-Thread wartet auf einen Worker (z. B. MyThread.WaitFor), während der Worker gerade via Synchronize den UI-Thread braucht. Beide warten – Ende.
  • Lock-Inversion: Der Worker hält ein Lock (z. B. TCriticalSection oder TMonitor) und ruft Synchronize. Die synchronisierte UI-Prozedur versucht, dasselbe Lock zu nehmen (direkt oder indirekt, oft über Logging/Cache/Singletons) – klassischer Deadlock.
  • Shutdown/Destroy: Beim Schließen einer Form wird ein Thread beendet, während noch Synchronize-Aufgaben ausstehen. Besonders fies: synchronisierte Aufrufe referenzieren Controls, die gerade zerstört werden.
  • Message Loop blockiert: Modale Dialoge, langlaufende UI-Operationen, ein blockierender COM-Aufruf oder ein Handler, der „mal eben“ DB/REST macht, halten den Main Thread fest. Synchronize-Aufgaben werden verspätet oder gar nicht abgearbeitet.

Die wichtigste Konsequenz für Architektur und Betrieb: Synchronize ist eine Blockade-Kante. In individueller Unternehmenssoftware mit Importen, BDE-Ablosung mit nativer Anbindung-Queries, Schnittstellen-Jobs oder Hintergrunddiensten mit UI-Komponente sollte diese Kante bewusst kontrolliert werden – sonst wird aus „selten“ irgendwann „immer dann, wenn es eilig ist“.

Grundregel: UI-Thread nie auf Worker warten lassen (wenn Synchronize im Spiel ist)

Wenn ein Worker irgendwo Synchronize verwendet, sollte der Main Thread nicht hart blockierend auf diesen Worker warten. Das klingt trivial, ist aber in Legacy-Code eine der häufigsten Ursachen, weil „warten wir kurz beim Schließen“ oder „Progress-Dialog wartet auf Ende“ schnell eingebaut ist.

Praktische Konsequenzen:

  • Keine WaitFor-Aufrufe im UI-Thread, sobald im Worker ein Pfad existiert, der Synchronize nutzt.
  • Thread-Abschluss per Event/Callback signalisieren: UI bleibt responsiv, räumt erst nach Signal auf.
  • UI-Updates grundsätzlich über TThread.Queue oder einen Dispatcher posten, damit Worker nicht blockieren.

TThread.Queue ist häufig die bessere Default-Option: Der Worker postet Arbeit an den Main Thread, läuft weiter und blockiert nicht. Das verhindert viele Deadlocks. Es löst aber nicht alle Randfälle – etwa wenn Sie in einem Worker zwingend ein Ergebnis benötigen, das im Main Thread erzeugt wird (z. B. Zugriff auf eine UI-gebundene Ressource oder eine Komponente, die threadgebunden ist).

TThread und Synchronize ohne UI-Deadlocks: Denkmodell für saubere Übergaben

Ein belastbares Denkmodell ist: Es gibt nur wenige legitim synchrone Übergaben in den Main Thread. Alles andere ist Status, Darstellung oder Telemetrie – und damit asynchron.

Eine einfache Einteilung hilft in Reviews und bei der Stabilisierung von Bestandsprojekten:

  • „Nur anzeigen“: Progress, Logzeile, Zähler, Ampel, Enable/Disable – immer Queue.
  • „Zustand übergeben“: Worker liefert Datenobjekt/DTO, UI rendert – Queue, aber mit Copy/Immutability (also keine gemeinsam mutierten Strukturen).
  • „UI muss entscheiden“: Nur hier brauchen Sie synchrone Semantik (z. B. Benutzerabfrage). Dann ist die eigentliche Frage: Muss wirklich ein Worker warten, oder kann der Workflow umgebaut werden (State Machine, Job abbrechen, später fortsetzen)?

Gerade die dritte Kategorie ist eine Deadlock-Falle: Wenn der Worker auf ein UI-Ergebnis wartet, wird die UI schnell dazu verleitet, auf den Worker zu warten (oder indirekt über Locks). Das kippt unter Last, bei langsamen Datenbanken oder bei Remote-Desktop-Umgebungen deutlich eher.

Source-Schnipsel: UI-Dispatcher mit Queue, optionalem Timeout und sauberem Shutdown

Das folgende Muster kapselt UI-Übergaben in eine kleine Hilfsklasse. Sie bekommen:

  • Post: Fire-and-forget über TThread.Queue (typisch für Statusupdates).
  • Call: Synchronous Call mit Timeout (ungewöhnlich, aber in Legacy-Situationen hilfreich), ohne direkt Synchronize als Blockadepunkt zu verwenden.
  • Shutdown-Schutz: Keine neuen UI-Jobs mehr annehmen, und queued Jobs prüfen einen Flag, bevor Controls angefasst werden.

Technische Einordnung: Wir nutzen Queue plus TEvent (ein Kernel-Event) zur Rückmeldung. Der Worker wartet nicht auf Synchronize, sondern auf ein Event, das im Main Thread gesetzt wird, nachdem die queued Action ausgeführt wurde. Das Timeout verhindert „ewiges“ Hängen, wenn der UI-Thread aus irgendeinem Grund nicht mehr zum Abarbeiten kommt.

Delphi
unit UiDispatch;

interface

uses
  System.SysUtils,
  System.Classes,
  System.SyncObjs;

type
  EUiDispatchTimeout = class(Exception);
  EUiDispatchShuttingDown = class(Exception);

  /// <summary>
  ///  Kapselt UI-Aufrufe aus Worker-Threads.
  ///  Post: asynchron (Queue).
  ///  Call: synchron mit Timeout, ohne TThread.Synchronize direkt zu blocken.
  /// </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

{ TUiDispatcher }

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;

  // Im Shutdown keine neuen UI-Jobs mehr annehmen.
  if IsShuttingDown then
    Exit;

  // Queue blockiert den Worker nicht.
  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 über die Thread-Grenze reichen.
          // Achtung: Kein "raise" hier, sonst landet es im Main Thread.
          RaisedObj := AcquireExceptionObject;
        end;
        DoneEvent.SetEvent;
      end);

    case DoneEvent.WaitFor(ATimeoutMs) of
      wrSignaled:
        begin
          if Assigned(RaisedObj) then
            raise Exception(RaisedObj);
        end;
      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 des Codes und wo er bewusst „ungewöhnlich“ ist

Das Muster ersetzt Synchronize nicht komplett, aber es macht synchrone Übergaben kontrollierbar: Der Worker wartet nicht auf die Synchronize-Mechanik, sondern auf ein Event. Damit können Sie Timeouts erzwingen, im Betrieb sichtbar machen, dass der UI-Thread hängt, und in einer Shutdown-Phase neue UI-Jobs konsequent abweisen.

Der „ungewöhnliche“ Teil ist nicht das Event, sondern die Entscheidung, synchrone Semantik mit Queue + Event abzubilden. Das lohnt sich genau dann, wenn Sie in Bestandsanwendungen schrittweise Stabilität nachrüsten müssen, ohne jede Synchronize-Stelle sofort architektonisch umbauen zu können.

Randbedingungen und Stolperfallen

  • Speichersichtbarkeit: DoneEvent ist die Synchronisationskante. Dadurch ist das Lesen von RaisedObj nach WaitFor konsistent. Trotzdem sollte RaisedObj lokal pro Call bleiben (wie hier), nie global.
  • Exception-Handling: AcquireExceptionObject verhindert, dass die Exception im Main Thread „verschwindet“. Beim erneuten Werfen im Worker ist der Stacktrace nicht identisch zum Ursprung, aber die Fehlermeldung bleibt im Worker-Log, und der Job kann sauber fehlschlagen.
  • Timeout ist Diagnose und Schutz: Er „repariert“ keinen blockierten Main Thread. Er verhindert aber, dass Worker unbegrenzt Ressourcen binden (z. B. BDE-Ablosung mit nativer Anbindung-Transaktionen offen halten), und er macht die Fehlerklasse messbar.
  • Shutdown muss früh beginnen: BeginShutdown gehört in eine zentrale Shutdown-Sequenz (z. B. sehr früh in OnCloseQuery der Hauptform). Sonst werden noch UI-Jobs queued, während Fenster bereits zerstört werden.

Lock-Strategie: so vermeiden Sie Lock-Inversionen mit UI-Callbacks

Viele Deadlocks entstehen nicht durch WaitFor, sondern durch eine unklare Lock-Reihenfolge. Typischer Ablauf: Worker lockt „Datenmodell“, ruft UI-Update per Synchronize, UI-Update greift wieder auf „Datenmodell“ zu. Das ist logisch nachvollziehbar, aber technisch fatal.

Praktische Regeln, die sich in Teams durchsetzen lassen:

  • Keine Locks über Thread-Grenzen halten: Bevor ein Worker irgendetwas in Richtung UI queued/synchronisiert, sollten fachliche Locks freigegeben sein.
  • UI liest Snapshots: UI-Callbacks sollten nicht „live“ in Worker-Strukturen schauen, sondern Kopien/Snapshots anzeigen (z. B. DTO, Record, einfache Werte).
  • Logging ist ein Lock-Kandidat: Wenn Logging intern eine Queue, Datei-Lock oder ein Singleton verwendet, kann es Teil eines Deadlocks werden. UI-Callbacks sollten Logging minimal halten oder über eine separate, nicht-blockierende Log-Pipeline schreiben.

Wenn Sie schon eine Layer-3-Architektur (UI, Services/Domäne, Infrastruktur wie Datenzugriff) haben: UI-Callbacks dürfen idealerweise nur UI machen. Alles, was „Service“ ist, gehört nicht in den Callback. Das reduziert Reentrancy-Effekte deutlich.

Shutdown ohne Hänger: „nicht WaitFor, sondern kooperatives Stoppen“

Beim Beenden kippt es oft: Die UI schließt, ein Thread soll weg, aber queued UI-Jobs sind noch offen. Ein sauberer Shutdown ist weniger „Thread killen“, sondern eine kleine Choreografie:

  1. Shutdown-Flag setzen (z. B. TUiDispatcher.BeginShutdown): Ab jetzt keine neuen UI-Jobs mehr.
  2. Worker kooperativ stoppen: Der Worker prüft ein Cancel-Flag (z. B. TEvent oder TCancellationToken-ähnlich) und beendet Schleifen/Waits.
  3. UI nicht blockieren: Keine harte Warte-Schleife im Main Thread. Wenn Sie „warten müssen“, dann nur mit weiterlaufender Message Loop (oder besser: ganz vermeiden, indem Sie den Abschluss per Callback behandeln).
  4. Letzte UI-Aufräumarbeiten nur, wenn Fenster/Controls garantiert noch existieren. In VCL ist der Zeitpunkt wichtig: spätestens wenn das Handle weg ist, dürfen queued Jobs nicht mehr auf Controls gehen.

Dieser Ablauf ist für Betrieb und Support relevant: „Die Anwendung hängt beim Schließen“ ist ein klassisches Akzeptanzproblem, obwohl fachlich alles korrekt verarbeitet wurde. Ein definierter Shutdown spart hier real Zeit.

Debugging: Wie Sie den Deadlock greifbar machen (ohne Rätselraten)

Wenn es hängt, ist die Kernfrage: Wer wartet auf wen? Ein paar Ansätze, die sich in Bestandsprojekten bewähren:

  • Alle Wait-Stellen inventarisieren: Volltextsuche nach WaitFor, Sleep in Schleifen, TEvent.WaitFor, INFINITE. Viele Probleme sind „versteckte“ Waits (auch in Bibliotheken).
  • Thread-Zustand im Log: Loggen Sie an Thread-Grenzen: „Job startet“, „queued UI“, „UI ausgeführt“, „Job fertig“. Damit sehen Sie, ob der Main Thread queued Jobs überhaupt abarbeitet.
  • Message-Loop-Verdacht prüfen: Tritt der Hänger nur bei modalen Dialogen oder bestimmten COM-Interaktionen auf, ist die Message Loop oft der Flaschenhals. Dann ist das Ziel: UI-Handler entlasten, COM-Aufrufe isolieren, keine langen Operationen im UI.
  • Locks sichtbar machen: Bei TCriticalSection/TMonitor lohnt sich ein Debug-Build mit „Owner“-Metadaten (z. B. Thread-ID beim Enter) und zeitlicher Messung. So sehen Sie, welches Lock der Main Thread gerade hält, während Worker auf UI wartet.

Wichtig ist die Haltung: Deadlocks sind selten „zufällig“. Sie sind deterministische Zyklen, die nur selten ausgelöst werden. Wenn Sie den Zyklus einmal sauber identifiziert haben, ist die Behebung meist klar.

Varianten für Datenzugriff und Schnittstellen-Jobs (FireDAC, REST, Dateisystem)

Gerade bei FireDAC (oder anderen DB-Zugriffen) gilt: Verbindung, Transaktion und Datasets sind in der Praxis threadgebunden. Ein Worker-Thread sollte seinen DB-Kontext ausschließlich selbst besitzen. UI-Aufrufe sollten sich auf Darstellung beschränken, nicht auf DB-Operationen. Ein robustes Muster ist:

  1. Worker führt Query/REST-Call aus, berechnet Ergebnis, erzeugt DTO.
  2. Worker postet DTO via Queue/TUiDispatcher.Post an die UI.
  3. UI übernimmt DTO und aktualisiert Controls (ohne Rückgriff auf Worker-Objekte).

Wenn Sie historisch gewachsene Mischformen haben („UI triggert DB, DB-Callback triggert UI“), lohnt sich eine schrittweise Entkopplung: Erst Übergabepunkte isolieren (Dispatcher), dann Zustände in Services/Model verlagern. Das ist weniger riskant als ein großer Umbau, aber reduziert Deadlocks spürbar.

Fazit: Deadlocks vermeiden heißt Übergaben kontrollieren

TThread und Synchronize ohne UI-Deadlocks ist weniger eine einzelne Technik als Disziplin: Blockaden minimieren, Lock-Reihenfolgen sauber halten, Shutdown definieren und synchrone UI-Abhängigkeiten reduzieren. Der gezeigte UI-Dispatcher ist in Legacy-Situationen besonders nützlich, weil er Queue als Default nutzt, für notwendige synchrone Übergaben aber Timeout und klare Shutdown-Regeln nachrüstet.

Einsatzgrenzen bleiben: Wenn der Main Thread dauerhaft blockiert (durch schwergewichtige UI-Logik, modale Dialogketten oder COM-STA-Aufrufe), kann auch ein Dispatcher nur diagnostizieren und kontrolliert abbrechen. Die nachhaltige Lösung ist dann, die UI zu entlasten und Verantwortlichkeiten zu trennen. Wenn Sie dafür in einer bestehenden Delphi-Anwendung Unterstützung brauchen – von Threading-Fallen bis zur schrittweisen Stabilisierung – können Sie das Vorhaben hier einordnen: Projekt oder Modernisierungsvorhaben mit Net-Base besprechen.

Im fachlichen Umfeld spielen auch Delphi Multithreading und Synchronize Deadlock eine wichtige Rolle, wenn Integrationen, Datenflüsse und Weiterentwicklung sauber zusammenspielen müssen.

Projekt oder Modernisierungsvorhaben mit Net-Base besprechen.

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.