Net-Base Журнал

15.05.2026

TThread и Synchronize без блокировок UI: надёжные шаблоны для VCL и унаследованного кода

Как надёжно работать с TThread, Synchronize и Queue, не допуская зависания UI: типичные причины блокировок (deadlocks), практичный паттерн UI‑диспетчера (включая тайм‑аут), защита при завершении, стратегии блокировок и проверки для отладки зрелых Delphi-приложений.

15.05.2026

Кто в 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-поток по каким‑то причинам перестаёт обрабатывать очередь.

Delphi
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-задачи ещё открыты. Корректное завершение — это меньше «убить поток», больше небольшая хореография:

  1. Установить флаг завершения (например, TUiDispatcher.BeginShutdown): с этого момента никаких новых UI-задач.
  2. Кооперативная остановка Worker: Worker проверяет флаг отмены (например TEvent или аналог TCancellationToken) и завершает циклы/ожидания.
  3. Не блокировать UI: Никаких жёстких циклов ожидания в Main Thread. Если нужно «подождать», то только с продолжающейся Message Loop (или лучше: полностью избежать, обрабатывая завершение через колбэк).
  4. Последние операции очистки 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 должны ограничиваться отображением, а не операциями с БД. Надёжная схема выглядит так:

  1. Рабочий поток выполняет Query/REST-вызов, вычисляет результат, формирует DTO.
  2. Рабочий поток отправляет DTO через Queue/TUiDispatcher.Post в UI.
  3. 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, когда интеграции, потоки данных и эволюция системы должны работать согласованно.

Обсудить проект или модернизацию с Net-Base.

Поделиться записью

Поделиться этой записью напрямую

LinkedIn, X, XING, Facebook, WhatsApp и E-Mail доступны сразу. Для Instagram мы сразу подготовим ссылку и краткий текст.

Электронная почта

Instagram открывается в новой вкладке. Ссылка и короткий текст предварительно копируются в буфер обмена.