Net-Base Revija

15.05.2026

TThread in Synchronize brez UI-deadlockov: robustni vzorci za VCL in zastarelo kodo

Kako zanesljivo delati s TThread, Synchronize in Queue, brez zatikanja UI: tipični vzroki deadlockov, praxistopreden vzorec UI-dispatcherja (vključno s timeoutom), zaščita pred zaustavitvijo, strategije zaklepanja in kontrolna preverjanja za razhroščevanje obstoječih Delphi-aplikacij.

15.05.2026

Kdor v Delphi dela z nitmi, prej ali slej pristane pri TThread.Synchronize. In prav tam se zgodijo neprijetne stvari: občasni zatikom, „UI ne odgovarja“, navidez naključni deadlocki ob zapiranju ali odprtju dialoga. Jedro problema redko pomeni „Delphi je pokvarjen“, temveč skoraj vedno neugodna mešanica Synchronize, blokirajočih čakalnih operacij in niti uporabniškega vmesnika, ki svojo Message Loop (obdelava dogodkov VCL) ne izvaja več pravilno. Ta prispevek prikazuje robustne, v legacy-kontekstu praktične vzorce za TThread und Synchronize ohne UI-Deadlocks – vključno z različico s timeoutom, pravilnim posredovanjem napak, pravili za izklop in namigi za razhroščevanje, ki pomagajo v dejanskih obstoječih aplikacijah.

Zakaj v praksi nastajajo deadlocki okoli Synchronize

Synchronize pomeni: delovna nit postavi proceduro v vrsto, ki se izvede v Main Thread, in običajno počaka, da je ta procedura končana. V VCL-aplikacijah je Main Thread hkrati nit uporabniškega vmesnika (okna, kontrolniki, dogodki). Poleg tega v mnogih namestitvah tam tečejo COM-objekti v STA-Modell (Single-Threaded Apartment: COM-klici morajo biti obdelani v isti niti), kar še povečuje odvisnost od pravilno delujoče Message Loop.

Deadlocki običajno nastanejo zaradi ene od teh konfiguracij:

  • WaitFor im Main Thread: UI-nit čaka na delovno nit (npr. MyThread.WaitFor), medtem ko delovna nit pravkar preko Synchronize potrebuje UI-nit. Obe čakata – konec.
  • Lock-Inversion: delovna nit drži lock (npr. TCriticalSection ali TMonitor) in kliče Synchronize. Sinhronizirana UI-procedura poskuša vzeti isti lock (direktno ali posredno, pogosto preko logiranja/cacha/singletonov) – klasičen deadlock.
  • Shutdown/Destroy: ob zapiranju forme se nit konča, medtem ko so še naloge Synchronize v vrsti. Še posebej zahrbtno: sinhronizirani klici referencirajo kontrolnike, ki so ravnokar v postopku uničenja.
  • Message Loop blockiert: modalni dialogi, dolgotrajne UI-operacije, blokirajoč COM-klic ali handler, ki „na hitro“ izvaja DB/REST, zadržijo Main Thread. Naloge Synchronize se obdelajo zamaknjeno ali sploh ne.

Najpomembnejši sklep za arhitekturo in obratovanje: Synchronize je blokirna meja. V individualni poslovni programski opremi z uvozi, BDE-zamenjava z nativno vezavo-Queries, vmesniškimi opravili ali ozadnimi storitvami z UI-komponento je treba to mejo zavestno nadzorovati – sicer iz „redko“ postane „vedno takrat, ko je nujno“.

Osnovno pravilo: UI-Thread nikoli ne sme čakati na Worker (če je Synchronize v igri)

Če delovna nit kjerkoli uporablja Synchronize, naj Main Thread ne strogo blokira in ne čaka na to delovno nit. Zveni trivialno, a v legacy-kodu je to eden najpogostejših vzrokov, saj se hitro doda „počakajmo malo pri zapiranju“ ali „progress-dialog čaka na konec“.

Praktične posledice:

  • 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 je pogosto boljša privzeta možnost: Worker postavi delo na glavno nit, nadaljuje z izvajanjem in ne blokira. To prepreči veliko deadlockov. Vendar ne reši vseh robnih primerov – na primer, če v Workerju nujno potrebujete rezultat, ki ga ustvari glavna nit (npr. dostop do UI-povezanega vira ali komponente, ki je vezana na nit).

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

Robusten miselni model je: obstaja le nekaj legitimnih sinhronih predaj glavni niti. Vse drugo je stanje, prikaz ali telemetrija – in torej asinhrono.

Enostavna delitev pomaga pri pregledih in pri stabilizaciji obstoječih projektov:

  • „Samo prikaz“: napredek, vrstica dnevnika, števec, semafor, omogoči/onemogoči – vedno Queue.
  • „Predati stanje“: Worker posreduje podatkovni objekt/DTO, UI renderira – Queue, vendar z kopijo/nenespremenljivostjo (torej brez skupnih mutiranih struktur).
  • „UI mora odločiti“: Le tukaj potrebujete sinhrono semantiko (npr. uporabniški poizvedek). Potem je pravo vprašanje: ali mora Worker res čakati, ali lahko delovni tok preuredite (stroj stanj, preklic opravila, kasnejše nadaljevanje)?

Tretja kategorija je posebej past za deadlock: če Worker čaka na rezultat iz UI, UI je hitro v skušnjavi, da čaka na Workerja (ali posredno preko zaklepov). To se pri obremenitvi, počasnih podatkovnih bazah ali v Remote-Desktop okoljih zgodi veliko prej.

Source-Schnipsel: UI-Dispatcher mit Queue, optionalem Timeout und sauberem Shutdown

Naslednji vzorec zapakira UI-predaje v majhen pomožni razred. Dobite:

  • Post: fire-and-forget über TThread.Queue (tipično za statusne posodobitve).
  • Call: sinhroni klic z Timeout (redko, vendar uporaben v legacy-situacijah), brez neposredne uporabe Synchronize kot točke blokade.
  • Shutdown-Schutz: Ne sprejemajte več novih UI-nalog, in čakajoče naloge preverijo zastavico, preden se poseže v kontrolnike.

Tehnična razlaga: uporabljamo Queue skupaj s TEvent (kernel dogodek) za povratno potrditev. Worker ne čaka na Synchronize, temveč na dogodek, ki ga v glavni niti nastavi, potem ko je izvedena akcija, postavljena v čakalno vrsto. Timeout prepreči »večno« zagozdenje, če UI-nit iz kakršnegakoli razloga ne pride več do obdelave.

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.

Namen kode in kje je namerno »nenavadna«

Vzorec ne nadomešča popolnoma Synchronize, vendar omogoča nadzor nad sinhronimi predajami: delovna nit ne čaka na mehanizem Synchronize, temveč na dogodek (Event). Tako lahko uveljavite časovne omejitve, v obratovanju pokažete, da se UI-nit zatakne, in v fazi zaustavljanja dosledno zavračate nove UI‑naloge.

»Nenavaden« del ni dogodek, temveč odločitev, sinhrono semantiko predstaviti z Queue + Event. To se izplača predvsem, kadar morate v obstoječih aplikacijah postopoma izboljševati stabilnost, ne da bi vsako mesto, kjer se uporablja Synchronize, takoj arhitekturno preoblikovali.

Mejne razmere in pasti

  • Vidnost v pomnilniku: DoneEvent je sinhronizacijska meja. Zaradi tega je branje RaisedObj po WaitFor dosledno. Kljub temu naj RaisedObj ostane lokalno za vsak klic (kot tukaj), nikoli globalno.
  • Obravnava izjem: AcquireExceptionObject prepreči, da bi izjema v glavni niti »izginila«. Ob ponovnem metu v Workerju stacktrace ni identičen izvoru, vendar sporočilo o napaki ostane v dnevniku Workerja in se opravilo lahko urejeno zaključi z napako.
  • Timeout je diagnostično orodje in zaščita: Ne »popravi« blokirane glavne niti. Prepreči pa, da bi Worker neskončno vezal vire (npr. BDE-Ablosung mit nativer Anbindung-transakcije odprte), in naredi razred napake merljiv.
  • Zaustavitev se mora začeti zgodaj: BeginShutdown spada v osrednjo sekvenco za zaustavitev (npr. zelo zgodaj v OnCloseQuery glavne forme). Sicer se še UI-opravila postavijo v vrsto, medtem ko so okna že uničena.
  • Strategija zaklepanja: kako se izogniti lock-inverzijam pri UI-Callbacks

    Veliko deadlockov ne nastane zaradi WaitFor, temveč zaradi nejasnega vrstnega reda zaklepanja. Tipičen potek: Worker zaklene »podatkovni model«, kliče posodobitev UI prek Synchronize, UI-posodobitev znova dostopa do »podatkovnega modela«. To je logično razumljivo, a tehnično usodno.

    Praktična pravila, ki se učinkovito uveljavijo v ekipah:

    • Ne držite zaklepov čez meje niti: Preden Worker karkoli postavi v vrsto ali sinhronizira proti UI, naj bodo zaklepi, povezani s poslovno logiko, sproščeni.
    • UI bere kopije/posnetke stanja: UI-Callbacks naj ne gledajo »v živo« v strukture Workerja, temveč prikažejo kopije/posnetke stanja (npr. DTO, Record, preproste vrednosti).
    • Logiranje je kandidat za zaklep: Če logiranje interno uporablja vrsto, datotečni zaklep ali singleton, lahko postane del deadlocka. UI-Callbacks naj ohranijo logiranje na minimumu ali pišejo preko ločene, neblokirajoče log-predorče.

    Če že imate Layer-3-arhitekturo (UI, Services/Domäne, infrastruktura, kot je dostop do podatkov): UI-Callbacks naj idealno izvajajo le UI. Vse, kar je »Service«, ne sodi v callback. To znatno zmanjša učinke ponovnega vstopanja.

    Zaustavitev brez zatikanja: „nicht WaitFor, sondern kooperatives Stoppen“

    Pri zapiranju pogosto pride do težav: UI se zapre, nit naj bi odšla, vendar so še odprta UI-opravila v vrsti. Čista zaustavitev ni toliko »nit ubiti«, ampak mala koreografija:

    1. Nastavite Shutdown-zastavico (npr. TUiDispatcher.BeginShutdown): od tega trenutka naprej nobenih novih UI-opravil.
    2. Kooperativno zaustavite Worker: Worker preveri cancel-zastavico (npr. TEvent ali TCancellationToken-podobno) in zaključi zanke/čakanja.
    3. Ne blokirajte UI: Ni ostrih čakalnih zank v glavni niti. Če morate »čakati«, naj bo to le z nadaljevanjem message loopa (ali še bolje: temu se izognite tako, da zaključek obravnavate prek callbacka).
    4. Zadnja čiščenja UI le, če okna/controls zagotovo še obstajajo. V VCL je čas bistven: najkasneje ko je handle izginil, queued opravila ne smejo več dostopati do kontrolnikov.

    Ta postopek je pomemben za obratovanje in podporo: »Aplikacija se zatakne ob zapiranju« je klasičen problem sprejemljivosti, čeprav je tehnično vse pravilno obdelano. Določen postopek zaustavitve prihrani resničen čas.

    Razhroščevanje: kako naredite deadlock otipljiv (brez ugibanja)

    Ko se zatakne, je ključno vprašanje: Kdo čaka na koga? Nekaj pristopov, ki so se izkazali v obstoječih projektih:

    • Inventarizirati vse čakalne točke: Celotno iskanje po besedilu za WaitFor, Sleep v zankah, TEvent.WaitFor, INFINITE. Veliko težav so „skrite“ čakalne točke (tudi v knjižnicah).
    • Stanje niti v dnevniku: Zabeležite pri mejah niti: „Job se zažene“, „UI v čakalni vrsti“, „UI izvedena“, „Job končan“. Tako boste videli, ali glavna nit sploh obdeluje naloge v čakalni vrsti.
    • Preverite sum na zanko sporočil: Če se zatikanje pojavi le pri modalnih dialogih ali pri določenih COM-interakcijah, je pogosto ozko grlo zanka sporočil. Cilj je: razbremeniti UI-handlerje, izolirati COM-klice in ne izvajati dolgih operacij v UI.
    • Naredite zaklepe vidne: Pri TCriticalSection/TMonitor se izplača Debug-build z „Owner“-metapodatki (npr. ID niti ob vstopu) in merjenjem časa. Tako vidite, kateri lock glavna nit trenutno drži, medtem ko delavci čakajo na UI.

    Pomemben je pristop: Deadlocks se redko pojavijo „naključno“. Gre za deterministične cikle, ki se le redko sprožijo. Ko ciklus enkrat pravilno identificirate, je odprava običajno jasna.

    Variacije za dostop do podatkov in naloge vmesnikov (FireDAC, REST, datotečni sistem)

    Še posebej pri FireDAC (ali drugih dostopih do DB) velja: povezava, transakcija in Datasets so v praksi vezani na nit. Worker-nit bi morala imeti svoj DB-kontekst izključno. Klici iz UI naj se omejijo na predstavitev, ne na DB-operacije. Robustni vzorec je:

    1. Worker izvede Query/REST-klic, izračuna rezultat in ustvari DTO.
    2. Worker pošlje DTO preko Queue/TUiDispatcher.Post v UI.
    3. UI prevzame DTO in posodobi kontrolnike (brez sklicevanja na Worker-objekte).

    Če imate zgodovinsko nastale mešane oblike („UI sproži DB, DB-callback sproži UI“), se izplača postopna odvezava: najprej izolirati točke predaje (Dispatcher), nato premakniti stanje v Services/Model. To je manj tvegano kot obsežen preobrat, a občutno zmanjša deadlocke.

    Zaključek: preprečevanje deadlockov pomeni nadzor nad predajami

    TThread und Synchronize ohne UI-Deadlocks ni toliko posamezna tehnika kot disciplina: minimalizirati blokade, dosledno ohranjati vrstni red zaklepov, definirati shutdown in zmanjšati sinhrone odvisnosti UI. Pokazani UI-dispatcher je v legacy-situacijah posebej uporaben, ker kot privzeto uporablja Queue, za potrebne sinhrone predaje pa doda Timeout in jasna pravila za shutdown.

    Omejitve ostajajo: če je glavna nit trajno blokirana (zaradi težke UI-logike, verig modalnih dialogov ali COM-STA-klicev), lahko tudi dispatcher le diagnosticira in nadzorovano prekine. Trajna rešitev je takrat razbremenitev UI in razdelitev odgovornosti. Če za to potrebujete podporo v obstoječi Delphi-aplikaciji – od pasti pri threading do postopne stabilizacije – lahko pobudo uredite tukaj: projekt ali modernizacijsko opravilo obravnajte z Net-Base.

    V strokovnem kontekstu igrajo Delphi multithreading in deadlocki pri Synchronize pomembno vlogo, kadar morajo integracije, tokovi podatkov in nadaljnji razvoj gladko sodelovati.

    O projektu ali modernizaciji se posvetujte z Net-Base.

    Deli objavo

    Deli ta prispevek neposredno

    LinkedIn, X, XING, Facebook, WhatsApp in e-pošta so takoj na voljo. Za Instagram bomo neposredno pripravili povezavo in kratek opis.

    E-pošta

    Instagram se odpre v novem zavihku. Povezava in kratek opis se pred tem kopirata v odložišče.