Chi in Delphi lavora con i thread, prima o poi arriva a TThread.Synchronize. Ed è proprio lì che si manifestano i problemi sgradevoli: blocchi sporadici, “UI non risponde”, deadlock apparentemente casuali alla chiusura o all’apertura di una finestra di dialogo. Il nucleo raramente è «Delphi è rotto», ma quasi sempre un mix sfavorevole di Synchronize, operazioni di attesa bloccanti e un UI-Thread che non elabora più correttamente la sua Message Loop (l’elaborazione degli eventi della VCL). Questo contributo mostra pattern robusti e praticabili nel contesto legacy per TThread e Synchronize senza deadlock UI – inclusa una variante con timeout, una corretta propagazione degli errori, regole di shutdown e suggerimenti per il debugging utili nelle applicazioni di produzione esistenti.
Perché i deadlock attorno a Synchronize si verificano nella pratica
Synchronize significa: un worker-thread mette una procedura in una coda che viene eseguita nel Main Thread, e attende tipicamente che tale procedura sia completata. Nelle applicazioni VCL il Main Thread coincide con l’UI-Thread (finestre, controlli, eventi). Inoltre, in molte installazioni in quel thread girano oggetti COM nel STA-Modell (Single-Threaded Apartment: le chiamate COM devono essere elaborate nello stesso thread), il che aumenta ulteriormente la dipendenza da una Message Loop funzionante.
I deadlock si verificano tipicamente a causa di una di queste configurazioni:
- WaitFor im Main Thread: il UI-Thread aspetta un worker (es.
MyThread.WaitFor), mentre il worker sta proprio usandoSynchronizeper chiamare il UI-Thread. Entrambi aspettano – fine. - Lock-Inversion: il worker detiene un lock (es.
TCriticalSectionoTMonitor) e chiamaSynchronize. La procedura sincronizzata nell’UI prova a prendere lo stesso lock (direttamente o indirettamente, spesso tramite logging/cache/singleton) – classico deadlock. - Shutdown/Destroy: alla chiusura di una form un thread viene terminato mentre ci sono ancora operazioni
Synchronizein sospeso. Particolarmente insidioso: le chiamate sincronizzate fanno riferimento a control che vengono contemporaneamente distrutti. - Message Loop blockiert: dialoghi modali, operazioni UI di lunga durata, una chiamata COM bloccante o un handler che fa „mal eben“ DB/REST mantengono il Main Thread bloccato. Le operazioni
Synchronizevengono elaborate in ritardo o non vengono elaborate affatto.
La conseguenza principale per architettura e esercizio: Synchronize è un punto di blocco. Nella software aziendale su misura con import, BDE-sostituzione con collegamento nativo-query, job di interfaccia o servizi in background con componente UI, questo punto deve essere controllato consapevolmente — altrimenti da “raro” diventerà prima o poi “sempre quando c’è fretta”.
Regola fondamentale: mai lasciare che l’UI-Thread aspetti un worker (se Synchronize è in gioco)
Se un worker usa Synchronize da qualche parte, il Main Thread non dovrebbe mai attendere in modo bloccante quel worker. Suona banale, ma nel codice legacy è una delle cause più frequenti, perché „facciamo aspettare un attimo alla chiusura“ o „il dialogo di progresso aspetta la fine“ vengono inseriti rapidamente.
Conseguenze pratiche:
- Nessuna chiamata
WaitFornel UI-Thread non appena nel Worker esiste un percorso che utilizzaSynchronize. - Segnalare il completamento del Thread tramite Event/Callback: l’interfaccia rimane responsiva e pulisce solo dopo il segnale.
- Inviare gli aggiornamenti UI fondamentalmente tramite
TThread.Queueo un dispatcher, in modo che i Worker non si blocchino.
TThread.Queue è spesso l’opzione predefinita migliore: il Worker posta lavoro al Main Thread, continua l’esecuzione e non si blocca. Questo previene molti deadlock. Tuttavia non risolve tutti i casi limite — per esempio quando in un Worker si necessita imperativamente di un risultato generato nel Main Thread (p.es. accesso a una risorsa vincolata all’UI o a una componente thread-bound).
TThread e Synchronize senza deadlock UI: modello mentale per passaggi puliti
Un modello mentale solido è: esistono poche consegne legittimamente sincrone al Main Thread. Tutto il resto è stato, rendering o telemetria — e quindi asincrono.
Una semplice classificazione aiuta nelle review e nella stabilizzazione di progetti esistenti:
- «Solo visualizzare»: progresso, riga di log, contatore, semaforo, abilitare/disabilitare – sempre
Queue. - «Trasferire stato»: il Worker fornisce un oggetto dati/DTO, l’UI effettua il rendering –
Queue, ma con copy/immutability (ossia nessuna struttura mutata congiuntamente). - «L’UI deve decidere»: solo qui serve semantica sincrona (p. es. una richiesta all’utente). La vera domanda allora è: deve davvero un Worker aspettare, oppure il workflow può essere riprogettato (State Machine, annullamento del job, ripresa successiva)?
Proprio la terza categoria è una trappola per deadlock: se il Worker attende un risultato dall’UI, l’UI viene presto tentata di aspettare il Worker (o indirettamente tramite lock). Questo fallisce molto più facilmente sotto carico, con database lenti o in ambienti Remote-Desktop.
Frammento di codice: UI-Dispatcher con Queue, timeout opzionale e shutdown pulito
Lo schema seguente incapsula i passaggi verso l’UI in una piccola classe di utilità. Fornisce:
- Post: fire-and-forget tramite
TThread.Queue(tipico per aggiornamenti di stato). - Call: chiamata sincrona con Timeout (non comune, ma utile in situazioni legacy), senza usare direttamente
Synchronizecome punto di blocco. - Protezione di shutdown: non accettare più nuovi job UI, e i job in coda verificano un flag prima di toccare i controlli.
Inquadramento tecnico: utilizziamo Queue insieme a TEvent (un Kernel-Event) per la segnalazione. Il Worker non aspetta Synchronize, ma un Event che viene impostato nel Main Thread dopo che l’azione in coda è stata eseguita. Il Timeout previene un blocco „eterno“ se per qualche motivo il UI-Thread non riesce più a processare.
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.Scopo del codice e dove è deliberatamente „inusuale“
Questo pattern non sostituisce completamente Synchronize, ma rende controllabili le chiamate sincrone: il worker non attende il meccanismo di Synchronize, ma un Event. Ciò permette di imporre timeout, di rendere visibile in esercizio che il thread UI è bloccato e, durante la fase di shutdown, di rifiutare in modo coerente nuovi job UI.
La parte „inusuale“ non è l’Event, bensì la decisione di rappresentare la semantica sincrona con Queue + Event. Questo approccio è utile esattamente quando bisogna migliorare progressivamente la stabilità in applicazioni esistenti, senza dover riprogettare immediatamente ogni singolo punto che usa Synchronize.
Vincoli e insidie
- Visibilità della memoria:
DoneEventè il punto di sincronizzazione. Questo garantisce che la lettura diRaisedObjdopoWaitForsia coerente. Tuttavia,RaisedObjdovrebbe restare locale per ogni chiamata (come qui), mai globale.
AcquireExceptionObject impedisce che l’eccezione nel Main Thread „scompaia“. Se rilanciata nel worker lo stacktrace non è identico all’origine, ma il messaggio di errore rimane nel log del worker e il job può fallire in modo pulito.BeginShutdown deve far parte di una sequenza di shutdown centrale (p. es. molto presto in OnCloseQuery della form principale). Altrimenti vengono ancora accodati UI-job mentre le finestre sono già state distrutte.Strategia di lock: come evitare inversioni di lock con callback UI
Molti deadlock non nascono da WaitFor, ma da un ordine di lock poco chiaro. Tipico flusso: il worker locka il „modello dati“, invoca un aggiornamento UI tramite Synchronize, l’aggiornamento UI a sua volta accede di nuovo al „modello dati“. Questo è comprensibile dal punto di vista logico, ma tecnicamente fatale.
Regole pratiche che possono essere adottate dai team:
- Non mantenere lock attraverso i confini dei thread: Prima che un worker accodi/sincronizzi qualsiasi cosa verso la UI, i lock applicativi dovrebbero essere rilasciati.
- La UI legge snapshot: i callback UI non dovrebbero „guardare“ dal vivo le strutture del worker, ma mostrare copie/snapshot (p. es. DTO, Record, valori semplici).
- Il logging è un candidato a lock: se il logging usa internamente una queue, un file-lock o un singleton, può diventare parte di un deadlock. I callback UI dovrebbero mantenere minimo il logging o scrivere tramite una pipeline di log separata e non bloccante.
Se avete già un’architettura Layer-3 (UI, servizi/dominio, infrastruttura come l’accesso ai dati): i callback UI idealmente devono occuparsi soltanto della UI. Tutto ciò che è „service“ non appartiene al callback. Questo riduce sensibilmente gli effetti di reentrancy.
Shutdown senza blocchi: „non WaitFor, ma stop cooperativo“
Al termine spesso succede che qualcosa vada storto: la UI si chiude, un thread dovrebbe terminare, ma ci sono ancora UI-job in coda. Uno shutdown pulito è meno „uccidere il thread“ e più una piccola coreografia:
- Impostare il flag di shutdown (p. es.
TUiDispatcher.BeginShutdown): da questo momento nessun nuovo UI-job. - Fermare i worker in modo cooperativo: il worker controlla un cancel-flag (p. es.
TEvento simile aTCancellationToken) e termina loop/attese. - Non bloccare la UI: niente cicli di attesa pesanti nel Main Thread. Se dovete „aspettare“, fatelo solo con la message loop in esecuzione (o meglio: evitarlo del tutto, trattando il completamento tramite callback).
- Ultimi lavori di pulizia UI solo se finestre/controlli esistono ancora in modo garantito. In VCL il momento è importante: al più tardi quando l’Handle non c’è più, i job in coda non devono più accedere ai controlli.
Questa sequenza è rilevante per le operazioni e il supporto: „L’applicazione si blocca alla chiusura“ è un classico problema di accettazione, anche se dal punto di vista funzionale tutto è stato processato correttamente. Uno shutdown definito risparmia tempo effettivo.
Debugging: come rendere il deadlock tangibile (senza indovinare)
Quando c’è un blocco, la domanda centrale è: chi aspetta chi? Alcuni approcci che si sono dimostrati efficaci in progetti esistenti:
- Inventariare tutti i punti di attesa: ricerca fulltext per
WaitFor,Sleepin loop,TEvent.WaitFor,INFINITE. Molti problemi sono „wait“ „nascosti“ (anche in librerie). - Stato del thread nel log: registrare nei log ai confini dei thread: „Job startet“, „queued UI“, „UI ausgeführt“, „Job fertig“. Così si vede se il thread principale elabora effettivamente i job in coda.
- Verificare il sospetto di Message-Loop: se il blocco si verifica solo con dialoghi modali o specifiche interazioni COM, la message loop è spesso il collo di bottiglia. L’obiettivo allora è: alleggerire gli handler UI, isolare le chiamate COM, evitare operazioni lunghe nell’UI.
- Rendere visibili i lock: per
TCriticalSection/TMonitorconviene una build di debug con metadati „Owner“ (p.es. ID thread all’Enter) e misurazioni temporali. In questo modo si vede quale lock tiene il thread principale mentre i worker aspettano l’UI.
Importante è l’atteggiamento: i deadlock sono raramente „casuali“. Sono cicli deterministici che si innescano di rado. Una volta identificato chiaramente il ciclo, la correzione è di solito evidente.
Varianti per accesso ai dati e job di interfaccia (FireDAC, REST, file system)
Soprattutto con FireDAC (o altri accessi DB) vale: connessione, transazione e dataset sono in pratica legati al thread. Un worker-thread dovrebbe possedere il proprio contesto DB esclusivamente. Le chiamate dall’UI dovrebbero limitarsi alla presentazione, non alle operazioni DB. Un pattern robusto è:
- Il worker esegue la query/chiamata
REST, calcola il risultato, genera un DTO. - Il worker posta il DTO via
Queue/TUiDispatcher.Postalla UI. - L’UI prende in carico il DTO e aggiorna i controlli (senza riferirsi ad oggetti del worker).
Se avete forme miste cresciute storicamente („UI innesca DB, callback DB innesca UI“), conviene una disaccoppiamento graduale: prima isolare i punti di passaggio (dispatcher), poi spostare gli stati in servizi/model. È meno rischioso di una grande ristrutturazione ma riduce i deadlock in modo percepibile.
Conclusione: evitare i deadlock significa controllare i passaggi
TThread e Synchronize senza deadlock UI è meno una singola tecnica e più una disciplina: minimizzare i blocchi, mantenere ordinati gli ordini di lock, definire lo shutdown e ridurre le dipendenze sincrone dall’UI. Il UI-Dispatcher mostrato è particolarmente utile in contesti legacy, perché usa Queue come default ma introduce per i trasferimenti sincroni necessari Timeout e regole di shutdown chiare.
Restano limiti d’uso: se il thread principale è bloccato in modo persistente (per logica UI pesante, catene di dialoghi modali o chiamate COM-STA), anche un dispatcher può solo diagnosticare e arrestare in modo controllato. La soluzione sostenibile è alleggerire l’UI e separare responsabilità. Se in una applicazione Delphi esistente avete bisogno di supporto — dalle trappole di threading fino alla stabilizzazione progressiva — potete inquadrare il progetto qui: discutere progetto o intervento di modernizzazione con Net-Base.
Nel contesto specialistico anche il Multithreading Delphi e i deadlock di Synchronize giocano un ruolo importante, quando integrazioni, flussi di dati e evoluzione devono cooperare in modo pulito.
Discutere progetto o intervento di modernizzazione con Net-Base.