Който в 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-нишката спре да обработва.
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-работи. Чистото изключване е по-малко „уничожаване на нишка“, а повече малка хореография:
- Задаване на флаг за изключване (напр.
TUiDispatcher.BeginShutdown): Оттук нататък никакви нови UI-работи. - Кооперативно спиране на Worker: Worker проверява Cancel-флаг (напр.
TEventили подобно наTCancellationToken) и прекратява цикли/очаквания. - Не блокирайте UI: Никакви твърди изчакващи цикли в основния поток. Ако трябва да „чакате“, правете го само с работеща Message Loop (или още по-добре: избягвайте напълно, като обработите приключването чрез callback).
- Последни 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-виканията трябва да се ограничават до представяне, а не до операции с БД. Здраво и устойчиво шаблон е:
- Worker изпълнява Query/REST-викане, пресмята резултата, създава DTO.
- Worker поства DTO чрез
Queue/TUiDispatcher.Postкъм UI. - 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.