Net-Base Magazyn

15.05.2026

TThread i Synchronize bez zakleszczeń interfejsu użytkownika: odporne wzorce dla VCL i kodu dziedziczonego

Jak pracować niezawodnie z TThread, Synchronize i Queue, bez zawieszania UI: typowe przyczyny zakleszczeń, praktyczny wzorzec dyspozytora UI (w tym timeout), ochrona przed zamknięciem, strategie blokad i kontrole debugowania dla rozbudowanych Delphi-aplikacji.

15.05.2026

Kto w Delphi pracuje z wątkami, prędzej czy później trafi na TThread.Synchronize. I właśnie tam zdarzają się nieprzyjemne rzeczy: sporadyczne zawieszenia, „UI nie odpowiada”, pozornie losowe deadlocki przy zamykaniu lub otwieraniu dialogu. Sednem rzadko jest „Delphi jest zepsute”, niemal zawsze chodzi o niekorzystny miks Synchronize, blokujących operacji oczekiwania i wątku UI, który nie przetwarza już poprawnie swojej Message Loop (obsługi zdarzeń VCL). Ten artykuł przedstawia odporne, w kontekście legacy praktyczne wzorce dla TThread i Synchronize bez deadlocków UI – włącznie z wariantem z timeoutem, poprawnym przekazywaniem błędów, zasadami zamykania i wskazówkami do debugowania, które przydają się w rzeczywistych aplikacjach utrzymaniowych.

Dlaczego deadlocki wokół Synchronize powstają w praktyce

Synchronize oznacza: wątek roboczy umieszcza procedurę w kolejce, która jest wykonywana w wątku głównym, i czeka zazwyczaj, aż ta procedura się zakończy. W aplikacjach VCL wątek główny jest jednocześnie wątkiem UI (okna, kontrolki, zdarzenia). Dodatkowo w wielu instalacjach działają tam obiekty COM w modelu STA (Single-Threaded Apartment: wywołania COM muszą być obsługiwane w tym samym wątku), co jeszcze bardziej wzmacnia zależność od poprawnie działającej pętli komunikatów.

Deadlocki zwykle powstają w jednej z następujących konfiguracji:

  • WaitFor w wątku głównym: Wątek UI czeka na worker (np. MyThread.WaitFor), podczas gdy worker potrzebuje wątku UI poprzez Synchronize. Obaj czekają – koniec.
  • Lock-Inversion: Worker trzyma lock (np. TCriticalSection lub TMonitor) i wywołuje Synchronize. Synchronizowana procedura UI próbuje przejąć ten sam lock (bezpośrednio lub pośrednio, często przez logging/cache/singletony) – klasyczny deadlock.
  • Shutdown/Destroy: Przy zamykaniu formularza wątek jest kończony, podczas gdy w kolejce są jeszcze zadania Synchronize. Szczególnie podstępne: zsynchronizowane wywołania odnoszą się do kontrolek, które są właśnie niszczone.
  • Pętla komunikatów zablokowana: Dialogi modalne, długotrwałe operacje UI, blokujące wywołanie COM lub handler, który „szybko” wykonuje DB/REST, zatrzymują wątek główny. Zadania Synchronize są wykonywane z opóźnieniem lub wcale.

Najważniejszy wniosek dla architektury i eksploatacji: Synchronize jest krawędzią blokującą. W indywidualnym oprogramowaniu dla przedsiębiorstw z importami, BDE-zastąpienie z natywną integracją-zapytaniami, zadaniami integracyjnymi lub usługami działającymi w tle z komponentą UI tę krawędź należy świadomie kontrolować – inaczej z „rzadko” zrobi się „zawsze, gdy jest pilne”.

Zasada podstawowa: nie pozwalać, aby wątek UI czekał na workera (gdy w grę wchodzi Synchronize)

Jeżeli worker gdziekolwiek używa Synchronize, wątek główny nie powinien twardo blokująco czekać na tego workera. Brzmi trywialnie, ale w kodzie legacy to jedna z najczęstszych przyczyn, ponieważ szybko dodaje się „poczekajmy chwilę przy zamykaniu” lub „okno postępu czeka na zakończenie”.

Praktyczne konsekwencje:

  • Brak wywołań WaitFor w wątku UI, gdy w workerze istnieje ścieżka wykorzystująca Synchronize.
  • Zakończenie wątku sygnalizować przez Event/Callback: UI pozostaje responsywne, sprząta dopiero po sygnale.
  • Aktualizacje UI zasadniczo wysyłać za pomocą TThread.Queue lub Dispatchera, aby worker nie blokował.

TThread.Queue jest często lepszą domyślną opcją: Worker wysyła zadanie do wątku głównego, kontynuuje działanie i nie blokuje się. To zapobiega wielu deadlockom. Nie rozwiązuje jednak wszystkich przypadków brzegowych – na przykład gdy w workerze koniecznie potrzebujesz wyniku, który jest generowany w wątku głównym (np. dostęp do zasobu powiązanego z UI lub komponentu zależnego od wątku).

TThread i Synchronize bez UI-deadlocków: model myślowy dla czystych przekazań

Solidny model myślowy: istnieje tylko niewiele uzasadnionych synchronicznych przekazań do wątku głównego. Reszta to stan, prezentacja lub telemetria – a więc asynchroniczne.

Proste rozróżnienie pomaga w przeglądach i przy stabilizacji istniejących projektów:

  • „Tylko wyświetlanie”: postęp, wpis w logu, licznik, lampka sygnalizacyjna, włącz/wyłącz – zawsze Queue.
  • „Przekazanie stanu”: worker dostarcza obiekt danych/DTO, UI renderuje – Queue, ale z kopiowaniem/niemutowalnością (czyli bez współdzielonych modyfikowanych struktur).
  • „UI musi zdecydować”: tylko tutaj potrzebna jest semantyka synchroniczna (np. zapytanie do użytkownika). Wtedy zasadnicze pytanie: czy worker naprawdę musi czekać, czy można przebudować workflow (maszyna stanów, przerwanie zadania, wznowienie później)?

Szczególnie trzecia kategoria to pułapka deadlocków: jeśli worker czeka na wynik z UI, UI łatwo zostanie skłonione do oczekiwania na workera (lub pośrednio przez blokady). To pod obciążeniem, przy wolnych bazach danych lub w środowiskach Remote-Desktop ujawnia się znacznie częściej.

Fragment kodu: UI-Dispatcher z Queue, opcjonalnym Timeoutem i czystym zamykaniem

Następujący wzorzec kapsułuje przekazy do UI w małej klasie pomocniczej. Otrzymują Państwo:

  • Post: fire-and-forget przez TThread.Queue (typowe dla aktualizacji stanu).
  • Call: wywołanie synchroniczne z Timeout (nietypowe, ale przydatne w sytuacjach legacy), bez bezpośredniego używania Synchronize jako punktu blokującego.
  • Shutdown-Schutz: Nie przyjmować nowych zadań UI, a kolejkowane zadania sprawdzają flagę przed manipulacją kontrolkami.

Techniczna klasyfikacja: Używamy Queue plus TEvent (zdarzenie jądra) do sygnalizacji zwrotnej. Worker nie czeka na Synchronize, lecz na zdarzenie, które jest ustawiane we wątku głównym po wykonaniu akcji umieszczonej w kolejce. Timeout zapobiega „wiecznemu” zawieszeniu, jeśli wątek UI z jakiegoś powodu przestanie przetwarzać zadania.

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.

Cel kodu i gdzie jest on świadomie „niezwykły”

Ten wzorzec nie zastępuje całkowicie Synchronize, ale czyni przekazywanie synchroniczne kontrolowalnym: wątek roboczy nie czeka na mechanikę Synchronize, lecz na zdarzenie (Event). Dzięki temu można wymuszać limity czasu (timeouts), w czasie pracy ujawnić, że wątek UI jest zablokowany, oraz w fazie zamykania konsekwentnie odrzucać nowe zadania UI.

Część „niezwykła” nie polega na samym zdarzeniu, lecz na decyzji, by odwzorować semantykę synchroniczną za pomocą Queue + Event. Ma to sens dokładnie wtedy, gdy w istniejących aplikacjach trzeba stopniowo podnosić stabilność, bez konieczności natychmiastowej przebudowy architektury każdej lokalizacji Synchronize.

Randbedingungen und Stolperfallen

  • Widoczność pamięci: DoneEvent jest punktem synchronizacji. Dzięki temu odczyt RaisedObj po WaitFor jest spójny. Mimo to RaisedObj powinien pozostać lokalny dla każdego wywołania (jak tutaj), a nie globalny.
  • Obsługa wyjątków: AcquireExceptionObject zapobiega „zniknięciu” wyjątku w wątku głównym. Przy ponownym rzuceniu w workerze stacktrace nie jest identyczny z oryginałem, ale komunikat o błędzie pozostaje w logu workera i zadanie może się poprawnie nie powieść.
  • Timeout to diagnoza i zabezpieczenie: Nie „naprawia” zablokowanego wątku głównego. Zapobiega jednak temu, by worker wiązał zasoby bez ograniczeń (np. utrzymywanie otwartych transakcji BDE-Ablosung mit nativer Anbindung), i pozwala uczynić klasę błędu mierzalną.
  • Wyłączanie musi zaczynać się wcześnie: BeginShutdown należy umieścić w centralnej sekwencji zamykania (np. bardzo wcześnie w OnCloseQuery głównej formy). W przeciwnym razie wciąż będą kolejkowane zadania UI, podczas gdy okna są już zniszczone.

Strategia blokad: jak unikać inwersji blokad przy callbackach UI

Wiele deadlocków nie wynika z WaitFor, lecz z niejasnej kolejności blokad. Typowy przebieg: worker blokuje „model danych”, wywołuje aktualizację UI przez Synchronize, a aktualizacja UI ponownie odwołuje się do „modelu danych”. To jest logicznie zrozumiałe, lecz technicznie katastrofalne.

Praktyczne zasady, które da się wdrożyć w zespołach:

  • Nie trzymać blokad przez granice wątków: Zanim worker cokolwiek w kierunku UI zakolejkuje lub zsynchronizuje, blokady domenowe powinny być zwolnione.
  • UI odczytuje snapshoty: Callbacki UI nie powinny „na żywo” patrzeć w struktury workera, lecz wyświetlać kopie/snapshoty (np. DTO, rekord, proste wartości).
  • Logowanie to kandydat na blokadę: Jeśli logowanie wewnętrznie używa kolejki, blokady pliku lub singletonu, może stać się elementem deadlocka. Callbacki UI powinny ograniczać logowanie do minimum lub zapisywać przez oddzielną, nieblokującą pipeline logów.

Jeśli już masz architekturę Layer-3 (UI, Services/Domäne, infrastruktura jak dostęp do danych): callbacki UI powinny zasadniczo robić tylko UI. Wszystko, co jest „Service”, nie powinno być w callbacku. To znacząco redukuje efekty reentrancyjne.

Zamykanie bez zawieszeń: „nie WaitFor, lecz kooperatywne zatrzymywanie”

Przy zamykaniu często się to psuje: UI zamyka się, wątek ma zostać zakończony, ale zakolejkowane zadania UI są nadal otwarte. Czyste zamykanie to mniej „zabijanie wątku”, a raczej mała choreografia:

  1. Ustawić flagę zamknięcia (np. TUiDispatcher.BeginShutdown): Od tego momentu żadne nowe zadania UI.
  2. Kooperatywne zatrzymanie workera: Worker sprawdza flagę anulowania (np. TEvent lub podobne do TCancellationToken) i kończy pętle/czekania.
  3. Nie blokować UI: Brak twardych pętli oczekiwania w wątku głównym. Jeśli musisz „czekać”, to tylko przy zachowanej pętli komunikatów (lub lepiej: unikaj tego całkowicie, obsługując zakończenie przez callback).
  4. Ostatnie porządki UI jedynie wtedy, gdy okna/kontrolki na pewno jeszcze istnieją. W VCL moment jest istotny: najpóźniej gdy handle zniknie, zakolejkowane zadania nie mogą już trafiać do kontrolek.

Ten proces jest istotny dla eksploatacji i wsparcia: „Aplikacja zawiesza się przy zamykaniu” to klasyczny problem akceptacyjny, mimo że merytorycznie wszystko zostało poprawnie przetworzone. Zdefiniowane zamykanie naprawdę oszczędza czas.

Debugowanie: jak uczynić deadlock namacalnym (bez zgadywania)

Gdy jest zawieszenie, kluczowe pytanie brzmi: Kto czeka na kogo? Kilka podejść, które sprawdzają się w istniejących projektach:

  • Zidentyfikować wszystkie miejsca oczekiwania: Pełnotekstowe przeszukanie pod kątem WaitFor, Sleep w pętlach, TEvent.WaitFor, INFINITE. Wiele problemów to „ukryte” oczekiwania (również w bibliotekach).
  • Stan wątku w logu: Zapisuj w logu przy granicach wątku: „Job uruchomiony”, „UI w kolejce”, „UI wykonane”, „Job zakończony”. Dzięki temu zobaczą Państwo, czy główny wątek w ogóle przetwarza zadania z kolejki.
  • Sprawdzić podejrzenie pętli komunikatów: Jeśli zawieszenie występuje tylko przy dialogach modalnych lub przy określonych interakcjach COM, pętla komunikatów często jest wąskim gardłem. Celem jest wtedy: odciążyć obsługę UI, izolować wywołania COM, nie wykonywać długotrwałych operacji w UI.
  • Uczynić locki widocznymi: W przypadku TCriticalSection/TMonitor warto użyć builda debug z metadanymi „Owner” (np. ID wątku przy Enter) i pomiarem czasu. W ten sposób zobaczą Państwo, który lock trzyma główny wątek, podczas gdy wątek roboczy czeka na UI.

Ważne jest podejście: deadlocki rzadko są „przypadkowe”. To deterministyczne cykle, które rzadko się uruchamiają. Jeśli raz poprawnie zidentyfikują Państwo cykl, jego usunięcie jest zwykle jasne.

Warianty dostępu do danych i zadań interfejsów (FireDAC, REST, system plików)

Szczególnie w przypadku FireDAC (lub innych dostępów do bazy danych) obowiązuje: połączenie, transakcja i zestawy danych są w praktyce przypisane do wątku. Wątek roboczy powinien wyłącznie posiadać swój kontekst DB. Wywołania UI powinny ograniczać się do prezentacji, a nie do operacji na bazie danych. Sprawdzony wzorzec to:

  1. Wątek roboczy wykonuje zapytanie/REST-call, oblicza wynik, tworzy DTO.
  2. Wątek roboczy wysyła DTO za pomocą Queue/TUiDispatcher.Post do UI.
  3. UI przyjmuje DTO i aktualizuje kontrolki (bez odwoływania się do obiektów wątku roboczego).

Jeśli mają Państwo historycznie ukształtowane formy mieszane („UI wyzwala DB, callback DB wyzwala UI”), warto przeprowadzić stopniowe odsprzęganie: najpierw izolować punkty przekazania (Dispatcher), następnie przenieść stany do serwisów/modelu. To jest mniej ryzykowne niż duża przebudowa, a jednocześnie zauważalnie redukuje deadlocki.

Wniosek: unikanie deadlocków oznacza kontrolę nad przekazaniami

TThread i Synchronize bez deadlocków UI to mniej pojedyncza technika niż dyscyplina: minimalizować blokady, utrzymywać porządek w kolejności locków, zdefiniować procedury zamykania i redukować synchroniczne zależności UI. Pokazany UI-Dispatcher jest w sytuacjach legacy szczególnie użyteczny, ponieważ używa Queue jako domyślu, a dla koniecznych synchronicznych przekazań dodaje Timeout i jasne reguły zamykania.

Pozostają ograniczenia zastosowania: jeśli główny wątek jest trwale zablokowany (przez ciężką logikę UI, łańcuchy dialogów modalnych lub wywołania COM-STA), nawet dispatcher może jedynie diagnozować i kontrolowanie przerywać. Trwałe rozwiązanie polega na odciążeniu UI i rozdzieleniu odpowiedzialności. Jeśli potrzebują Państwo wsparcia w istniejącej aplikacji Delphi – od pułapek wielowątkowości po stopniową stabilizację – mogą Państwo zakwalifikować przedsięwzięcie tutaj: omówić projekt lub modernizację z Net-Base.

W kontekście merytorycznym wielowątkowość w Delphi oraz deadlocki związane z Synchronize również odgrywają ważną rolę, gdy integracje, przepływy danych i rozwój muszą współgrać.

Omówić projekt lub modernizację z Net-Base.

Udostępnij wpis

Udostępnij ten wpis bezpośrednio

LinkedIn, X, XING, Facebook, WhatsApp i e‑mail są natychmiast dostępne. Dla Instagrama przygotowujemy od razu link i krótki tekst.

E-mail

Instagram otwiera się w nowej karcie. Link i krótki tekst są wcześniej kopiowane do schowka.