Net-Base Magasin

15.05.2026

TThread og Synchronize utan UI-deadlocks: robuste mønster for VCL og legacy-kode

Korleis du kan arbeide påliteleg med TThread, Synchronize og Queue utan at UI heng: typiske årsaker til deadlock, eit praktisk eigna UI-dispatcher-mønster (inkl. Timeout), shutdown-sikring, lock-strategiar og debugging-sjekkar for etablerte Delphi-applikasjonar.

15.05.2026

Den som arbeider med trådar i Delphi hamnar før eller seinare på TThread.Synchronize. Og nett der skjer dei ubehagelege tinga: sporadiske heng, „UI reagerer ikkje“, tilsynelatande tilfeldige deadlocks ved avslutting eller ved opning av ein dialog. Kjernen er sjeldan «Delphi er kaputt», men nesten alltid ein uheldig miks av Synchronize, blokkerande ventearbeid og ein UI-tråd som ikkje lenger handterer si Message Loop (VCL si hendinghandsaming) skikkeleg. Denne artikkelen viser robuste, i legacy-kontekst praktiske mønster for TThread und Synchronize ohne UI-Deadlocks – inkludert timeout-variant, rein feilformidling, avsluttingsreglar og debugging-tiltak som hjelper i ekte bestandsapplikasjonar.

Kvifor deadlocks rundt Synchronize oppstår i praksis

Synchronize betyr: Ein worker-tråd plasserer ei prosedyre i ei kø som blir køyrt i Main Thread, og ventar vanlegvis til denne prosedyren er ferdig. I VCL-applikasjonar er Main Thread samtidig UI-tråden (vindauge, controls, hendingar). I tillegg køyrer det i mange installasjonar COM-objekt i STA-Modell (Single-Threaded Apartment: COM-kall må handterast i same tråd), noko som forsterkar avhengnaden av ei fungerande Message Loop.

Deadlocks oppstår typisk frå ein av desse konstellasjonane:

  • WaitFor i Main Thread: UI-tråden ventar på ein worker (t.d. MyThread.WaitFor), medan workaren nettopp treng UI-tråden via Synchronize. Begge ventar – slutt.
  • Lock-Inversion: Workaren held eit lås (t.d. TCriticalSection eller TMonitor) og kallar Synchronize. Den synkroniserte UI-prosedyren prøver å ta same lås (direkte eller indirekte, ofte over logging/cache/singletons) – klassisk deadlock.
  • Shutdown/Destroy: Ved lukking av ein form blir ein tråd stoppa medan det framleis ligg Synchronize-oppgåver i kø. Særleg sleipt: synkroniserte kall refererer til Controls som nettopp blir øydelagde.
  • Message Loop blokkerer: Modale dialogar, langvarige UI-operasjonar, eit blokkerande COM-kall eller ein handler som «tar seg tid» for å gjere DB/REST held Main Thread fast. Synchronize-oppgåver blir forseinka eller ikkje kjøyrt i det heile.

Den viktigaste konsekvensen for arkitektur og drift: Synchronize er ei blokkeringsgrense. I individuell bedriftsprogramvare med importar, BDE-Ablosung mit nativer Anbindung-Queries, grensesnitt-jobbar eller bakgrunnstenester med UI-komponent bør denne grensa kontrollerast medvite – ellers går «sjelden» etter kvart over i «alltid når det hastar».

Grunnregel: Lat aldri UI-tråden venta på ein worker (når Synchronize er i spel)

Når ein worker nokon stad brukar Synchronize, bør ikkje Main Thread hardt blokkere og vente på denne workaren. Det verkar trivielt, men i legacy-kode er dette ei av dei vanlegaste årsakene – fordi «ventar vi litt ved lukking» eller «framdriftsdialog ventar på avslutning» blir raskt lagt inn.

Praktiske konsekvensar:

  • 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:

  • „Berre vise“: 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.

Formålet med koden og kor han medvite er «uvanleg»

Mønsteret erstattar ikkje Synchronize fullstendig, men det gjer synkrone overføringar kontrollerbare: Worker-tråden ventar ikkje på Synchronize-mekanismen, men på eit event. Dette gjer det mogleg å tvinge fram timeouts, synleggjere under drift at UI-tråden heng, og konsekvent avvise nye UI-jobbar i ein shutdown-fase.

Den «uvanlege» delen er ikkje eventet, men avgjerda om å representere synkron semantikk med Queue + Event. Dette løner seg nett når du i eksisterande applikasjonar trinnvis må ettermontere stabilitet utan å måtte byggje om kvar Synchronize-stad arkitektonisk med ein gong.

Rammevilkår og fallgruver

  • Speichersichtbarkeit: DoneEvent er synkroniseringsgrense. Dette gjer at lesing av RaisedObj etter WaitFor er konsistent. Likevel bør RaisedObj vere lokal per kall (som her), aldri global.
  • Exception-handtering: AcquireExceptionObject hindrar at unntaket „forsvinner“ i hovudtråden. Ved ny kasta i workeren er ikkje stacktracen identisk med opphavet, men feilmeldinga blir liggjande i worker-loggen, og jobben kan feile på ein ryddig måte.
  • Timeout er diagnose og vern: Han „reparerer“ ikkje ein blokkert Main Thread. Han hindrar derimot at workarar binder ressursar på ubestemt tid (t.d. halde BDE-Ablosung mit nativer Anbindung-transaksjonar opne), og han gjer feilkategorien målbar.
  • Shutdown må starte tidleg: BeginShutdown høyrer til i ei sentral shutdown-sekvens (t.d. svært tidleg i OnCloseQuery i hovudskjemaet). Elles kan UI-jobbar framleis bli lagt i kø medan vindauga allereie er øydelagde.
  • Låsstrategi: slik unngår du låsinversjonar med UI-Callbacks

    Mange deadlocks oppstår ikkje på grunn av WaitFor, men på grunn av uklar rekkefølgje for låsing. Typisk forløp: Worker låser „datamodell“, kallar UI-oppdatering via Synchronize, UI-oppdateringa går igjen inn i „datamodell“. Det er logisk forståeleg, men teknisk fatalt.

    Praktiske reglar som let seg innføre i team:

    • Ikkje hald lås over trådgrenser: Før ein worker køyrer noko mot UI (legg i kø eller synkroniserer), må faglege lås vere frigjorde.
    • UI les snapshots: UI-callbacks bør ikkje sjå „live“ inn i worker-strukturar, men vise kopiar/snapshots (t.d. DTO, Record, enkle verdiar).
    • Logging kan vere ein lås-kandidat: Dersom logging internt brukar ei kø, fil-lås eller eit singleton, kan det bli del av eit deadlock. UI-callbacks bør halde logging minimal eller skrive via ei separat, ikkje-blokkerande log-pipeline.

    Hvis du alt har ei Layer-3-arkitektur (UI, Services/Domäne, Infrastruktur wie Datenzugriff): UI-Callbacks bør ideelt berre gjere UI. Alt som «Service» er, høyrer ikkje heime i callbacken. Det reduserer reentrancy-effektar merkbart.

    Shutdown utan heng: „nicht WaitFor, sondern kooperatives Stoppen“

    Ved avslutting skjer det ofte: UI lukkar, ein tråd skal vekk, men UI-jobbar i kø er framleis opne. Ein ryddig shutdown handlar mindre om å «drepe» trådar og meir om ei kort koreografi:

    1. Setje shutdown-flag (t.d. TUiDispatcher.BeginShutdown): Frå no av ingen nye UI-jobbar.
    2. Stoppe worker kooperativt: Workeren sjekkar eit avbryt-flag (t.d. TEvent eller TCancellationToken-liknande) og avsluttar løkker/ventingar.
    3. Ikkje blokkere UI: Ingen harde vente-løkker i Main Thread. Dersom du „må vente“, gjer det berre med ei løpande meldingssløyfe (eller betre: unngå det heilt ved å handsame fullføringa via callback).
    4. Siste UI-oppryddingsarbeid berre når vindauge/controls er garantert framleis eksisterande. I VCL er tidspunktet viktig: seinast når Handle er borte, skal ikkje jobbar i kø lenger gå til controls.

    Denne rekkefølgja er relevant for drift og support: „Die Anwendung hängt beim Schließen“ er eit klassisk akseptproblem, sjølv om fagleg alt er korrekt handsama. Ein definert shutdown sparar her reell tid.

    Debugging: Korleis gjere deadlocken greieleg (utan gjetting)

    Når det heng, er kjernefokuset: Kven ventar på kven? Nokre tilnærmingar som har vist seg i eksisterande prosjekt:

    • 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.

    Del innlegg

    Del dette innlegget direkte

    LinkedIn, X, XING, Facebook, WhatsApp og e-post er tilgjengelege med ein gong. For Instagram klargjer vi lenke og kort tekst med det same.

    E-post

    Instagram opnar i ein ny fane. Lenkje og kort tekst blir kopiert til utklippstavla på førehand.