Net-Base Magasin

15.05.2026

TThread og Synchronize uten UI-deadlocks: robuste mønstre for VCL og legacy-kode

Hvordan du arbeider pålitelig med TThread, Synchronize og Queue uten at UI fryser: typiske deadlock-årsaker, et praktisk UI-dispatcher-mønster (inkl. timeout), shutdown-beskyttelse, låsestrategier og debugging-sjekker for veletablerte Delphi-applikasjoner.

15.05.2026

Den som jobber med tråder i Delphi ender før eller senere opp hos TThread.Synchronize. Og nettopp der skjer de ubehagelige tingene: sporadiske heng, «UI reagerer ikke», tilsynelatende tilfeldige deadlocks ved avslutning eller ved åpning av en dialog. Kjernen er sjelden «Delphi er ødelagt», men nesten alltid en ugunstig miks av Synchronize, blokkerende ventoperasjoner og en UI-tråd som ikke lenger ryddig behandler sin Message Loop (hendelseshåndteringen i VCL). Dette innlegget viser robuste, i legacy-kontekst praktiske mønstre for TThread und Synchronize ohne UI-Deadlocks – inkludert timeout-variant, ren feilformidling, shutdown-regler og debugging-tips som hjelper i reelle eksisterende applikasjoner.

Warum Deadlocks rund um Synchronize in der Praxis entstehen

Synchronize betyr: En worker-tråd legger en prosedyre i en kø som kjøres i hovedtråden, og venter typisk til denne prosedyren er ferdig. I VCL-applikasjoner er hovedtråden samtidig UI-tråden (vinduer, controls, hendelser). I tillegg kjører det i mange installasjoner COM-objekter i STA-modell (Single-Threaded Apartment: COM-kall må behandles i samme tråd), noe som forsterker avhengigheten av en fungerende message loop.

Deadlocks oppstår typisk gjennom en av disse konstellasjonene:

  • WaitFor i hovedtråden: UI-tråden venter på en worker (f.eks. MyThread.WaitFor), mens workeren akkurat nå trenger UI-tråden via Synchronize. Begge venter – slutt.
  • Lock-Inversion: Workeren holder en lock (f.eks. TCriticalSection eller TMonitor) og kaller Synchronize. Den synkroniserte UI-prosedyren forsøker å ta samme lock (direkte eller indirekte, ofte via logging/cache/singletons) – klassisk deadlock.
  • Shutdown/Destroy: Ved lukking av en form blir en tråd terminert mens det fortsatt finnes Synchronize-oppgaver i kø. Særlig problematisk: synkroniserte kall refererer til controls som akkurat blir ødelagt.
  • Message Loop blokkert: Modale dialoger, langvarige UI-operasjoner, et blokkerende COM-kall eller en handler som «bare raskt» gjør DB/REST holder hovedtråden fast. Synchronize-oppgaver blir forsinket eller blir ikke behandlet i det hele tatt.

Den viktigste konsekvensen for arkitektur og drift: Synchronize er en blokkering. I skreddersydd bedriftsprogramvare med importer, BDE-utskifting med native-tilkobling-queries, grensesnittjobber eller bakgrunnstjenester med UI-komponent bør denne grensen kontrolleres bevisst – ellers går «sjelden» etter hvert over i «alltid når det haster».

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

Hvis en worker et sted bruker Synchronize, bør ikke hovedtråden hardt blokkere og vente på denne workeren. Det høres trivielt ut, men i legacy-kode er dette en av de hyppigste årsakene, fordi «la oss vente litt ved lukking» eller «progress-dialog venter på slutt» raskt blir lagt inn.

Praktiske konsekvenser:

  • Keine WaitFor-Aufrufe im UI-tråden, 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 hovedtråden, 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 hovedtråden 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 hovedtråden. 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 (tilstandsmaskin, 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 hovedtråden gesetzt wird, nachdem die queued Action ausgeführt wurde. Das Timeout verhindert „ewiges“ Hängen, wenn der UI-tråden 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>
  ///  Kapsler UI-kall fra arbeidstråder.
  ///  Post: asynkront (Queue).
  ///  Call: synkront med timeout, uten å blokkere TThread.Synchronize direkte.
  /// </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;

  // Under Shutdown aksepteres ikke nye UI-jobber.
  if IsShuttingDown then
    Exit;

  // Køen blokkerer ikke arbeidstråden.
  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 er i shutdown.');

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

    TThread.Queue(nil,
      procedure
      begin
        try
          if not IsShuttingDown then
            AProc();
        except
          // Overfør exception-objektet over trådgrensen.
          // Merk: Ikke "raise" her, ellers ender den i hovedtråden.
          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 etter %d ms: hovedtråden har ikke behandlet UI-kallet.',
          [ATimeoutMs]);
    else
      raise Exception.Create('Uventet WaitFor-status i UI-dispatcheren.');
    end;
  finally
    DoneEvent.Free;
  end;
end;

end.

Formålet med koden og hvor den bevisst er «uvanlig»

Mønsteret erstatter Synchronize ikke helt, men det gjør synkrone overganger kontrollerbare: Arbeidstråden venter ikke på Synchronize-mekanismen, men på et Event. Dermed kan du tvinge frem timeouts, synliggjøre i drift at UI-tråden henger, og konsekvent avvise nye UI-jobber i en shutdown-fase.

Den «uvanlige» delen er ikke Eventet, men beslutningen om å gjengi synkron semantikk med Queue + Event. Dette gir mening når du i eksisterende applikasjoner må ettermontere stabilitet trinnvis, uten å måtte bygge om hver Synchronize-forekomst arkitektonisk med én gang.

Forutsetninger og fallgruver

  • Hukommelsessynlighet: DoneEvent er synkroniseringskanten. Dermed er lesing av RaisedObj etter WaitFor konsistent. Likevel bør RaisedObj være lokalt per kall (som her), aldri globalt.
  • Unntakshåndtering: AcquireExceptionObject forhindrer at unntaket forsvinner i hovedtråden. Når det kastes på nytt i en Worker, er stacktracet ikke identisk med opprinnelsen, men feilmeldingen forblir i Worker-loggen, og jobben kan feile på en kontrollert måte.
  • Timeout er diagnose og vern: Den „reparerer“ ikke en blokkert hovedtråd. Den forhindrer derimot at Worker binder ressurser ubegrenset (f.eks. holde BDE-Ablosung mit nativer Anbindung-transaksjoner åpne), og den gjør feiltypen målelig.
  • Shutdown må starte tidlig: BeginShutdown hører hjemme i en sentral shutdown-sekvens (f.eks. svært tidlig i OnCloseQuery på hovedformen). Ellers kan UI-jobber fortsatt bli queued mens vinduer allerede er ødelagt.

Låsstrategi: slik unngår du lock-inversjoner med UI-callbacks

Mange deadlocks oppstår ikke på grunn av WaitFor, men på grunn av en uklar låsrekkefølge. Typisk forløp: Worker låser «datamodell», kaller UI-oppdatering via Synchronize, UI-oppdateringen aksesserer igjen «datamodell». Det er logisk forståelig, men teknisk fatalt.

Praktiske regler som lar seg forankre i team:

  • Ikke hold låser over trådgrenser: Før en Worker køer/synkroniserer noe mot UI, bør faglige låser være frigitt.
  • UI leser snapshots: UI-callbacks bør ikke «se live» inn i Worker-strukturer, men vise kopier/snapshots (f.eks. DTO, Record, enkle verdier).
  • Logging er en potensiell låsekandidat: Hvis logging internt bruker en kø, fil-lås eller et singleton, kan det bli del av en deadlock. UI-callbacks bør holde logging minimal eller skrive via en separat, ikke-blokkerende logg-pipeline.

Hvis du allerede har en Layer-3-arkitektur (UI, services/domene, infrastruktur som dataaksess): UI-callbacks bør ideelt sett kun gjøre UI. Alt som er «service» hører ikke hjemme i callbacken. Det reduserer reentrancy-effekter betydelig.

Shutdown uten heng: „ikke WaitFor, men kooperativ stopping“

Ved avslutning går det ofte galt: UI lukker, en tråd skal bort, men queued UI-jobber er fortsatt åpne. En ryddig shutdown handler mindre om å «drepe» tråder enn om en liten koreografi:

  1. Sett shutdown-flagget (f.eks. TUiDispatcher.BeginShutdown): Fra nå av ingen nye UI-jobber.
  2. Stopp worker kooperativt: Worker sjekker et cancel-flag (f.eks. TEvent eller TCancellationToken-liknende) og avslutter sløyfer/venting.
  3. Ikke blokker UI: Ingen hard venteløkke i hovedtråden. Hvis du «må vente», så kun med en fortsatt kjørende meldingssløyfe (eller bedre: unngå helt ved å håndtere ferdigstillelse via callback).
  4. Siste UI-oppryddingsarbeider kun hvis vinduer/kontroller garantert fortsatt eksisterer. I VCL er tidspunktet viktig: senest når handle er borte, må ikke queued jobber gå mot kontroller.

Denne prosessen er relevant for drift og support: «Applikasjonen henger ved lukking» er et klassisk akseptanseproblem, selv om alt faglig er korrekt behandlet. En definert shutdown sparer reell tid her.

Feilsøking: Hvordan gjøre en deadlock håndgripelig (uten gjetting)

Når det henger, er kjerne-spørsmålet: Hvem venter på hvem? Noen tilnærminger som har vist seg i eksisterende prosjekter:

  • Kartlegg alle ventesteder: Volltextsuche nach WaitFor, Sleep in Schleifen, TEvent.WaitFor, INFINITE. Viele Probleme sind „versteckte“ Waits (auch in Bibliotheken).
  • Trådtilstand i loggen: Logg ved Thread-Grenzen: „Job startet“, „queued UI“, „UI ausgeführt“, „Job fertig“. Damit sehen Sie, ob der Main Thread queued Jobs überhaupt abarbeitet.
  • Sjekk mistanke om Message-Loop: 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.
  • Gjør låser synlige: 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 umiddelbart tilgjengelig. For Instagram forbereder vi lenke og kort tekst umiddelbart.

E-post

Instagram åpnes i en ny fane. Lenken og kortteksten kopieres først til utklippstavlen.