Кто в Delphi работает с потоками, рано или поздно сталкивается с TThread.Synchronize. И именно там происходят неприятные вещи: спорадические зависания, «UI не отвечает», кажущиеся случайными дедлоки при завершении или при открытии диалога. Корень проблемы редко в том, что «Delphi сломан», почти всегда это неблагоприятная смесь из Synchronize, блокирующих операций ожидания и UI-потока, который перестаёт корректно обрабатывать свою Message Loop (обработку событий VCL). В этой статье показаны устойчивые, в контексте наследуемого кода применимые шаблоны для TThread und Synchronize ohne UI-Deadlocks — включая вариант с таймаутом, корректную передачу ошибок, правила завершения и подсказки по отладке, которые помогают в реальных существующих приложениях.
Почему на практике возникают Deadlocks вокруг Synchronize
Synchronize означает: рабочий поток помещает процедуру в очередь, которая выполняется в главном потоке, и обычно ожидает, пока эта процедура не завершится. В VCL-приложениях главный поток одновременно является UI-потоком (окна, контролы, события). Кроме того, во многих установках в нём работают COM-объекты в STA-Modell (Single-Threaded Apartment: COM-вызовы должны обрабатываться в том же потоке), что ещё сильнее усиливает зависимость от корректно работающей Message Loop.
Дедлоки обычно возникают из-за одной из следующих конфигураций:
- WaitFor im Main Thread: UI-поток ожидает рабочий поток (например,
MyThread.WaitFor), в то время как рабочий поток черезSynchronizeнуждается в UI-потоке. Оба ждут — конец. - Инверсия блокировок: рабочий поток удерживает лок (например,
TCriticalSectionилиTMonitor) и вызываетSynchronize. Синхронизированная UI-процедура пытается захватить тот же лок (прямо или косвенно, часто через логирование/кэш/одиночки) — классический дедлок. - Shutdown/Destroy: при закрытии формы поток завершают, в то время как ещё остаются задачи
Synchronize. Особенно опасно, если синхронизированные вызовы ссылаются на контролы, которые как раз уничтожаются. - Message Loop блокируется: модальные диалоги, длительные UI-операции, блокирующий COM-вызов или обработчик, который «вроде бы быстро» выполняет DB/REST, удерживают главный поток. Задачи
Synchronizeвыполняются с задержкой или вовсе не выполняются.
Главный вывод для архитектуры и эксплуатации: Synchronize — это граница блокировки. В индивидуальном корпоративном ПО с импортами, BDE-замена с нативной интеграцией-запросами, интерфейсными задачами или фоновыми службами с UI-компонентой эту границу следует сознательно контролировать — иначе из «редко» рано или поздно станет «всегда, когда нужно срочно».
Основное правило: не заставлять UI-поток ждать рабочий поток (когда используется Synchronize)
Если рабочий поток где-то использует Synchronize, главный поток не должен жёстко блокироваться в ожидании этого рабочего потока. Это звучит тривиально, но в наследуемом коде именно это одна из самых частых причин: «подождём немного при закрытии» или «диалог прогресса ждёт завершения» быстро добавляются и приводят к проблемам.
Практические последствия:
- Не вызывать
WaitForв UI-потоке, если в Worker существует путь, использующийSynchronize. - Сигнализируйте завершение потока через Event/Callback: UI остаётся отзывчивым и выполняет очистку только после получения сигнала.
- Обновления UI в целом публиковать через
TThread.Queueили диспетчер, чтобы Worker не блокировались.
TThread.Queue часто является лучшим вариантом по умолчанию: Worker отправляет задачу в главный поток (Main Thread), продолжает работу и не блокируется. Это предотвращает многие взаимные блокировки (Deadlocks). Но это не решает все пограничные случаи — например, когда в Worker вам обязательно нужен результат, создаваемый в главном потоке (например, доступ к ресурсу, привязанному к UI, или компоненту, зависящему от потока).
TThread и Synchronize без UI-Deadlocks: модель мышления для корректной передачи
Надёжная модель мышления такова: существует лишь несколько оправданных синхронных передач в главный поток. Всё остальное — состояние, отображение или телеметрия — и потому должно быть асинхронным.
Простая классификация помогает при ревью и при стабилизации существующих проектов:
- «Только отображать»: индикатор выполнения, строка логов, счётчик, сигнальная лампа, включение/отключение — всегда
Queue. - «Передать состояние»: Worker предоставляет объект данных/DTO, UI рендерит —
Queue, но с копированием/неизменяемостью (т. е. без общих изменяемых структур). - «UI должно принять решение»: только здесь нужна синхронная семантика (например, запрос у пользователя). Тогда главный вопрос: действительно ли Worker должен ждать, или можно перестроить поток выполнения (машина состояний, отмена задания, продолжение позже)?
Именно третья категория — ловушка для deadlock’ов: если Worker ждёт результата от UI, UI быстро склоняется к тому, чтобы ждать Worker (или косвенно через блокировки). При высокой нагрузке, медленных базах данных или в средах Remote Desktop это приводит к проблемам значительно чаще.
Фрагмент исходного кода: UI-диспетчер с Queue, опциональным таймаутом и корректным завершением
Следующий шаблон инкапсулирует передачи в UI в небольшой вспомогательный класс. Вы получаете:
- Post: fire-and-forget через
TThread.Queue(типично для обновлений статуса). - Call: синхронный вызов с Timeout (необычно, но полезно в legacy-сценариях), без прямого использования
Synchronizeкак точки блокировки. - Защита при завершении: не принимать новые UI-задачи и проверять флаг в queued задачах перед доступом к контролам.
Техническая классификация: мы используем Queue вместе с TEvent (ядерное событие) для обратной связи. Worker не ждёт Synchronize, а ожидает событие, которое устанавливается в главном потоке после выполнения поставленного в очередь действия. Таймаут предотвращает «вечную» блокировку, если UI-поток по каким‑то причинам перестаёт обрабатывать очередь.
unit UiDispatch;
interface
uses
System.SysUtils,
System.Classes,
System.SyncObjs;
type
EUiDispatchTimeout = class(Exception);
EUiDispatchShuttingDown = class(Exception);
/// <summary>
/// Инкапсулирует вызовы UI из рабочих потоков.
/// Post: асинхронно (Queue).
/// Call: синхронно с таймаутом, без прямой блокировки TThread.Synchronize.
/// </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;
// При завершении (Shutdown) не принимать новые UI-задачи.
if IsShuttingDown then
Exit;
// Очередь не блокирует рабочий поток.
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 находится в Shutdown.');
DoneEvent := TEvent.Create(nil, True, False, '');
try
RaisedObj := nil;
TThread.Queue(nil,
procedure
begin
try
if not IsShuttingDown then
AProc();
except
// Передаём объект исключения через границу потоков.
// Важно: здесь не использовать "raise", иначе исключение попадёт в главный поток.
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(
'Таймаут после %d мс: главный поток не обработал вызов UI.',
[ATimeoutMs]);
else
raise Exception.Create('Неожиданный статус WaitFor в UI-Dispatcher.');
end;
finally
DoneEvent.Free;
end;
end;
end.Zweck des Codes und wo er bewusst „ungewöhnlich“ ist
Шаблон не полностью заменяет Synchronize, но он делает синхронные передачи контролируемыми: рабочий поток не ожидает механики Synchronize напрямую, вместо этого он ждёт событие. Это даёт возможность принудительно задавать таймауты, сделать видимым в работе, что главный поток завис, и в фазе Shutdown последовательно отказывать в новых UI-задачах.
«Необычная» часть — не само событие, а решение выражать синхронную семантику через сочетание Queue + Event. Это имеет смысл именно тогда, когда в существующих приложениях необходимо поэтапно повышать стабильность, не переписывая немедленно все места с Synchronize архитектурно.
Randbedingungen und Stolperfallen
- Speichersichtbarkeit:
DoneEventявляется границей синхронизации. Благодаря этому чтениеRaisedObjпослеWaitForбудет согласованным. Тем не менееRaisedObjдолжен оставаться локальным для каждого вызова (как здесь), никогда не делать его глобальным. - Обработка исключений:
AcquireExceptionObjectпредотвращает «исчезновение» исключения в Main Thread. При повторном выбрасывании в Worker стек-трейс не идентичен источнику, но сообщение об ошибке сохраняется в логе Worker, и задача может корректно завершиться с ошибкой. - Таймаут — диагностика и защита: Он не «починит» заблокированный Main Thread. Зато он предотвращает бессрочное удержание ресурсов воркерами (например, BDE-Ablosung mit nativer Anbindung-транзакции остаются открытыми) и делает класс ошибок измеримым.
- Завершение должно начинаться рано:
BeginShutdownдолжен входить в центральную последовательность завершения (например, очень рано вOnCloseQueryглавной формы). Иначе будут поставлены в очередь UI-задачи, пока окна уже уничтожаются.
Стратегия блокировок: как избежать инверсий блокировок при UI-колбэках
Многие дедлоки возникают не из-за WaitFor, а из-за неясного порядка блокировок. Типичный сценарий: Worker блокирует «модель данных», вызывает обновление UI через Synchronize, обновление UI снова обращается к «модели данных». С логической точки зрения это понятно, но технически фатально.
Практические правила, которые можно внедрить в командах:
- Не держать блокировки через границы потоков: Прежде чем Worker что‑то поставит в очередь/синхронизирует с UI, прикладные блокировки должны быть освобождены.
- UI читает снимки (Snapshots): UI-колбэки не должны «вживую» смотреть в структуры Worker, а показывать копии/снимки (например DTO, Record, простые значения).
- Логирование как потенциальная блокировка: Если логирование внутри использует очередь, файловую блокировку или синглтон, оно может стать частью дедлока. UI-колбэки должны минимизировать логирование или писать в отдельный, неблокирующий канал логирования.
Если у вас уже есть Layer-3-архитектура (UI, Services/Domäne, инфраструктура вроде доступа к данным): UI-колбэки по идее должны заниматься только UI. Всё, что относится к «Service», не должно находиться в колбэке. Это существенно снижает эффекты повторного входа.
Завершение без зависаний: «не WaitFor, а кооперативная остановка»
При завершении часто всё идет не так: UI закрывается, поток должен уйти, но поставленные в очередь UI-задачи ещё открыты. Корректное завершение — это меньше «убить поток», больше небольшая хореография:
- Установить флаг завершения (например,
TUiDispatcher.BeginShutdown): с этого момента никаких новых UI-задач. - Кооперативная остановка Worker: Worker проверяет флаг отмены (например
TEventили аналогTCancellationToken) и завершает циклы/ожидания. - Не блокировать UI: Никаких жёстких циклов ожидания в Main Thread. Если нужно «подождать», то только с продолжающейся Message Loop (или лучше: полностью избежать, обрабатывая завершение через колбэк).
- Последние операции очистки UI только если окна/контролы гарантированно ещё существуют. В VCL время важно: как только дескриптор отсутствует, поставленные в очередь задачи не должны обращаться к контролам.
Этот процесс важен для эксплуатации и поддержки: «Приложение зависает при закрытии» — классическая проблема приёмки, даже если с функциональной точки зрения всё корректно обработано. Чётко определённый Shutdown экономит реальное время.
Отладка: как сделать дедлок воспроизводимым (без домыслов)
Когда всё зависает, ключевой вопрос: кто на кого ждёт? Несколько подходов, проверенных в существующих проектах:
- Инвентаризация всех мест ожидания: полнотекстовый поиск по
WaitFor,Sleepв циклах,TEvent.WaitFor,INFINITE. Многие проблемы — «скрытые» Waits (включая ожидания внутри библиотек). - Состояние потока в логе: логируйте на границах потоков: «Задача запускается», «UI поставлен в очередь», «UI выполнен», «Задача завершена». Так вы увидите, обрабатывает ли главный поток вообще поставленные в очередь задачи.
- Проверить подозрение на Message Loop: если зависание возникает только при модальных диалогах или при определённых COM-взаимодействиях, часто узким местом является Message Loop. Цель в этом случае: разгрузить обработчики UI, изолировать COM-вызовы, не выполнять длительные операции в UI.
- Сделать блокировки видимыми: при
TCriticalSection/TMonitorимеет смысл использовать Debug-сборку с метаданными «владельца» (например, ID потока при Enter) и измерением времени. Так вы увидите, какой lock удерживает главный поток, пока worker ждёт UI.
Важно понимать: дедлоки редко бывают «случайными». Это детерминированные циклы, которые срабатывают нечасто. Как только вы корректно идентифицируете цикл, исправление обычно становится очевидным.
Варианты доступа к данным и задач интерфейсов (FireDAC, REST, Dateisystem)
Особенно для FireDAC (или других обращений к БД) справедливо: соединение, транзакция и наборы данных на практике привязаны к потоку. Рабочий поток должен владеть своим контекстом БД исключительно сам. Вызовы из UI должны ограничиваться отображением, а не операциями с БД. Надёжная схема выглядит так:
- Рабочий поток выполняет Query/REST-вызов, вычисляет результат, формирует DTO.
- Рабочий поток отправляет DTO через
Queue/TUiDispatcher.Postв UI. - UI принимает DTO и обновляет контролы (без обращения к объектам рабочего потока).
Если у вас исторически сложились смешанные формы («UI запускает БД, callback из БД запускает UI»), имеет смысл поэтапно разъединять: сначала изолировать точки передачи (диспатчер), затем переносить состояния в сервисы/модель. Это менее рискованно, чем крупный рефакторинг, и заметно снижает вероятность дедлоков.
Итог: избегание дедлоков — это контроль передач
TThread und Synchronize ohne UI-Deadlocks — это скорее дисциплина, чем отдельная техника: минимизировать блокировки, поддерживать корректные порядки блокировок, определить процедуру завершения и сократить синхронные зависимости UI. Показанный UI-Dispatcher полезен в legacy-сценариях, поскольку по умолчанию использует Queue, а для необходимых синхронных передач добавляет Timeout и ясные правила Shutdown.
Существуют пределы применимости: если главный поток постоянно блокируется (из‑за тяжёлой UI-логики, цепочек модальных диалогов или вызовов COM-STA), даже Dispatcher сможет лишь диагностировать ситуацию и корректно прервать выполнение. Устойчивое решение в этом случае — разгрузить UI и разделить зоны ответственности. Если вам нужна поддержка в существующем Delphi-приложении — от ловушек многопоточности до поэтапной стабилизации — вы можете отнести задачу сюда: обсудить проект или модернизацию с Net-Base.
В предметной области также важную роль играют Delphi Multithreading и Synchronize Deadlock, когда интеграции, потоки данных и эволюция системы должны работать согласованно.