Net-Base Časopis

15.05.2026

TThread i Synchronize bez UI-deadlockova: robustni obrasci za VCL i legacy kod

Kako pouzdano raditi s TThread, Synchronize i Queue bez zastoja UI-ja: tipični uzroci deadlocka, praktični UI-dispatcher uzorak (uključujući timeout), zaštita pri gašenju, strategije zaključavanja i provjere za otklanjanje pogrešaka za razvijene Delphi aplikacije.

15.05.2026

Tko u Delphi radi s threadovima, prije ili kasnije dođe do TThread.Synchronize. I upravo tamo se događaju neugodne stvari: sporadična zamrzavanja, „UI ne reagira“, naizgled slučajni deadlockovi pri zatvaranju ili otvaranju dijaloga. Uzrok rijetko glasi „Delphi je pokvaren“, već gotovo uvijek predstavlja nepovoljan miks Synchronize, blokirajućih čekajućih operacija i UI-dretve koja više ne obrađuje pravilno svoju Message Loop (obradu događaja VCL-a). Ovaj članak pokazuje robusne, u legacy-kontekstu praktične obrasce za TThread i Synchronize bez UI-Deadlocks – uključujući varijantu s timeoutom, uredno prosljeđivanje grešaka, pravila gašenja i savjete za debugiranje koji pomažu u stvarnim postojećim aplikacijama.

Zašto u praksi nastaju deadlockovi oko Synchronize

Synchronize znači: radna dretva stavi proceduru u red koja se izvršava u glavnoj dretvi i tipično čeka dok ta procedura ne završi. U VCL-aplikacijama je glavna dretva istovremeno UI-dretva (prozori, kontrola, događaji). Dodatno, u mnogim instalacijama tamo rade COM-objekti u STA-modelu (Single-Threaded Apartment: COM-pozivi moraju se obrađivati u istoj dretvi), što dodatno pojačava ovisnost o ispravno funkcionirajućoj Message Loop.

Deadlockovi se tipično javljaju zbog jedne od ovih konstelacija:

  • WaitFor u glavnoj dretvi: UI-dretva čeka radnu dretvu (npr. MyThread.WaitFor), dok radna dretva upravo putem Synchronize treba UI-dretvu. Obje čekaju – kraj priče.
  • Lock-Inversion: Worker drži lock (npr. TCriticalSection ili TMonitor) i poziva Synchronize. Sinkronizirana UI-procedura pokušava preuzeti isti lock (direktno ili indirektno, često preko logiranja/cache/singeltona) – klasični deadlock.
  • Shutdown/Destroy: Prilikom zatvaranja forme thread se zaustavlja dok još uvijek postoje zadaci u Synchronize-u. Posebno nezgodno: sinkronizirani pozivi referenciraju kontrole koje se upravo uništavaju.
  • Message Loop blokirana: Modalni dijalozi, dugotrajne UI-operacije, blokirajući COM-poziv ili handler koji „samo malo“ radi DB/REST drže glavnu dretvu zauzetom. Synchronize-zadaci se obrađuju sa zakašnjenjem ili uopće ne.

Najvažnija posljedica za arhitekturu i operacije: Synchronize je rub blokade. U individualnom poslovnom softveru s importima, BDE-zamjena s nativnom vezom-upiti, sučeljima poslova ili pozadinskim servisima s UI-komponentom, taj rub treba svjesno kontrolirati – inače će od „rijetko“ prije ili kasnije postati „uvijek kad je hitno“.

Osnovno pravilo: UI-dretva nikad ne smije čekati worker (ako je Synchronize u igri)

Ako radna dretva negdje koristi Synchronize, glavna dretva ne smije tvrdo blokirajuće čekati tu radnu dretvu. To zvuči trivijalno, ali u legacy-kodu je jedan od najčešćih uzroka, jer se brzo ubaci „čekaj malo pri zatvaranju“ ili „progresni dijalog čeka završetak“.

Praktične posljedice:

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

Svrha koda i zašto je namjerno „neobičan“

Ovaj obrazac ne zamjenjuje u potpunosti Synchronize, ali čini sinkrone prijenose kontroliranim: radne dretve ne čekaju na mehanizam Synchronize, nego na događaj. Time možete nametnuti vremenska ograničenja (timeouts), u radu učiniti vidljivim da se UI-dretva zaglavila i tijekom faze gašenja dosljedno odbijati nove UI-zadatke.

Neobičan dio nije događaj, nego odluka prikazati sinkronu semantiku pomoću Queue + Event. To se posebno isplati kada u postojećim aplikacijama trebate postupno poboljšavati stabilnost, bez potrebe da svako mjesto sa Synchronize odmah arhitektonski preuređujete.

Ograničenja i zamke

  • Vidljivost u memoriji: DoneEvent je granica sinkronizacije. Zahvaljujući tome je čitanje RaisedObj nakon WaitFor konzistentno. Ipak, RaisedObj bi trebao ostati lokalni po pozivu (kao ovdje), nikad globalni.
  • Rukovanje iznimkama: AcquireExceptionObject sprječava da iznimka „nestane“ u glavnoj niti. Pri ponovnom bacanju u Worker-u stacktrace nije identičan izvorniku, ali poruka o pogrešci ostaje u Worker-Logu, i zadatak može uredno zakazati.
  • Timeout je dijagnostika i zaštita: Ne „popravlja“ blokiranu glavnu nit. Ali sprječava da Worker neograničeno veže resurse (npr. drže otvorene BDE-Ablosung mit nativer Anbindung-transakcije) i čini tu klasu pogrešaka mjerljivom.
  • Shutdown treba početi rano: BeginShutdown treba biti u centralnoj shutdown-sekvenci (npr. vrlo rano u OnCloseQuery glavnog obrasca). Inače će se još UI-poslovi stavljati u red čekanja dok su prozori već uništeni.
  • Strategija zaključavanja: kako izbjeći inverzije zaključavanja pri UI-callbackovima

    Mnogi deadlockovi ne nastaju zbog WaitFor, već zbog nejasnog redoslijeda zaključavanja. Tipični tijek: Worker zaključava „model podataka“, poziva ažuriranje UI preko Synchronize, a UI-ažuriranje ponovno pristupa „modelu podataka“. To je logično razumljivo, ali tehnički fatalno.

    Praktična pravila koja se u timovima mogu provesti:

    • Ne držati zaključavanja preko granica niti: Prije nego što Worker nešto stavi u red za UI ili sinkronizira s UI, funkcionalna zaključavanja trebaju biti otpuštena.
    • UI čita snimke: UI-callbackovi ne bi trebali „uživo“ gledati u strukture Workera, već prikazivati kopije/snimke (npr. DTO, Record, jednostavne vrijednosti).
    • Logging je kandidat za zaključavanje: Ako logging interno koristi queue, file-lock ili singleton, može postati dio deadlocka. UI-callbackovi trebaju držati logging na minimumu ili pisati preko zasebne, neblokirajuće log-pipeline.

    Ako već imate Layer-3-arhitekturu (UI, Services/Domena, infrastruktura poput pristupa podacima): UI-callbackovi bi se idealno trebali baviti samo UI-jem. Sve što je „Service“ ne pripada u callback. To značajno smanjuje efekte ponovnog ulaska.

    Shutdown bez zastoja: „ne WaitFor, već kooperativno zaustavljanje“

    Pri gašenju često zapne: UI se zatvara, nit treba otići, ali UI-poslovi u redu čekanja još su otvoreni. Ispravan shutdown manje je „ubijanje niti“, a više mala koreografija:

    1. Postaviti shutdown-flag (npr. TUiDispatcher.BeginShutdown): Od sada se više ne prihvaćaju novi UI-poslovi.
    2. Kooperativno zaustaviti Workera: Worker provjerava cancel-flag (npr. TEvent ili slično TCancellationToken) i završava petlje/čekanja.
    3. Ne blokirati UI: Nema tvrdih petlji čekanja u glavnoj niti. Ako morate „čekati“, onda samo uz aktivnu petlju poruka (ili još bolje: uopće izbjegavati to tako da završetak obradite preko callbacka).
    4. Posljednje UI-poslove čišćenja izvršavati samo ako su prozori/kontrolke zajamčeno još prisutni. U VCL-u je vremenski trenutak važan: najkasnije kad handle više ne postoji, queued poslovi više ne smiju ići na kontrole.

    Ovaj postupak je relevantan za operacije i podršku: „Aplikacija se zamrzne pri zatvaranju“ je klasičan problem prihvatljivosti, iako je funkcionalno sve ispravno obrađeno. Definiran shutdown ovdje stvarno štedi vrijeme.

    Debugging: Kako učiniti deadlock opipljivim (bez nagađanja)

    Kad se zaključa, ključno pitanje je: Tko koga čeka? Nekoliko pristupa koji su se pokazali u postojećim projektima:

    • Evidentirati sve Wait-lokacije: Volltextsuche nach WaitFor, Sleep in Schleifen, TEvent.WaitFor, INFINITE. Viele Probleme sind „versteckte“ Waits (auch in Bibliotheken).
    • Zabilježite stanje dretve u logu: Loggen Sie an Thread-Grenzen: „Job započinje“, „UI stavljeno u red“, „UI izvršeno“, „Job završen“. Damit sehen Sie, ob der Main Thread queued Jobs überhaupt abarbeitet.
    • Provjerite sumnju na 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.
    • Učinite zaključavanja vidljivima: 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.

    Varijante za pristup podacima i zadatke sučelja (FireDAC, REST, datotečni sustav)

    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.

    Zaključak: Izbjegavanje deadlockova znači kontrolu prijenosa

    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.

    Raspraviti projekt ili modernizacijski zahvat s Net-Base.

    Podijeli objavu

    Izravno proslijedite ovu objavu

    LinkedIn, X, XING, Facebook, WhatsApp i e-mail su odmah dostupni. Za Instagram pripremamo link i kratak tekst.

    E-pošta

    Instagram se otvara u novoj kartici. Link i kratki tekst se prethodno kopiraju u međuspremnik.