Хто в Delphi працює з потоками, рано чи пізно натрапить на TThread.Synchronize. І саме там відбуваються неприємності: спорадичні зависання, „UI не реагує“, на перший погляд випадкові взаємні блокування при завершенні або відкритті діалогу. Причина рідко в тому, що «Delphi зламаний», натомість майже завжди це невдале поєднання Synchronize, блокуючих операцій очікування і UI-потоку, який більше не коректно обробляє свою Message Loop (обробку подій VCL). Ця стаття показує надійні, у контексті legacy практичні шаблони для TThread і Synchronize без UI-Deadlocks — включно з варіантом з таймаутом, коректною передачею помилок, правилами завершення та порадами для налагодження, що допомагають у реальних існуючих застосунках.
Чому на практиці виникають deadlocks навколо Synchronize
Synchronize означає: робочий потік ставить процедуру в чергу, яка виконується у головному потоці, і зазвичай чекає, доки ця процедура не завершиться. У VCL-застосунках головний потік одночасно є UI-потоком (вікна, елементи керування, події). Додатково в багатьох інсталяціях у ньому працюють COM-об’єкти в STA-моделі (Single-Threaded Apartment: виклики COM мають оброблятися в тому ж потоці), що посилює залежність від справної Message Loop.
Взаємні блокування зазвичай виникають через одну з таких конфігурацій:
- WaitFor у головному потоці: UI-потік чекає на робочий потік (наприклад
MyThread.WaitFor), тоді як робочий потік саме зараз черезSynchronizeпотребує UI-потоку. Обидва чекають — кінець. - Lock-Inversion: Робочий потік утримує лок (наприклад
TCriticalSectionабоTMonitor) і викликаєSynchronize. Синхронізована UI-процедура намагається захопити той самий лок (безпосередньо або опосередковано, часто через логування/кеш/сінглтони) — класичне взаємне блокування. - Shutdown/Destroy: При закритті форми потік завершується, поки ще є відкладені завдання
Synchronize. Особливо підступно: синхронізовані виклики посилаються на елементи керування, які якраз знищуються. - Блокування Message Loop: Модальні діалоги, тривалі UI-операції, блокуючий COM-виклик або обробник, який «швиденько» виконує DB/REST, утримують головний потік. Завдання
Synchronizeобробляються із затримкою або взагалі не виконуються.
Найважливіший наслідок для архітектури та експлуатації: Synchronize — це межа блокування. В індивідуальному корпоративному програмному забезпеченні з імпортами, BDE-заміна з нативним підключенням-запити, інтерфейсні джоби або фонові сервіси з UI-компонентом цю межу слід свідомо контролювати — інакше з «рідко» рано чи пізно стане «завжди, коли терміново».
Основне правило: не дозволяти UI-потоку чекати на Worker (коли в грі є Synchronize)
Якщо робочий потік десь використовує Synchronize, головний потік не повинен жорстко блокувально чекати на цей робочий потік. Звучить тривіально, але в legacy-коді це одна з найпоширеніших причин, бо «трохи почекаємо при закритті» або «діалог прогресу чекає на завершення» швидко додаються.
Практичні наслідки:
- Keine
WaitFor-Aufrufe im UI-Thread, sobald im Worker ein Pfad existiert, derSynchronizenutzt. - Thread-Abschluss per Event/Callback signalisieren: UI bleibt responsiv, räumt erst nach Signal auf.
- UI-Updates grundsätzlich über
TThread.Queueoder 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
Synchronizeals 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.
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.Призначення коду та де він свідомо «незвичний»
Шаблон не замінює повністю Synchronize, але він робить синхронні передачі контрольованими: Worker не чекає на механіку Synchronize, а на подію. Це дає можливість встановлювати тайм-аути, у робочому режимі виявляти зависання UI-потоку та в фазі завершення послідовно відмовлятися від нових UI-джобів.
Незвичний елемент — не сама подія, а рішення відтворювати синхронну семантику за допомогою Queue + Event. Це виправдано саме тоді, коли потрібно поетапно підвищувати надійність у існуючих застосунках, не переробляючи архітектурно кожну точку, де використовується Synchronize.
Граничні умови та підводні камені
- Видимість у пам’яті:
DoneEventє краєм синхронізації. Завдяки цьому читанняRaisedObjпісляWaitForє консистентним. Тим не меншеRaisedObjмає залишатися локальним для кожного виклику (як тут), ніколи глобальним.
AcquireExceptionObject запобігає «зникненню» винятку в Main Thread. При повторному викиданні в Worker стек викликів не буде ідентичним первісному, але повідомлення про помилку залишається в логах Worker, і завдання може коректно завершитися з помилкою.BeginShutdown gehört in eine zentrale Shutdown-Sequenz (z. B. sehr früh in OnCloseQuery der Hauptform). Sonst werden noch UI-Jobs queued, während Fenster bereits zerstört werden.Lock-Strategie: so vermeiden Sie Lock-Inversionen mit UI-Callbacks
Viele Deadlocks entstehen nicht durch WaitFor, sondern durch eine unklare Lock-Reihenfolge. Typischer Ablauf: Worker lockt „Datenmodell“, ruft UI-Update per Synchronize, UI-Update greift wieder auf „Datenmodell“ zu. Das ist logisch nachvollziehbar, aber technisch fatal.
Praktische Regeln, die sich in Teams durchsetzen lassen:
- Keine Locks über Thread-Grenzen halten: Bevor ein Worker irgendetwas in Richtung UI queued/synchronisiert, sollten fachliche Locks freigegeben sein.
- UI liest Snapshots: UI-Callbacks sollten nicht „live“ in Worker-Strukturen schauen, sondern Kopien/Snapshots anzeigen (z. B. DTO, Record, einfache Werte).
- Logging ist ein Lock-Kandidat: Wenn Logging intern eine Queue, Datei-Lock oder ein Singleton verwendet, kann es Teil eines Deadlocks werden. UI-Callbacks sollten Logging minimal halten oder über eine separate, nicht-blockierende Log-Pipeline schreiben.
Wenn Sie schon eine Layer-3-Architektur (UI, Services/Domäne, Infrastruktur wie Datenzugriff) haben: UI-Callbacks dürfen idealerweise nur UI machen. Alles, was „Service“ ist, gehört nicht in den Callback. Das reduziert Reentrancy-Effekte deutlich.
Shutdown ohne Hänger: „nicht WaitFor, sondern kooperatives Stoppen“
Beim Beenden kippt es oft: Die UI schließt, ein Thread soll weg, aber queued UI-Jobs sind noch offen. Ein sauberer Shutdown ist weniger „Thread killen“, sondern eine kleine Choreografie:
- Shutdown-Flag setzen (z. B.
TUiDispatcher.BeginShutdown): Ab jetzt keine neuen UI-Jobs mehr. - Worker kooperativ stoppen: Der Worker prüft ein Cancel-Flag (z. B.
TEventoderTCancellationToken-ähnlich) und beendet Schleifen/Waits. - UI nicht blockieren: Keine harte Warte-Schleife im Main Thread. Wenn Sie „warten müssen“, dann nur mit weiterlaufender Message Loop (oder besser: ganz vermeiden, indem Sie den Abschluss per Callback behandeln).
- Letzte UI-Aufräumarbeiten nur, wenn Fenster/Controls garantiert noch existieren. In VCL ist der Zeitpunkt wichtig: spätestens wenn das Handle weg ist, dürfen queued Jobs nicht mehr auf Controls gehen.
Dieser Ablauf ist für Betrieb und Support relevant: „Die Anwendung hängt beim Schließen“ ist ein klassisches Akzeptanzproblem, obwohl fachlich alles korrekt verarbeitet wurde. Ein definierter Shutdown spart hier real Zeit.
Debugging: Wie Sie den Deadlock greifbar machen (ohne Rätselraten)
Wenn es hängt, ist die Kernfrage: Wer wartet auf wen? Ein paar Ansätze, die sich in Bestandsprojekten bewähren:
- Інвентаризувати всі місця очікування: Повнотекстовий пошук за
WaitFor,Sleepв циклах,TEvent.WaitFor,INFINITE. Багато проблем — це «приховані» очікування (включно з бібліотеками). - Стан потоків у логах: Логувати на межах потоків: «Job startet», «queued UI», «UI ausgeführt», «Job fertig». Так ви побачите, чи обробляє головний потік поставлені в чергу завдання взагалі.
- Перевірити підозру на Message Loop: Якщо зависання відбувається лише при модальних діалогах або певних COM-взаємодіях, часто вузьке місце — цикл повідомлень. Мета тоді: розвантажити обробники UI, ізолювати COM-виклики, уникати тривалих операцій у UI.
- Зробити блокування видимими: Для
TCriticalSection/TMonitorмає сенс Debug-Build з метаданими «Owner» (наприклад, Thread-ID при Enter) та вимірюванням часу. Так ви побачите, яке блокування утримує головний потік у той час, як робітники чекають на UI.
Важлива позиція: Deadlocks рідко бувають «випадковими». Це детерміновані цикли, які лише зрідка активуються. Якщо ви однаково чітко ідентифікуєте цикл, виправлення зазвичай стає очевидним.
Варіанти доступу до даних і завдань інтерфейсів (FireDAC, REST, файловa система)
Особливо при FireDAC (або інших зверненнях до БД) справедливо: з’єднання, транзакція та набори даних на практиці є зв’язаними з потоком. Робочий потік повинен володіти своїм контекстом БД виключно сам. Виклики з UI мають обмежуватись відображенням, а не операціями над БД. Надійний шаблон такий:
- Робітник виконує запит/REST-виклик, обчислює результат, створює DTO.
- Робітник надсилає DTO через
Queue/TUiDispatcher.Postв UI. - UI приймає DTO і оновлює контролі (без звернення до об’єктів робітника).
Якщо у вас історично виросли змішані форми («UI ініціює БД, callback з БД ініціює UI»), варто поступово розв’язати зв’язки: спочатку ізолювати точки передачі (диспетчер), потім перемістити стани в сервіси/модель. Це менш ризиковано, ніж великий рефакторинг, але помітно зменшує ймовірність Deadlock.
Висновок: уникати Deadlock — означає контролювати передачі
TThread та Synchronize без UI-Deadlock — це радше дисципліна, ніж окрема техніка: мінімізувати блокування, дотримувати послідовності захоплення локів, визначити порядок завершення та зменшити синхронні залежності від UI. Показаний UI-диспетчер особливо корисний у legacy-сценаріях, бо використовує Queue як значення за замовчуванням, але для необхідних синхронних передач додає Timeout і чіткі правила завершення.
Межі застосування залишаються: якщо головний потік довгостроково блокується (через важку логіку UI, ланцюги модальних діалогів або COM-STA-виклики), диспетчер може тільки діагностувати та контрольовано перервати. Стійке рішення — розвантажити UI і розмежувати відповідальності. Якщо вам потрібна підтримка для існуючої Delphi-застосунку — від пасток багатопоточності до поступової стабілізації — ви можете узгодити проєкт тут: обговорити проєкт або модернізацію з Net-Base.
В професійному контексті також важливі Delphi Multithreading і Synchronize Deadlock, коли інтеграції, потоки даних і подальший розвиток мають працювати злагоджено.