Net-Base Списание

15.05.2026

TThread и Synchronize без UI-Deadlocks: устойчиви шаблони за VCL и наследен код

Как да работите надеждно с TThread, Synchronize и Queue, без потребителският интерфейс (UI) да блокира: типични причини за deadlock, практичен UI-dispatcher модел (вкл. timeout), защита при shutdown, Lock-стратегии и Debugging-проверки за еволюирали Delphi приложения.

15.05.2026

Който в Delphi работи с Threads, по-рано или късно стига до TThread.Synchronize. И точно там възникват неприятните неща: спорадични замръзвания, „UI реагира не“, привидно случайни deadlock-и при затваряне или при отваряне на диалог. Ядрото рядко е „Delphi е счупен“, а почти винаги е неблагоприятна комбинация от Synchronize, блокиращи операции за изчакване и един UI-Thread, който не обработва коректно своята Message Loop (обработката на събития на VCL). Тази статия показва устойчиви, в контекста на наследен код практически приложими шаблони за TThread и Synchronize без UI-Deadlocks – включително вариант с таймаут, коректно предаване на грешки, правила за shutdown и указания за дебъг, които помагат в реални поддържани приложения.

Защо в практиката възникват deadlock-и около Synchronize

Synchronize означава: работещ нишка поставя процедура в опашка, която се изпълнява в Main Thread, и обикновено изчаква, докато тази процедура завърши. В VCL-приложения Main Thread е същевременно UI-Thread (прозорци, контроли, събития). Освен това в много инсталации там работят COM-обекти в STA-модел (Single-Threaded Apartment: COM-виканията трябва да се обработват в същата нишка), което засилва зависимостта от работеща Message Loop.

Deadlock-ите типично възникват поради една от следните конфигурации:

  • WaitFor в Main Thread: UI-Thread изчаква worker (напр. MyThread.WaitFor), докато worker-ът тъкмо чрез Synchronize се нуждае от UI-Thread. И двамата чакат – край.
  • Lock-Inversion: Worker-ът държи lock (напр. TCriticalSection или TMonitor) и извиква Synchronize. Синхронизираната UI-процедура се опитва да вземе същия lock (директно или индиректно, често чрез логване/кеш/сингълтони) – класически deadlock.
  • Shutdown/Destroy: При затваряне на форма нишка се прекратява, докато все още има чакащи Synchronize-задачи. Особено неприятно: синхронизираните извиквания реферират контроли, които в момента се унищожават.
  • Message Loop blockiert: модални диалози, дълго изпълняващи се UI-операции, блокиращо COM-извикване или хендълър, който „малко по малко“ прави DB/REST, държат Main Thread заключен. Synchronize-задачите се обработват със забавяне или въобще не се изпълняват.

Най-важното последствие за архитектурата и експлоатацията: Synchronize е блокираща граница. В индивидуален корпоративен софтуер с импорти, BDE-замяна с нативна връзка-заявки, интерфейсни задачи или бекграунд услуги с UI-компонент тази граница трябва да бъде съзнателно контролирана – иначе от „рядко“ ще стане „винаги, когато е спешно“.

Основно правило: никога да не оставяте UI-Thread да чака worker (когато Synchronize е в игра)

Ако някъде worker използва Synchronize, Main Thread не бива да изчаква твърдо блокиращо този worker. Звучи тривиално, но в наследен код това е една от най-честите причини, защото „нека изчакаме малко при затваряне“ или „диалог за прогрес чака завършване“ бързо се добавят.

Практически последици:

  • Никакви WaitFor-повиквания в UI-нишката, веднага щом в Worker съществува път, който използва Synchronize.
  • Завършек на нишката да се сигнализира чрез Event/Callback: UI остава отзивчив и извършва почистване едва след получаване на сигнала.
  • UI-актуализации по принцип да се публикуват чрез TThread.Queue или през Dispatcher, за да не блокират Worker-ите.

TThread.Queue често е по-добрият вариант по подразбиране: Worker-ът поства работа към главната нишка, продължава да работи и не блокира. Това предотвратява много Deadlocks. Не решава обаче всички крайни случаи – например когато в Worker-а ви задължително ви е нужен резултат, който се генерира в главната нишка (напр. достъп до ресурс, привързан към UI, или компонент, който е thread-bound).

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

Устойчив мисловен модел е: има само няколко легитимни синхронни предавания към главната нишка. Всичко останало е статус, визуализация или телеметрия – и следователно асинхронно.

Едно просто разделение помага при ревюта и при стабилизиране на наследени проекти:

  • „Само за показване“: прогрес, ред в лога, брояч, сигнален индикатор, включване/изключване – винаги Queue.
  • „Предаване на състояние“: Worker доставя обект с данни/DTO, UI рендерира – Queue, но с копиране/неизменяемост (т.е. без споделени изменяеми структури).
  • „UI трябва да реши“: Само тук имате нужда от синхронна семантика (напр. потребителски диалог). Тогава реалният въпрос е: Трябва ли наистина Worker-ът да чака, или работният поток може да се преструктурира (State Machine, прекъсване на Job, възобновяване по-късно)?

Особено третата категория е капан за Deadlock: ако Worker чака резултат от UI, UI-то лесно се изкушава да чака Worker-а (или косвено чрез locks). Това се проявява по-ясно под натоварване, при бавни бази данни или в Remote-Desktop среди.

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

Следният шаблон капсулира предавания към UI в малък помощен клас. Получавате:

  • Post: fire-and-forget чрез TThread.Queue (типично за статусни обновления).
  • Call: синхронно повикване с Timeout (необичайно, но полезно в Legacy-ситуации), без директно използване на Synchronize като точка на блокиране.
  • Shutdown-Schutz: Не се приемат нови UI задачи и задачите в опашката проверяват флаг преди да достъпят контроли.

Техническо позициониране: използваме Queue плюс TEvent (един Kernel-Event) за обратна връзка. Worker-ът не чака Synchronize, а чака Event, което се задава в главната нишка след като действието в опашката бъде изпълнено. Timeout-ът предотвратява „вечния“ застой, ако по някаква причина UI-нишката спре да обработва.

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.

Цел на кода и къде той е умишлено „необичаен“

Шаблонът не заменя напълно Synchronize, но той прави синхронните предавания контролируеми: работният поток не чака механизма на Synchronize, а чака събитие. Това ви позволява да наложите таймаути, да направите видимо в експлоатация, че UI нишката е блокирана, и в една Shutdown-фаза последователно да отхвърляте нови UI задачи.

„Необичайната“ част не е самото събитие, а решението да се моделира синхронната семантика чрез Queue + Event. Това има смисъл точно когато трябва постепенно да подобрите стабилността на съществуващи приложения, без да преработвате архитектурно всяко място с Synchronize изведнъж.

Гранични условия и подводни камъни

  • Видимост в паметта: DoneEvent е синхронизационният ръб. Благодарение на това четенето на RaisedObj след WaitFor е консистентно. Въпреки това RaisedObj трябва да остане локален за всяко извикване (както тук), никога глобален.
  • Обработка на изключения: AcquireExceptionObject предотвратява „изчезването“ на изключението в основния поток. При повторно хвърляне в Worker стекът не е идентичен с оригинала, но съобщението за грешка остава в лог-а на Worker и задачата може да приключи коректно с грешка.
  • Timeout е диагноза и защита: Той не „поправя“ блокиран основен поток. Но предотвратява, че Worker държи ресурси неограничено (напр. държане на BDE-Ablosung mit nativer Anbindung-транзакции отворени), и прави класа на грешките измерим.
  • Изключването трябва да започне рано: BeginShutdown трябва да бъде част от централна последователност за изключване (напр. много рано в OnCloseQuery на главната форма). В противен случай още UI-работи ще бъдат поставени в опашка, докато прозорците вече са унищожени.

Стратегия за заключвания: как да избегнете инверсия на заключвания с UI-обратни повиквания

Много deadlock-и не възникват поради WaitFor, а заради неясен ред на заключванията. Типичен сценарий: Worker заключва „модела на данни“, извиква UI-ъпдейт чрез Synchronize, а UI-ъпдейтът отново достъпва „модела на данни“. Това е логически разбираемо, но технически фатално.

Практически правила, които могат да се наложат в екипи:

  • Не задържайте заключвания през границите на нишките: Преди Worker да постави нещо в опашката към UI/да го синхронизира, бизнес-заключванията трябва да са освободени.
  • UI чете снимки (Snapshots): UI-обратните повиквания не трябва да гледат „на живо“ в структури на Worker, а да показват копия/снимки (напр. DTO, Record, прости стойности).
  • Логването е кандидат за заключване: Ако логването използва вътрешно опашка, заключване на файл или сингълтон, то може да стане част от deadlock. UI-обратните повиквания трябва да правят минимално логване или да пишат чрез отделна, неблокираща лог-пайплайн.

Ако вече имате Layer-3-архитектура (UI, Services/домен, инфраструктура като достъп до данни): UI-обратните повиквания идеално трябва да правят само UI. Всичко, което е „Service“, не принадлежи в callback-а. Това значително намалява ефектите от реентрантност.

Изключване без задържане: „nicht WaitFor, sondern kooperatives Stoppen“

При затваряне често нещата се объркват: UI-то се затваря, един нишка трябва да приключи, но в опашката има още UI-работи. Чистото изключване е по-малко „уничожаване на нишка“, а повече малка хореография:

  1. Задаване на флаг за изключване (напр. TUiDispatcher.BeginShutdown): Оттук нататък никакви нови UI-работи.
  2. Кооперативно спиране на Worker: Worker проверява Cancel-флаг (напр. TEvent или подобно на TCancellationToken) и прекратява цикли/очаквания.
  3. Не блокирайте UI: Никакви твърди изчакващи цикли в основния поток. Ако трябва да „чакате“, правете го само с работеща Message Loop (или още по-добре: избягвайте напълно, като обработите приключването чрез callback).
  4. Последни UI-операции по почистване само ако прозорците/контролите със сигурност все още съществуват. В VCL времето е важно: най-късно когато handle-ът е изчезнал, не трябва повече да има опашкани задачи, които да оперират върху контроли.

Този процес е релевантен за експлоатация и поддръжка: „Приложението замръзва при затваряне“ е класически проблем за приемане, въпреки че функционално всичко е обработено правилно. Дефинирано изключване реално спестява време.

Отстраняване на грешки: Как да направите deadlock-а видим (без гадаене)

Когато има задържане, централният въпрос е: Кой чака кого? Няколко подхода, които са се доказали в съществуващи проекти:

  • Инвентаризирайте всички места с операции за изчакване: Пълнотекстово търсене за WaitFor, Sleep в цикли, TEvent.WaitFor, INFINITE. Много проблеми са „скрити“ операции за изчакване (включително в библиотеки).
  • Състояние на нишката в логовете: Логвайте на границите на нишките: „Job startet“, „queued UI“, „UI ausgeführt“, „Job fertig“. Така ще видите дали главната нишка изобщо обработва поставените в опашка задачи.
  • Проверете подозрения за Message Loop: Ако замръзването се проявява само при модални диалози или при определени COM-взаимодействия, често тесното място е цикълът за съобщения (Message Loop). Целта тогава е: облекчаване на UI-обработчиците, изолиране на COM-виканията, отказ от дълги операции в UI.
  • Направете заключванията видими: За TCriticalSection/TMonitor си струва debug build с метаданни за „Owner“ (напр. ID на нишката при Enter) и измерване на времето. Така ще видите кое заключване държи главната нишка, докато работещите нишки чакат за UI.

Важно е отношението: Взаимните блокировки рядко са „случайни“. Те са детерминистични цикли, които рядко се задействат. Ако еднократно идентифицирате цикъла чисто, отстраняването обикновено е ясно.

Варианти за достъп до данни и задачи за интерфейси (FireDAC, REST, Dateisystem)

Особено при FireDAC (или при други достъпи до БД) важи: връзката, транзакцията и Datasets на практика са привързани към нишката. Един Worker-Thread трябва да притежава своя DB-контекст изключително самостоятелно. UI-виканията трябва да се ограничават до представяне, а не до операции с БД. Здраво и устойчиво шаблон е:

  1. Worker изпълнява Query/REST-викане, пресмята резултата, създава DTO.
  2. Worker поства DTO чрез Queue/TUiDispatcher.Post към UI.
  3. UI поема DTO и актуализира контролите (без връщане към обекти на Worker).

Ако имате исторически смесени форми („UI тригерира DB, DB-callback тригерира UI“), има смисъл от поетапна декуплация: първо изолиране на точките на предаване (Dispatcher), след това преместване на състояния в услуги/модел. Това е по-малко рисково от голям рефактор, но значително намалява честотата на взаимните блокировки.

Финал: Избягването на взаимни блокировки означава контрол на предаванията

TThread и Synchronize без UI-deadlocks е по-малко единична техника и повече дисциплина: минимизиране на блокадите, спазване на последователност при заключванията, дефиниране на процедура за shutdown и намаляване на синхронните зависимости на UI. Представеният UI-Dispatcher е особено полезен в legacy ситуации, тъй като използва Queue по подразбиране, но за необходимите синхронни предавания добавя Timeout и ясни правила за Shutdown.

Ограничения остават: Ако главната нишка е трайно блокирана (поради тежка UI-логика, вериги от модални диалози или COM-STA-викания), дори един Dispatcher може само да диагностицира и да прекъсне контролирано. Устойчивото решение е да се облекчи UI и да се разделят отговорностите. Ако за това имате нужда от подкрепа в съществуващо Delphi-приложение – от капаните при threading до поетапна стабилизация – можете да разпределите задачата така: обсъдете проект или модернизационно намерение с Net-Base.

В предметната област Delphi Multithreading и Synchronize-Deadlock също играят важна роля, когато интеграциите, потокът от данни и бъдещото развитие трябва да работят синхронизирано.

Projekt oder Modernisierungsvorhaben mit Net-Base besprechen.

Сподели публикацията

Споделете тази публикация директно

LinkedIn, X, XING, Facebook, WhatsApp и имейл са незабавно достъпни. За Instagram ще подготвим връзка и кратък текст.

Електронна поща

Instagram се отваря в нов раздел. Връзката и краткият текст се копират предварително в клипборда.