Net-Base Magazine

15.05.2026

TThread and Synchronize without UI deadlocks: robust patterns for VCL and legacy code

How to work reliably with TThread, Synchronize and Queue without the UI freezing: typical causes of deadlocks, a practical UI-dispatcher pattern (incl. timeout), shutdown protection, lock strategies and debugging checks for evolved Delphi applications.

15.05.2026

Anyone working with threads in Delphi sooner or later ends up at TThread.Synchronize. And that is exactly where the unpleasant things happen: sporadic freezes, „UI not responding“, apparently random deadlocks when closing or when opening a dialog. The root cause is rarely „Delphi is broken“; it is almost always an unfortunate mix of Synchronize, blocking wait operations and a UI thread that no longer cleanly processes its Message Loop (the VCL’s event processing). This article shows robust, legacy-practical patterns for TThread and Synchronize without UI deadlocks – including a timeout variant, clean error propagation, shutdown rules and debugging hints that help in real, in-place applications.

Why deadlocks around Synchronize occur in practice

Synchronize means: a worker thread places a procedure into a queue that is executed in the Main Thread, and typically waits until that procedure has finished. In VCL applications the Main Thread is also the UI thread (windows, controls, events). Additionally, in many installations there are COM objects running in the STA model (Single-Threaded Apartment: COM calls must be processed in the same thread), which increases the dependency on a functioning Message Loop.

Deadlocks typically arise from one of these constellations:

  • WaitFor in the Main Thread: the UI thread waits for a worker (e.g. MyThread.WaitFor), while the worker currently needs the UI thread via Synchronize. Both wait – end of story.
  • Lock inversion: the worker holds a lock (e.g. TCriticalSection or TMonitor) and calls Synchronize. The synchronized UI procedure tries to take the same lock (directly or indirectly, often via logging/cache/singletons) – classic deadlock.
  • Shutdown/Destroy: when a form is closed a thread is terminated while Synchronize tasks are still pending. Particularly nasty: synchronized calls reference controls that are currently being destroyed.
  • Message Loop blocked: modal dialogs, long-running UI operations, a blocking COM call or a handler that „just quickly“ performs DB/REST work hold the Main Thread. Synchronize tasks are processed late or not at all.

The most important consequence for architecture and operations: Synchronize is a blocking boundary. In custom enterprise software with imports, BDE-Ablosung mit nativer Anbindung-Queries, interface jobs or background services with a UI component this boundary should be consciously controlled – otherwise „rarely“ will eventually become „always when it is urgent“.

Basic rule: never let the UI thread wait on a worker (when Synchronize is involved)

If a worker uses Synchronize anywhere, the Main Thread should not wait on that worker in a hard-blocking way. That sounds trivial, but in legacy code it is one of the most common causes, because „let’s wait a bit on shutdown“ or „the progress dialog waits for completion“ gets added quickly.

Practical consequences:

  • No WaitFor calls on the UI thread once there is a path in the worker that uses Synchronize.
  • Signal thread completion via event/callback: the UI remains responsive and only performs cleanup after the signal.
  • Post UI updates by default via TThread.Queue or a dispatcher so workers do not block.

TThread.Queue is often the better default option: the worker posts work to the Main Thread, continues running and does not block. That prevents many deadlocks. It does not, however, solve every edge case—for example when a worker strictly requires a result produced on the Main Thread (e.g. access to a UI-bound resource or a component that is thread-affine).

TThread and Synchronize without UI deadlocks: mental model for clean handoffs

A robust mental model is: there are only a few legitimately synchronous handoffs to the Main Thread. Everything else is state, presentation, or telemetry—and therefore asynchronous.

A simple classification helps in reviews and when stabilizing legacy projects:

  • “Display only”: progress, log line, counter, traffic light, enable/disable — always Queue.
  • “Pass state”: worker supplies a data object/DTO, UI renders — Queue, but with copy/immutability (i.e. no shared mutable structures).
  • “UI must decide”: only here do you need synchronous semantics (e.g. a user prompt). The actual question then is: does a worker really have to wait, or can the workflow be restructured (state machine, cancel job, resume later)?

The third category in particular is a deadlock trap: if the worker waits for a UI result, the UI is easily tempted to wait for the worker (or indirectly via locks). This breaks down much more readily under load, with slow databases, or in remote-desktop environments.

Source snippet: UI dispatcher with Queue, optional timeout and clean shutdown

The following pattern encapsulates UI handoffs in a small helper class. You get:

  • Post: fire-and-forget via TThread.Queue (typical for status updates).
  • Call: synchronous call with Timeout (uncommon, but useful in legacy situations), without using Synchronize directly as a blocking point.
  • Shutdown protection: accept no new UI jobs, and queued jobs check a flag before touching controls.

Technical classification: we use Queue plus TEvent (a kernel event) for feedback. The worker does not wait on Synchronize, but on an event that the Main Thread sets after the queued action has executed. The timeout prevents indefinite hanging if the UI thread for some reason no longer processes the queue.

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.

Purpose of the code and where it is intentionally „unusual“

The pattern does not completely replace Synchronize, but it makes synchronous handovers controllable: the worker does not wait on the Synchronize mechanism, but on an event. That allows you to enforce timeouts, make it observable in operation that the UI thread is hung, and consistently reject new UI jobs during a shutdown phase.

The „unusual“ part is not the event itself, but the decision to represent synchronous semantics using Queue + Event. This approach is worthwhile precisely when you need to incrementally retrofit stability into legacy applications without having to refactor every Synchronize site architecturally at once.

Constraints and pitfalls

  • Memory visibility: DoneEvent is the synchronization boundary. This makes reading RaisedObj after WaitFor consistent. Nevertheless, RaisedObj should remain local per call (as shown here), never global.
  • Exception-Handling: AcquireExceptionObject prevents the exception from ‚disappearing‘ in the main thread. When rethrowing in the worker the stacktrace is not identical to the origin, but the error message remains in the worker log, and the job can fail cleanly.
  • Timeout ist Diagnose und Schutz: A timeout is both diagnostic and protective. It does not ‚fix‘ a blocked main thread. However, it prevents workers from holding resources indefinitely (e.g. BDE-Ablosung mit nativer Anbindung-transactions open), and it makes the class of error measurable.
  • Shutdown muss früh beginnen: BeginShutdown belongs in a central shutdown sequence (e.g. very early in the main form’s OnCloseQuery). Otherwise UI jobs may still be queued while windows are already destroyed.

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

Many deadlocks are not caused by WaitFor but by an unclear lock order. Typical sequence: a worker locks the ‚data model‘, calls a UI update via Synchronize, and the UI update again accesses the ‚data model‘. That is logically understandable, but technically fatal.

Practical rules that can be adopted by teams:

  • Keine Locks über Thread-Grenzen halten: Before a worker queues/synchronizes anything toward the UI, domain locks should be released.
  • UI liest Snapshots: UI callbacks should not look ‚live‘ into worker structures, but display copies/snapshots (e.g. DTOs, records, simple values).
  • Logging ist ein Lock-Kandidat: If logging internally uses a queue, file lock or a singleton, it can become part of a deadlock. UI callbacks should keep logging minimal or write via a separate, non-blocking log pipeline.

If you already have a Layer-3 architecture (UI, services/domain, infrastructure such as data access): UI callbacks should ideally only perform UI work. Anything that is ’service‘ does not belong in the callback. That significantly reduces reentrancy effects.

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

When shutting down it often goes wrong: the UI closes, a thread should exit, but queued UI jobs are still pending. A clean shutdown is less ‚killing a thread‘ and more a small choreography:

  1. Shutdown-Flag setzen (e.g. TUiDispatcher.BeginShutdown): from now on no new UI jobs.
  2. Worker kooperativ stoppen: The worker checks a cancel flag (e.g. TEvent or similar to TCancellationToken) and terminates loops/waits.
  3. UI nicht blockieren: No hard wait loop in the main thread. If you ‚must wait‘, do so only with a running message loop (or better: avoid it entirely by handling completion via a callback).
  4. Letzte UI-Aufräumarbeiten only when windows/controls are guaranteed to still exist. In VCL timing is important: at the latest, once the handle is gone, queued jobs must no longer target controls.

This procedure is relevant for operations and support: ‚the application hangs on close‘ is a classic acceptance problem, even though everything was processed correctly from a domain perspective. A defined shutdown saves real time here.

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

When it hangs, the core question is: Who is waiting for whom? A few approaches that have proven useful in legacy projects:

  • Inventory all wait sites: Full-text search for WaitFor, Sleep in loops, TEvent.WaitFor, INFINITE. Many issues are “hidden” waits (also inside libraries).
  • Log thread state: Log at thread boundaries: “Job starts”, “queued UI”, “UI executed”, “Job finished”. That shows whether the main thread is actually processing queued jobs.
  • Check for message-loop issues: If the hang occurs only with modal dialogs or certain COM interactions, the message loop is often the bottleneck. The goal then is: relieve UI handlers, isolate COM calls, and avoid long operations in the UI.
  • Make locks visible: For TCriticalSection/TMonitor a debug build with owner metadata (e.g. thread ID on Enter) and timing measurements is worthwhile. This shows which lock the main thread currently holds while workers wait on the UI.

The important mindset is: deadlocks are seldom “accidental”. They are deterministic cycles that are only rarely triggered. Once you have identified the cycle cleanly, the fix is usually straightforward.

Variants for data access and interface jobs (FireDAC, REST, file system)

Especially with FireDAC (or other DB accesses) the rule is: connection, transaction and datasets are in practice thread-bound. A worker thread should exclusively own its DB context. UI calls should be limited to presentation, not DB operations. A robust pattern is:

  1. The worker executes the query/REST call, computes the result and produces a DTO.
  2. The worker posts the DTO via Queue/TUiDispatcher.Post to the UI.
  3. The UI consumes the DTO and updates controls (without referring back to worker objects).

If you have historically grown mixed forms (“UI triggers DB, DB callback triggers UI”), a stepwise decoupling is worthwhile: first isolate handover points (dispatcher), then shift states into services/model. This is less risky than a large refactor but noticeably reduces deadlocks.

Conclusion: avoiding deadlocks means controlling handoffs

TThread and Synchronize without UI deadlocks is less a single technique than a discipline: minimize blocking, keep lock ordering clean, define shutdown and reduce synchronous UI dependencies. The UI dispatcher shown is particularly useful in legacy situations because it uses Queue as the default, yet adds Timeout and clear shutdown rules for necessary synchronous handoffs.

Limits remain: if the main thread is permanently blocked (by heavyweight UI logic, modal dialog chains or COM-STA calls), even a dispatcher can only diagnose and abort in a controlled way. The sustainable solution is to relieve the UI and separate responsibilities. If you need support for that in an existing Delphi application — from threading pitfalls to stepwise stabilization — you can classify the undertaking here: discuss project or modernization initiative with Net-Base.

In the operational context, Delphi multithreading and Synchronize deadlocks also play an important role when integrations, data flows and ongoing development must interact cleanly.

Discuss project or modernization initiative with Net-Base.

Share post

Share this post directly

LinkedIn, X, XING, Facebook, WhatsApp and email are available immediately. For Instagram, we will prepare the link and a short caption immediately.

Email

Instagram opens in a new tab. The link and short text are copied to the clipboard beforehand.