Net-Base Magazine

15.05.2026

TThread en Synchronize zonder UI-Deadlocks: robuuste patronen voor VCL en legacy-code

Hoe u betrouwbaar met TThread, Synchronize en Queue kunt werken zonder dat de UI vastloopt: typische oorzaken van deadlocks, een praktijkgeschikt UI-dispatcherpatroon (incl. timeout), bescherming tegen shutdown, lockstrategieën en debuggingchecks voor gegroeide Delphi-toepassingen.

15.05.2026

Wie iemand die in Delphi met Threads werkt, komt vroeg of laat bij TThread.Synchronize terecht. En juist daar gebeuren de vervelende dingen: sporadische vastlopers, „UI reageert niet“, ogenschijnlijk willekeurige deadlocks bij afsluiten of bij het openen van een dialoog. De kern is zelden „Delphi is kapot“, maar bijna altijd een ongunstige mix van Synchronize, blokkerende wachtoperaties en een UI-thread die zijn Message Loop (de gebeurtenisverwerking van de VCL) niet meer netjes afhandelt. Dit artikel toont robuuste, in legacy-context toepasbare patronen voor TThread und Synchronize ohne UI-Deadlocks – inclusief timeout-variant, nette foutdoorvoer, shutdown-regels en debugging-tips die in echte bestaande applicaties helpen.

Waarom deadlocks rond Synchronize in de praktijk ontstaan

Synchronize betekent: een worker-thread zet een procedure in een wachtrij die in de main thread wordt uitgevoerd, en wacht doorgaans totdat die procedure klaar is. In VCL-applicaties is de main thread tegelijk de UI-thread (vensters, controls, events). Daarnaast draaien in veel installaties daar COM-objecten in het STA-Modell (Single-Threaded Apartment: COM-aanroepen moeten in dezelfde thread verwerkt worden), wat de afhankelijkheid van een werkende Message Loop verder versterkt.

Deadlocks ontstaan typisch door een van deze constellaties:

  • WaitFor im Main Thread: De UI-thread wacht op een worker (bijv. MyThread.WaitFor), terwijl de worker juist via Synchronize de UI-thread nodig heeft. Beide wachten – einde.
  • Lock-Inversion: De worker houdt een lock vast (bijv. TCriticalSection of TMonitor) en roept Synchronize aan. De gesynchroniseerde UI-procedure probeert hetzelfde lock te nemen (direct of indirect, vaak via logging/cache/singletons) – klassieke deadlock.
  • Shutdown/Destroy: Bij het sluiten van een form wordt een thread beëindigd terwijl er nog Synchronize-taken in de wachtrij staan. Extra hinderlijk: gesynchroniseerde aanroepen refereren controls die net worden vernietigd.
  • Message Loop blockiert: Modale dialogen, langdurige UI-operaties, een blokkerende COM-aanroep of een handler die “even” DB/REST doet, houden de main thread vast. Synchronize-taken worden vertraagd of helemaal niet verwerkt.

De belangrijkste consequentie voor architectuur en operatie: Synchronize is een blokkader. In individuele bedrijfssoftware met imports, BDE-Ablosung mit nativer Anbindung-Queries, interface-jobs of achtergronddiensten met een UI-component moet deze rand expliciet gecontroleerd worden – anders wordt van „zelden“ uiteindelijk „altijd wanneer het dringend is”.

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

Als een worker ergens Synchronize gebruikt, mag de main thread niet hard blockerend op die worker wachten. Dat klinkt triviaal, maar in legacy-code is dit een van de meest voorkomende oorzaken, omdat „laten we even wachten bij het sluiten“ of „progress-dialoog wacht op einde“ snel wordt ingebouwd.

Praktische Konsequenzen:

  • Geen WaitFor-aanroepen in de UI-thread zodra er in de Worker een pad bestaat dat Synchronize gebruikt.
  • Thread-afsluiting per Event/Callback signaleren: de UI blijft responsief en ruimt pas op na ontvangst van het signaal.
  • UI-updates in principe via TThread.Queue of een dispatcher posten, zodat Workers niet blokkeren.

TThread.Queue is vaak de betere default-optie: de Worker post werk aan de Main Thread, loopt door en blokkeert niet. Dat voorkomt veel deadlocks. Het lost echter niet alle randgevallen op – bijvoorbeeld als u in een Worker verplicht een resultaat nodig heeft dat in de Main Thread wordt gegenereerd (bijv. toegang tot een UI-gebonden resource of een component die aan een thread gebonden is).

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

Een robuust denkmodel is: er zijn maar weinig legitieme synchrone overdrachten naar de Main Thread. Alles anders is status, presentatie of telemetrie – en dus asynchroon.

Een eenvoudige indeling helpt bij reviews en bij het stabiliseren van bestaande projecten:

  • „Alleen weergeven“: voortgang, logregel, teller, statusindicator, inschakelen/uitschakelen – altijd Queue.
  • „Status overdragen“: Worker levert dataobject/DTO, UI rendert – Queue, maar met copy/immutability (dus geen gedeeld gemuteerde structuren).
  • „UI moet beslissen“: alleen hier heeft u synchrone semantiek nodig (bijv. gebruikersvraag). Dan is de werkelijke vraag: moet een Worker echt wachten, of kan de workflow anders worden ingericht (state machine, job afbreken, later hervatten)?

Juist de derde categorie is een deadlock-val: als de Worker op een UI-resultaat wacht, wordt de UI snel verleid om op de Worker te wachten (of indirect via locks). Dat leidt onder belasting, bij trage databases of in Remote-Desktop-omgevingen veel eerder tot problemen.

Broncodevoorbeeld: UI-dispatcher met Queue, optionele timeout en ordelijke shutdown

Het volgende patroon kapselt UI-overdrachten in een kleine helperklasse. U krijgt:

  • Post: fire-and-forget via TThread.Queue (typisch voor statusupdates).
  • Call: synchrone aanroep met Timeout (ongebruikelijk, maar nuttig in legacy-situaties), zonder direct Synchronize als blokkeringspunt te gebruiken.
  • Shutdown-bescherming: geen nieuwe UI-jobs meer accepteren, en queued jobs controleren een vlag voordat controls worden aangeraakt.

Technische positionering: we gebruiken Queue plus TEvent (een kernel-event) voor terugmelding. De Worker wacht niet op Synchronize, maar op een Event dat in de Main Thread wordt gezet nadat de queued action is uitgevoerd. De Timeout voorkomt „eeuwig“ vastlopen als de UI-thread om welke reden dan ook niet meer toekomt aan het afhandelen.

Delphi
unit UiDispatch;

interface

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

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

  /// <summary>
  ///  Omvat UI-aanroepen vanuit worker-threads.
  ///  Post: asynchroon (Queue).
  ///  Call: synchron met timeout, zonder TThread.Synchronize direct te blokkeren.
  /// </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;

  // Tijdens shutdown geen nieuwe UI-Jobs meer accepteren.
  if IsShuttingDown then
    Exit;

  // De Queue blokkeert de Worker niet.
  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 bevindt zich in Shutdown.');

  DoneEvent := TEvent.Create(nil, True, False, '');
  try
    RaisedObj := nil;

    TThread.Queue(nil,
      procedure
      begin
        try
          if not IsShuttingDown then
            AProc();
        except
          // Het Exception-object over de thread-grens doorgeven.
          // Let op: geen "raise" hier, anders belandt het in de 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 na %d ms: Main Thread heeft de UI-aanroep niet afgehandeld.',
          [ATimeoutMs]);
    else
      raise Exception.Create('Onverwachte WaitFor-status in de UI-Dispatcher.');
    end;
  finally
    DoneEvent.Free;
  end;
end;

end.

Doel van de code en waar het bewust „ongewoon“ is

Het patroon vervangt Synchronize niet volledig, maar het maakt synchrone overdragingen controleerbaar: de Worker wacht niet op het Synchronize-mechanisme, maar op een Event. Daarmee kunt u timeouts afdwingen, tijdens de operatie zichtbaar maken dat de UI-thread vastloopt, en in een shutdown-fase nieuwe UI-Jobs consequent afwijzen.

Het „ongebruikelijke“ deel is niet het Event, maar de beslissing om synchrone semantiek met Queue + Event te modelleren. Dat loont precies wanneer u in bestaande applicaties stap voor stap stabiliteit wilt inbouwen, zonder elke Synchronize-locatie meteen architectonisch te moeten aanpassen.

Randvoorwaarden en valkuilen

  • Geheugenzichtbaarheid: DoneEvent is de synchronisatiegrens. Daardoor is het lezen van RaisedObj na WaitFor consistent. Toch moet RaisedObj lokaal per Call blijven (zoals hier), nooit globaal.
  • Exception-afhandeling: AcquireExceptionObject voorkomt dat de uitzondering in de Main Thread „verdwijnt“. Bij opnieuw werpen in de Worker is de stacktrace niet identiek aan de oorsprong, maar de foutmelding blijft in het Worker-Log staan en de taak kan netjes falen.
  • Timeout is diagnose en bescherming: Het „repareert“ geen geblokkeerde Main Thread. Het voorkomt echter dat Worker onbeperkt resources vasthouden (bijv. BDE-Ablosung mit nativer Anbindung-transacties open houden), en het maakt de foutklasse meetbaar.
  • Shutdown moet vroeg beginnen: BeginShutdown hoort in een centrale shutdown-sequentie (bijv. zeer vroeg in OnCloseQuery van het hoofdformulier). Anders worden er nog UI-Jobs in de wachtrij gezet terwijl vensters al zijn vernietigd.

Lock-strategie: zo voorkomt u lock-inversies met UI-Callbacks

Veel deadlocks ontstaan niet door WaitFor, maar door een onduidelijke lock-volgorde. Typisch verloop: een Worker vergrendelt het gegevensmodel, roept een UI-update via Synchronize aan, de UI-update gaat vervolgens weer naar het gegevensmodel. Dat is logisch te begrijpen, maar technisch fataal.

Praktische regels die teams kunnen handhaven:

  • Geen locks over thread-grenzen vasthouden: Voordat een Worker iets richting UI in de wachtrij zet / synchroniseert, moeten functionele locks zijn vrijgegeven.
  • UI leest snapshots: UI-Callbacks mogen niet „live“ in Worker-structuren kijken, maar moeten kopieën/snapshots tonen (bijv. DTO, Record, eenvoudige waarden).
  • Logging is een lock-kandidaat: Als Logging intern een queue, bestandsslot of een Singleton gebruikt, kan het deel van een deadlock worden. UI-Callbacks moeten Logging tot een minimum beperken of via een aparte, niet-blokkerende log-pijplijn schrijven.

Als u al een Layer-3-architectuur heeft (UI, Services/Domäne, infrastructuur zoals gegevenstoegang): UI-Callbacks mogen idealiter alleen UI doen. Alles wat „Service“ is, hoort niet in de callback. Dat vermindert reentrancy-effecten aanzienlijk.

Shutdown zonder vastlopers: „niet WaitFor, maar coöperatief stoppen“

Bij afsluiten gaat het vaak mis: de UI sluit, een thread moet stoppen, maar er staan nog queued UI-Jobs open. Een nette shutdown is minder „een thread doden“, en meer een kleine choreografie:

  1. Shutdown-flag zetten (bijv. TUiDispatcher.BeginShutdown): Vanaf nu geen nieuwe UI-Jobs meer.
  2. Worker coöperatief stoppen: De Worker controleert een cancel-flag (bijv. TEvent of vergelijkbaar met TCancellationToken) en beëindigt lussen/waits.
  3. UI niet blokkeren: Geen harde wachtlus in de Main Thread. Als u „moet wachten“, dan alleen met een doorlopende Message Loop (of beter: helemaal vermijden door het einde per callback af te handelen).
  4. Laatste UI-opruimwerk alleen als vensters/controls gegarandeerd nog bestaan. In VCL is het tijdstip belangrijk: uiterlijk wanneer het Handle weg is, mogen queued Jobs niet meer naar Controls gaan.

Deze procedure is relevant voor operatie en support: „De applicatie blijft hangen bij sluiten“ is een klassiek acceptatieprobleem, hoewel inhoudelijk alles correct verwerkt werd. Een gedefinieerde shutdown bespaart hier daadwerkelijk tijd.

Debugging: Hoe u de deadlock tastbaar maakt (zonder giswerk)

Als het vastloopt, is de kernvraag: Wie wacht op wie? Een paar benaderingen die zich in bestaande projecten hebben bewezen:

  • Alle Wait-locaties inventariseren: Volledige tekstzoekopdracht naar WaitFor, Sleep in lussen, TEvent.WaitFor, INFINITE. Veel problemen zijn „verborgen“ waits (ook in bibliotheken).
  • Thread-toestand in de log: Log op thread-grenzen: „Job gestart“, „UI in wachtrij“, „UI uitgevoerd“, „Job voltooid“. Zo ziet u of de Main Thread de queued jobs in het geheel verwerkt.
  • Verdacht op Message Loop controleren: Treedt de hang alleen op bij modale dialogen of bepaalde COM-interacties, dan is de message loop vaak de bottleneck. Het doel is dan: UI-handlers ontlasten, COM-aanroepen isoleren, geen lange operaties in de UI.
  • Locks zichtbaar maken: Bij TCriticalSection/TMonitor is een debug-build met „Owner“-metadata (bijv. thread-ID bij Enter) en tijdsmeting zinvol. Zo ziet u welk lock de Main Thread op dat moment houdt, terwijl workers op de UI wachten.

Belangrijk is de houding: Deadlocks zijn zelden „toevallig“. Het zijn deterministische cycli die maar zelden worden geactiveerd. Als u de cyclus eenmaal netjes heeft geïdentificeerd, is de oplossing meestal duidelijk.

Varianten voor gegevenstoegang en interface-jobs (FireDAC, REST, bestandssysteem)

Vooral bij FireDAC (of andere DB-toegangen) geldt: verbinding, transactie en datasets zijn in de praktijk threadgebonden. Een worker-thread zou zijn DB-context uitsluitend zelf moeten bezitten. UI-aanroepen moeten zich beperken tot presentatie, niet tot DB-operaties. Een robuust patroon is:

  1. Worker voert Query/REST-call uit, berekent resultaat, maakt DTO aan.
  2. Worker plaatst DTO via Queue/TUiDispatcher.Post naar de UI.
  3. UI neemt DTO over en werkt Controls bij (zonder terugval op Worker-objecten).

Als u historisch gegroeide mengvormen heeft („UI triggert DB, DB-callback triggert UI“), loont een stapsgewijze ontkoppeling: eerst overdrachtspunten isoleren (Dispatcher), daarna staten verplaatsen naar Services/Model. Dat is minder riskant dan een grote herstructurering, maar vermindert deadlocks merkbaar.

Conclusie: Deadlocks vermijden betekent overdrachten beheersen

TThread en Synchronize zonder UI-deadlocks is minder een enkele techniek dan een discipline: blokkades minimaliseren, lock-volgordes netjes houden, shutdown definiëren en synchrone UI-afhankelijkheden verminderen. De getoonde UI-Dispatcher is in legacy-situaties bijzonder nuttig, omdat hij Queue als standaard gebruikt, maar voor noodzakelijke synchrone overdrachten wel Timeout en duidelijke shutdown-regels toevoegt.

Er blijven grenzen: Als de Main Thread langdurig geblokkeerd is (door zware UI-logica, modale dialogketens of COM-STA-aanroepen), kan een Dispatcher alleen diagnose stellen en gecontroleerd beëindigen. De duurzame oplossing is dan de UI ontlasten en verantwoordelijkheden scheiden. Als u daarvoor in een bestaande Delphi-applicatie ondersteuning nodig heeft – van threading-valkuilen tot stapsgewijze stabilisatie – kunt u het voornemen hier positioneren: Project of moderniseringsproject met Net-Base bespreken.

In het vakgebied spelen ook Delphi Multithreading en Synchronize Deadlock een belangrijke rol, wanneer integraties, datastromen en doorontwikkeling netjes moeten samenwerken.

Project of moderniseringsproject met Net-Base bespreken.

Bericht delen

Dit bericht direct delen

LinkedIn, X, XING, Facebook, WhatsApp en e-mail zijn direct beschikbaar. Voor Instagram bereiden we de link en een korte tekst direct voor.

E-mail

Instagram opent in een nieuw tabblad. Link en korte tekst worden van tevoren naar het klembord gekopieerd.