Ha valaki a Delphi-ben szálakkal dolgozik, előbb-utóbb a TThread.Synchronize-nél köt ki. És pont ott történnek a kellemetlenségek: szórványos akadozások, „a felület nem reagál”, látszólag véletlenszerű deadlockok bezáráskor vagy egy párbeszédablak megnyitásakor. A probléma ritkán az, hogy „Delphi hibás”, sokkal gyakrabban egy kedvezőtlen keveréke a Synchronize-nek, blokkoló váró műveleteknek és egy UI-szálnak, amely már nem dolgozza fel tisztán a Message Loop-ját (a VCL eseményfeldolgozását). Ez a bejegyzés robosztus, legacy-környezetben gyakorlatban alkalmazható mintákat mutat be TThread és Synchronize UI-deadlockok nélkül – beleértve timeout-változatot, tiszta hibaterjesztést, leállítási szabályokat és hibakeresési jelzéseket, amelyek valódi meglévő alkalmazásoknál hasznosak.
Miért keletkeznek a gyakorlatban deadlockok a Synchronize körül
Synchronize azt jelenti: egy worker-szál egy eljárást tesz egy várólistára, amelyet a fő szál hajt végre, és tipikusan vár addig, amíg az eljárás be nem fejeződik. VCL-alkalmazásokban a fő szál egyben a UI-szál (ablakok, vezérlők, események). Ráadásul sok telepítésnél ott futnak COM-objektumok az STA-Modell szerint (Single-Threaded Apartment: a COM-hívásokat ugyanabban a szálban kell feldolgozni), ami tovább erősíti a működő Message Loop-tól való függést.
A deadlockok tipikusan az alábbi konstellációk valamelyikéből adódnak:
- WaitFor a fő szálon: a UI-szál egy workerre vár (pl.
MyThread.WaitFor), miközben a worker éppenSynchronize-on keresztül a UI-szálat igényli. Mindketten várnak – vége. - Lock-Inversion: a worker tart egy lockot (pl.
TCriticalSectionvagyTMonitor) és meghívja aSynchronize-t. A szinkronizált UI-procedúra megpróbálja megszerezni ugyanazt a lockot (közvetlenül vagy közvetve, gyakran naplózás/cache/singletonok miatt) – klasszikus deadlock. - Shutdown/Destroy: egy űrlap bezárásakor egy szál leáll, miközben még fennállnak
Synchronize-feladatok. Különösen kellemetlen, ha a szinkronizált hívások olyan vezérlőkre hivatkoznak, amelyeket éppen törölnek. - Message Loop blokkolva: modális dialógusok, hosszú futású UI-műveletek, egy blokkoló COM-hívás vagy egy handler, amely „csak gyorsan“ DB/REST műveletet végez, fogva tartja a fő szálat. A
Synchronize-feladatok késve vagy egyáltalán nem kerülnek feldolgozásra.
A legfontosabb következmény az architektúra és az üzemeltetés számára: Synchronize blokkoló él. Egyedi vállalati szoftvereknél, amelyek importokat, BDE-kiváltást natív csatlakozással-lekérdezéseket, interfész-feladatokat vagy UI-komponenst tartalmazó háttérszolgáltatásokat végeznek, ezt az élt tudatosan kell kontrollálni – különben a „ritkán“ előbb-utóbb „mindig akkor, amikor sürgős“ lesz.
Alapszabály: a UI-szál soha ne várjon egy workerre (ha Synchronize szerepel)
Ha egy worker valahol Synchronize-t használ, a fő szálnak nem szabad keményen, blokkoló módon várnia erre a workerre. Ez triviálisnak tűnik, de a legacy-kódban ez az egyik leggyakoribb ok: a „várjunk egy kicsit bezáráskor“ vagy a „folyamatjelző dialógus a befejezésre vár“ megoldásokat gyorsan beépítik.
Gyakorlati következmények:
- Keine
WaitFor-Aufrufe im UI-Thread, sobald im Worker ein Pfad existiert, derSynchronizenutzt. - Jelezze a szál befejeződését Event/Callback segítségével: a UI reagálókész marad, és csak a jelzés után takarít fel.
- UI-frissítéseket alapvetően über
TThread.Queuevagy egy Dispatcher-en keresztül postoljon, damit Worker nicht blockieren.
TThread.Queue gyakran jobb alapértelmezett opció: a Worker munkát postol a főszálra, folytatja a futást és nem blokkol. Ez sok Deadlock-ot megelőz. Nem old meg minden szélső esetet – például ha egy Workerben feltétlenül szüksége van egy eredményre, amit a főszál állít elő (pl. hozzáférés egy UI-hoz kötött erőforráshoz vagy egy threadhez kötött komponenshez).
TThread und Synchronize ohne UI-Deadlocks: Denkmodell für saubere Übergaben
Egy megbízható gondolkodásmód: csak néhány jogosult szinkron átadás létezik a főszálnak. Minden más állapot, megjelenítés vagy telemetria – és így aszinkron.
Egy egyszerű felosztás segít a review-kor és a meglévő projektek stabilizálásakor:
- „Csak megjelenítés”: folyamatjelző, naplósor, számláló, jelzőlámpa, engedélyezés/letiltás – mindig
Queue. - „Állapot átadása”: a Worker adatobjektumot/DTO-t ad, a UI renderel –
Queue, de másolással/immutabilitással (tehát nincs közösen módosított struktúra). - „A UI-nak kell döntenie”: csak itt van szükség szinkron szemantikára (pl. felhasználói kérdés). Az igazi kérdés ilyenkor: tényleg várnia kell-e egy Workernek, vagy átalakítható a workflow (állapotgép, munka megszakítása, későbbi folytatás)?
Különösen a harmadik kategória Deadlock-csapda: ha a Worker egy UI-eredményre vár, a UI könnyen csábulhat arra, hogy a Workerre várjon (vagy közvetve lockokon keresztül). Ez terhelés alatt, lassú adatbázisoknál vagy Remote-Desktop-környezetben jóval könnyebben kifordul.
Source-Schnipsel: UI-Dispatcher mit Queue, optionalem Timeout und sauberem Shutdown
A következő minta becsomagolja az UI-átadásokat egy kis segédosztályba. Kapni fogja:
- Post: fire-and-forget über
TThread.Queue(tipikus státuszfrissítésekhez). - Call: szinkron hívás Időkorláttal (szokatlan, de legacy helyzetekben hasznos), anélkül hogy közvetlenül
Synchronize-t használnánk blokkoló pontként. - Shutdown-védelem: ne fogadjon több új UI-feladatot, és a sorban álló feladatok ellenőrizzenek egy flaget, mielőtt vezérlőket módosítanak.
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.A kód célja és miért szándékosan „szokatlan”
A minta nem helyettesíti teljesen a Synchronize-t, de ellenőrizhetővé teszi a szinkron átadást: a worker nem a Synchronize-mechanikára vár, hanem egy Event-re. Így képesek lehetnek időkorlátokat érvényesíteni, üzem közben láthatóvá tenni, ha a UI-szál akadozik, és a leállási fázisban következetesen elutasítani új UI-feladatokat.
A „szokatlan” rész nem maga az Event, hanem az a döntés, hogy a szinkron szemantikát Queue + Event kombinációval modellezzük. Ez akkor éri meg, ha meglévő alkalmazásokban fokozatosan kell stabilitást bevezetni anélkül, hogy minden Synchronize-helyet azonnal architektúráilag újra kellene tervezni.
Korlátozások és buktatók
- Memória láthatósága:
DoneEventa szinkronizációs határ. Ez biztosítja, hogy aWaitForután aRaisedObjolvasása konzisztens legyen. Ennek ellenére aRaisedObj-nak hívásonként lokálisnak kell maradnia (ahogy itt is), soha ne legyen globális.
AcquireExceptionObject megakadályozza, hogy a kivétel eltűnjön a főszálon. Amikor újradobják a Workerben, a stacktrace nem lesz azonos az eredetivel, de a hibaüzenet megmarad a Worker-logban, és a feladat tisztán elbukhat.BeginShutdown része legyen egy központi leállítási szekvenciának (pl. nagyon korán a fő űrlap OnCloseQuery eseményében). Ellenkező esetben még UI-munkák kerülnek a sorba, miközben az ablakok már el lettek távolítva.Lock-stratégia: így kerülhetők el a lock-inverziók UI-callbackekkel
Sok holtpont nem a WaitFor-tól keletkezik, hanem a nem egyértelmű lock-sorrendtől. Tipikus menet: a Worker zárolja az adatmodellt, UI-frissítést hív meg Synchronize-val, a UI-frissítés ismét hozzáfér az adatmodellhez. Logikailag érthető, de technikailag végzetes.
Gyakorlati szabályok, amelyek a csapatoknál érvényesíthetők:
- Ne tartsunk lockokat szálhatárokon át: Mielőtt egy Worker bármit az UI felé sorba állítana vagy szinkronizálna, a szakmai lockokat fel kell oldani.
- Az UI snapshotokat olvasson: Az UI-callbackek ne nézzenek „élőben” a Worker-struktúrákba, hanem másolatokat/snapshotokat jelenítsenek meg (pl. DTO, Record, egyszerű értékek).
- A naplózás potenciális lock: Ha a naplózás belsőleg sort, fájlzárat vagy singletont használ, része lehet egy holtpontnak. Az UI-callbackek tartózkodjanak a kiterjedt naplózástól, vagy külön, nem blokkoló naplópipeline-ra írjanak.
Ha már van egy Layer-3-architektúrája (UI, Services/Domäne, infrastruktúra, például adatelérés): az UI-callbackek ideálisan csak UI-t végezzenek. Minden, ami „Service”, ne legyen a callbackben. Ez jelentősen csökkenti a reentrancy-hatásokat.
Leállítás akadás nélkül: „nicht WaitFor, sondern kooperatives Stoppen”
A leállításnál gyakran elromlik: az UI bezárul, egy szálnak el kell tűnnie, de még vannak sorban lévő UI-munkák. Egy tiszta leállítás nem a szálak erőszakos megszüntetése, hanem egy kis koreográfia:
- Leállítási jelző beállítása (pl.
TUiDispatcher.BeginShutdown): Innentől ne induljanak új UI-munkák. - Worker kooperatív leállítása: A Worker ellenőrzi egy lemondási jelzőt (pl.
TEventvagyTCancellationToken-szerű) és befejezi a ciklusokat/várakozásokat. - Ne blokkolja az UI-t: Ne legyen kemény várakozó ciklus a főszálban. Ha „várni kell”, akkor csak futó üzenetkezelő hurok mellett (vagy még jobb: teljesen elkerülni, és a befejezést callbackkel kezelni).
- Utolsó UI-takarítási műveletek csak akkor, ha az ablakok/vezérlők garantáltan még léteznek. VCL-ben az időzítés fontos: legkésőbb amikor a handle eltűnik, a sorba állított feladatok már nem léphetnek rá a vezérlőkre.
Ez a folyamat üzemeltetés és support szempontjából releváns: „Az alkalmazás beragad a bezárásnál” klasszikus elfogadási probléma, még ha szakmailag minden helyesen feldolgozásra került is. Egy definiált leállítás itt valódi időt takarít meg.
Hibakeresés: hogyan tehető a holtpont megfoghatóvá (rejtvényfejtés nélkül)
Ha beragad, a kulcskérdés: Ki vár kire? Néhány megközelítés, amelyek beváltak meglévő projektekben:
- Az összes várakozó helyet feltérképezni: Teljes szövegű keresés a
WaitFor,Sleepciklusokban,TEvent.WaitFor,INFINITEután. Sok probléma „rejtett” várakozásokból ered (akár könyvtárakban is). - Thread-állapot a logban: Logoljon a szálhatároknál: „Feladat elindult”, „UI sorba állítva”, „UI végrehajtva”, „Feladat befejeződött”. Így látható, dolgozza-e egyáltalán a Main Thread a queue-olt feladatokat.
- Message-Loop gyanú ellenőrzése: Ha a lefagyás csak modális párbeszédablakoknál vagy bizonyos COM-interakcióknál jelentkezik, gyakran a Message Loop a szűk keresztmetszet. A cél ilyenkor: tehermentesíteni az UI-kezelőket, izolálni a COM-hívásokat és nem végezni hosszú műveleteket az UI-ban.
- Lockok láthatóvá tétele:
TCriticalSection/TMonitoresetén érdemes egy debug build-et használni „Owner” metaadatokkal (pl. belépéskor a Thread-ID) és időméréssel. Így látható, melyik lockot tartja épp a Main Thread, miközben a worker-ek az UI-ra várnak.
Fontos hozzáállás: a deadlockok ritkán „véletlenszerűek”. Determinisztikus ciklusok eredményei, amelyek csak ritkán aktiválódnak. Ha egyszer tisztán azonosítja a ciklust, a javítás rendszerint egyértelmű.
Variánsok adat-hozzáférésre és interfész-feladatokra (FireDAC, REST, fájlrendszer)
Különösen FireDAC (vagy más DB-hozzáférések) esetén igaz: a kapcsolat, tranzakció és a datasetek gyakorlatban szálhoz kötöttek. Egy worker-szálnak kizárólag a saját DB-környezetét kell birtokolnia. Az UI-hívásokra korlátozódjon a megjelenítés, ne végezzenek DB-műveleteket. Egy robusztus minta:
- Worker végrehajt egy Query/REST-hívást, kiszámolja az eredményt, létrehozza a DTO-t.
- A worker DTO-t postol a UI felé
Queue/TUiDispatcher.Postsegítségével. - Az UI átveszi a DTO-t és frissíti a vezérlőket (Controls) — visszahivatkozás nélkül a worker-objektumokra.
Ha történelmileg kevert megoldások vannak („UI triggereli az DB-t, DB-callback triggereli az UI-t”), érdemes lépésről lépésre szétválasztani: először izolálni az átadási pontokat (Dispatcher), majd az állapotokat áthelyezni service-ekbe/modelbe. Ez kevésbé kockázatos, mint egy nagymértékű átépítés, de jelentősen csökkenti a deadlockokat.
Következtetés: a deadlockok elkerülése = átadások kontrollja
TThread és Synchronize nélkül UI-deadlockok elkerülése inkább fegyelem, mint egyetlen technika: minimalizálni a blokkolást, következetesen tartani a lock-sorrendet, definiálni a leállítást és csökkenteni a szinkron UI-függőségeket. A bemutatott UI-Dispatcher legacy helyzetekben különösen hasznos, mert alapértelmezettként a Queue-t használja, de a szükséges szinkron átadásokhoz utólag biztosít Timeout-ot és egyértelmű shutdown-szabályokat.
Az alkalmazhatóság korlátai megmaradnak: ha a Main Thread tartósan blokkolt (nehézsúlyú UI-logika, modális dialógus-láncok vagy COM-STA-hívások miatt), egy Dispatcher legfeljebb diagnosztizálni tud és kontrolláltan megszakítani. A fenntartható megoldás ilyenkor az UI tehermentesítése és a felelősségek szétválasztása. Ha ebben egy meglévő Delphi-alkalmazásban támogatásra van szüksége – a threading csapdáktól a lépésenkénti stabilizálásig – az ügyet itt sorolhatja be: Projekt vagy Modernisierungsvorhaben mit Net-Base besprechen.
A szakterületi környezetben szintén fontos szerepet játszanak a Delphi Multithreading és a Synchronize Deadlock kérdései, ha az integrációk, adatszállítások és a továbbfejlesztés tisztán kell, hogy együttműködjenek.
Projekt vagy Modernisierungsvorhaben mit Net-Base besprechen.