Net-Base Rivista

15.05.2026

TThread e Synchronize senza UI-Deadlocks: pattern robusti per VCL e codice legacy

Come lavorare in modo affidabile con TThread, Synchronize e Queue senza che l’UI si blocchi: cause tipiche di deadlock, un pattern dispatcher per l’UI praticabile (incluso timeout), protezione allo shutdown, strategie di locking e controlli di debug per applicazioni Delphi consolidate.

15.05.2026

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 usando Synchronize per chiamare il UI-Thread. Entrambi aspettano – fine.
  • Lock-Inversion: il worker detiene un lock (es. TCriticalSection o TMonitor) e chiama Synchronize. 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 Synchronize in 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 Synchronize vengono 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 WaitFor nel UI-Thread non appena nel Worker esiste un percorso che utilizza Synchronize.
  • 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.Queue o 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 Synchronize come 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.

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.

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 di RaisedObj dopo WaitFor sia coerente. Tuttavia, RaisedObj dovrebbe restare locale per ogni chiamata (come qui), mai globale.
  • Gestione delle eccezioni: 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.
  • Il timeout è diagnosi e protezione: non „ripara“ un Main Thread bloccato. Previene però che i worker vincolino risorse indefinitamente (p. es. mantenendo aperte transazioni BDE-Ablosung mit nativer Anbindung), e rende la classe di errore misurabile.
  • Lo shutdown deve iniziare presto: 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:

    1. Impostare il flag di shutdown (p. es. TUiDispatcher.BeginShutdown): da questo momento nessun nuovo UI-job.
    2. Fermare i worker in modo cooperativo: il worker controlla un cancel-flag (p. es. TEvent o simile a TCancellationToken) e termina loop/attese.
    3. 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).
    4. 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, Sleep in 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/TMonitor conviene 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 è:

    1. Il worker esegue la query/chiamata REST, calcola il risultato, genera un DTO.
    2. Il worker posta il DTO via Queue/TUiDispatcher.Post alla UI.
    3. 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.

    Condividi il post

    Condividi direttamente questo articolo

    LinkedIn, X, XING, Facebook, WhatsApp e e-mail sono immediatamente disponibili. Per Instagram prepariamo direttamente il link e un breve testo.

    E-mail

    Instagram si apre in una nuova scheda. Il link e il breve testo vengono copiati prima negli appunti.