Dal tema della rivista alla pratica di progetto
Pagine di servizi e tecniche correlate all'articolo
Chi in Delphi vuole parallelizzare job intensivi di calcolo o I/O, arriva rapidamente alla Parallel Programming Library (PPL) e in particolare a TParallel.For. L’effetto è spesso immediatamente misurabile – finché non si desidera aggiornare „al volo“ una Progress-UI. Proprio qui si manifestano i tipici blocchi: apparenti freeze dell’UI, una ProgressBar che salta indietro o un deadlock completo non appena si esegue passo passo nel debugger.
In questo articolo si tratta di UI di progresso thread-safe per TParallel.For: un modello robusto che funziona in VCL e FMX, raggruppa in modo ordinato gli aggiornamenti UI tramite TThread.Queue, considera Cancel/Abort e aggira in modo coerente le trappole di deadlock più comuni. Il focus è sulla realtà operativa: comportamento riproducibile, responsabilità comprensibili e indicazioni per il debugging che aiutano anche quando l’errore si manifesta solo „dal cliente“.
Perché la Progress-UI con TParallel.For fallisce così spesso
TParallel.For viene tipicamente eseguito su worker thread presi dal thread pool di Delphi. Questi thread non devono toccare direttamente controlli VCL o FMX, perché i framework UI (message loop, window handles, rendering) sono legati al Main Thread. Anche una apparentemente innocua ProgressBar.Position := … eseguita da un worker può causare comportamenti indefiniti: AV sporadiche, finestre congelate o aggiornamenti „sfarfallanti“.
La soluzione ovvia è spesso TThread.Synchronize. Questo risolve la thread-safety, ma nelle loop parallele porta rapidamente a un altro problema: crea un collo di bottiglia seriale. Ogni worker attende il UI-Thread, che a sua volta è occupato nel render e nell’elaborazione delle chiamate Synchronize. Sotto carico questo appare come un deadlock — anche se è „solo“ un effetto di starvation/lockstep.
E poi c’è la vera categoria dei deadlock: il Main Thread attende (ad es. tramite WaitFor, Task.Wait o indirettamente tramite chiamate bloccanti) la fine dell’operazione parallela, mentre i worker thread cercano di inviare lavoro al Main Thread tramite Synchronize o per un uso inopportuno di Queue. Risultato: il Main Thread aspetta i worker, i worker aspettano il Main Thread.
TThread.Queue vs. TThread.Synchronize: la differenza pratica
Entrambi i meccanismi servono a eseguire codice in modo sicuro nel Main Thread. La differenza sta nella semantica di attesa:
- TThread.Synchronize: il worker chiamante attende che il Main Thread esegua il codice. È „sincrono“, aumenta le latenze ed è un ingrediente classico per deadlock quando il Main Thread è bloccato.
- TThread.Queue: Il worker mette il codice solo in una coda per il Main Thread e continua l’elaborazione. Questo è „asincrono“, disaccoppia i thread ed è, negli scenari paralleli, quasi sempre la scelta predefinita migliore — a patto di controllare la frequenza degli aggiornamenti.
Importante: Queue non è un lasciapassare. Se inviate un update alla Queue in ogni iterazione di un ciclo, sommergerete la coda del Main Thread. In quel caso la UI non si blocca per deadlock, ma per la semplice quantità di messaggi. L’interfaccia appare „lenta“ e la conclusione dell’elaborazione si ritarda, perché centinaia o migliaia di aggiornamenti UI devono ancora essere eseguiti.
Il caso limite che fa davvero male: aspettare nel UI-Thread
Nelle applicazioni aziendali si osserva spesso il seguente flusso: si clicca il pulsante „Start“, la UI viene disattivata, appare il ProgressDialog, quindi si „attende“ sincronicamente fino al completamento per poi riattivare l’interfaccia. Questo schema è al centro di molti deadlock.
Varianti tipiche (a seconda della base di codice):
- Il Main Thread avvia TParallel.For e poi esegue una logica di attesa bloccante (direttamente o indirettamente).
- Un ProgressDialog richiama nel Constructor o in OnShow una routine che internamente attende.
- Un pulsante per annullare imposta un flag, ma il Main Thread rimane comunque bloccato in un loop di attesa.
Se i worker thread in questo intervallo usano Synchronize, il deadlock è praticamente garantito. Anche con Queue può verificarsi un blocco se il Main Thread è bloccato e non processa messaggi — perché in tal caso anche la Queue non viene svuotata.
La conseguenza operativa è: il Main Thread non deve attendere in modo bloccante la loop parallela quando sono necessari aggiornamenti UI in parallelo. L’elaborazione deve invece essere o completamente delegata a un task in background, oppure bisogna organizzare una „fine asincrona“ (callback/azione di completamento messa in coda) che rilasci la UI al termine.
Approccio pulito: progress solo aggregato, aggiornamenti UI regolati
Un modello robusto consiste in tre responsabilità chiaramente separate:
- Worker-Threads eseguono il lavoro effettivo per elemento/indice. Segnalano solo il progresso in una forma thread-safe (contatore, Queue, Thread-safe Queue).
- Aggregator (spesso: Main Thread o un timer dedicato nell’UI) calcola dallo stato di avanzamento uno stato UI (posizione, testo, ETA) e aggiorna i controlli. In questo modo si evitano aggiornamenti 1:1 per ogni iterazione.
- Completamento (anch’esso nel Main Thread): riattivare la UI, mostrare il risultato, riepilogare gli errori, liberare le risorse.
Perché questa separazione funziona così bene: il carico di lavoro può essere ad alta frequenza (migliaia di elementi), ma l’interfaccia utente necessita solo di pochi aggiornamenti al secondo. In pratica sono sufficienti 5–10 aggiornamenti/secondo, per lavori molto veloci anche 2–4. Tutto ciò che supera questa soglia è per lo più rumore visivo e consuma tempo CPU nella message pump.
Conteggio thread-safe: atomico invece di Lock
Per una semplice ProgressBar spesso è sufficiente un contatore atomico. «Atomico» significa: incremento e lettura avvengono senza race condition, tipicamente tramite TInterlocked. In questo modo evitate lock (critical sections) nel hot path del ciclo.
Idea minima collaudata:
- La quantità totale è nota in anticipo (es. numero di record, file, ID).
- Ogni iterazione incrementa in modo atomico un DoneCounter.
- Un timer dell’interfaccia legge periodicamente il contatore e imposta ProgressBar.Position.
Vantaggio: nessun TThread.Queue per elemento, niente sovraccarico dell’interfaccia. Svantaggio: non si hanno messaggi dettagliati per elemento (es. nome del file). A questo si può aggiungere un secondo messaggio di stato limitato (vedi sezione successiva).
Messaggi di stato senza spam: «l’ultimo stato vince»
Se desiderate inoltre visualizzare un breve testo (elemento corrente, fase, messaggio d’errore), serve anch’esso un modello che non inondi l’interfaccia a ogni passo del worker. In pratica «l’ultimo stato vince» funziona molto bene:
- Il worker scrive un’informazione di stato in una struttura thread-safe (es. stringa sostituibile in modo atomico, oppure protetta da un piccolo lock).
- Un timer dell’interfaccia copia periodicamente l’ultimo stato rilevato in un’etichetta.
In questo modo l’interfaccia rimane reattiva e si percepisce comunque che «qualcosa sta accadendo». Qui è meno importante la stringa in sé che la durata di vita: non trascinate riferimenti a oggetti effimeri dei worker thread nel thread UI. Se passate oggetti, definite esplicitamente la Ownership.
TParallel.For thread-safe Progress-UI con TThread.Queue: un modello robusto
Esistono scenari in cui un timer dell’interfaccia non basta da solo: per esempio quando dovete garantire a fine elaborazione un unico aggiornamento «completato», o quando l’aggiornamento dell’interfaccia è un passo più complesso (es. inserimento in una finestra di log, ma limitato). In questi casi TThread.Queue è appropriato — ma non per ogni iterazione, bensì mirato.
Un approccio pratico è una queue solo per eventi a bassa frequenza:
- Evento di avvio (preparare l’interfaccia, disabilitare i pulsanti)
- Eventi di progresso periodici (al massimo ogni X millisecondi)
- Eventi di errore (opzionalmente accumulati)
- Evento di completamento (reset dell’interfaccia, mostrare il risultato)
La periodicità non la gestite nel thread UI, ma già nel contesto worker: fate sì che i worker queueino un aggiornamento dell’interfaccia solo se dal precedente aggiornamento è trascorso sufficiente tempo. A questo scopo è adatta una sorgente di tempo monotona (es. TickCount) più un valore atomico ‚last update‘.
Importante: l’aggiornamento UI stesso deve essere «veloce». Calcoli costosi, I/O su file o accessi al database non devono essere eseguiti nel callback UI messo in coda. Il callback dovrebbe solo leggere stati e impostare i controlli.
Cancel-Handling: annullamento senza blocchi
Nelle applicazioni reali l’annullamento non è opzionale. Essenziale: il cancel non è un «kill», bensì una terminazione cooperativa. I worker devono controllare regolarmente se è stato impostato un segnale di annullamento e quindi uscire pulitamente. In Delphi ci sono diverse strade per questo (a seconda della costruzione PPL): un proprio flag Volatile, un boolean atomico, o un concetto di cancellation tramite task (a seconda della versione e struttura di Delphi).
Per il funzionamento sono importanti due regole:
- Il comando „Annulla“ deve essere visibile immediatamente: Verifichi il flag di interruzione in punti sensati, non solo alla fine di un’iterazione, quando l’iterazione può durare anche diversi secondi.
- Il comando „Annulla“ deve pulire: Handle aperti, file temporanei, transazioni o lock non devono rimanere. Cioè: in ogni iterazione del worker sono obbligatori blocchi try/finally quando sono coinvolte risorse.
Dal lato UI il comando „Annulla“ dovrebbe solo impostare un segnale e portare l’interfaccia in uno stato „Stopping…“. La terminazione effettiva e il riattivare la UI avvengono poi nell’evento Done, non immediatamente al clic.
Evitare deadlock: le insidie più comuni nella pratica
Trappola 1: WaitFor/Task.Wait nel thread principale
Se il thread principale è bloccato, non può eseguire callback dalla coda e non può processare messaggi. Questo appare come un deadlock, anche se i worker continuano a funzionare correttamente. Soluzione: evitare wait bloccanti nel thread UI. Invece eseguire l’azione di completamento tramite TThread.Queue o un controllo basato su eventi (es. un timer che verifica „pronto“).
Trappola 2: Synchronize all’interno di un lock
Un classico: il worker detiene una sezione critica, chiama poi Synchronize, e nel callback UI viene (direttamente o indirettamente) richiesta di nuovo la stessa sezione critica. Risultato: circolo di attesa. La regola è semplice: non trasferire al UI (Synchronize/Queue) mentre si tiene un lock. Se è necessario un lock, prelevi prima tutti i dati in variabili locali, esca dal lock e poi esegua il queue.
Trappola 3: il callback UI causa reentrancy
Talvolta l’aggiornamento della UI non è „innocuo“: impostare proprietà può scatenare eventi (OnChange, OnResize) che a loro volta avviano logica che accede agli stati dei worker. Non è un deadlock in senso stretto, ma porta a blocchi difficili da spiegare e a race condition. Rimedi: collocare gli aggiornamenti UI in percorsi „silenziosi“ (disattivare temporaneamente gli eventi) o usare guardie contro la reentrancy (es. un guard atomico per la fase di aggiornamento).
Trappola 4: troppi aggiornamenti in coda
Anche senza wait la UI può „inchiodarsi“ se si generano decine di migliaia di callback in coda. Sintomi: la ProgressBar continua a scorrere a lungo, la finestra reagisce lentamente, CPU del thread principale alta. Soluzione: limitare (finestra temporale), aggregare (contatore), o una vera struttura Producer/Consumer in cui può esserci al più un aggiornamento UI „pending“ (coalescing).
Quando diventa più complesso: raccogliere risultati, aggregare errori, garantire l’ordine
TParallel.For è ideale quando le iterazioni sono indipendenti. Nella software business però le iterazioni sono spesso solo „sostanzialmente“ indipendenti: leggono file, invocano API REST, scrivono righe su database. In quel caso è necessario pianificare con cura tre punti aggiuntivi:
- Raccolta dei risultati thread-safe: o un buffer locale per thread (da unire alla fine) oppure una coda/collezione thread-safe. Evitare lock nel percorso hot.
- Gestione degli errori: Exceptions provenienti dai Worker-Threads devono essere raccolte. Nella pratica è efficace: memorizzare la prima Exception e attivare il Cancel, oppure raccogliere tutte le Exceptions e mostrarle aggregate alla fine.
- Ordine: Se l’output richiede un ordine stabile (p.es. log ordinati per indice), spesso la parallelizzazione con un successivo ordinamento è più semplice che un „inserimento ordinato thread-safe“.
Per la UI ciò significa: non mostrare immediatamente ogni singolo messaggio di errore. Questo porta a un’inferno di dialoghi modali. Raccogliete gli errori (p.es. una lista di stringhe) e mostrate alla fine un riepilogo o un log esportabile.
Debugging: come rendere realmente visibile un deadlock
I deadlock nel codice parallelo sono frustranti perché nel debugger possono apparire diversi rispetto al release. Ci sono comunque alcune leve pratiche:
Usare la finestra Threads e i call stack
Se la UI è bloccata, esaminate tutti i thread: dove si trova il Main Thread? Sta aspettando? È in una message-loop? Dove sono i Worker-Threads? Se i Worker sono bloccati in Synchronize, la causa è quasi sempre „Main Thread bloccato“ o „Main Thread necessita di un lock“.
Marcare i punti di Queue/Synchronize
Inserite logging mirato nei punti di passaggio (prima della Queue, nel callback della Queue, alla fine dell’iterazione). In ambiente operativo questo è spesso più utile dei breakpoint, perché il timing è determinante. Assicuratevi che lo stesso logging sia thread-safe e non bloccante (p.es. non effettuare output di log alla UI direttamente dai Worker-Threads).
Misurare il tempo dell’ultimo update
Se la UI „si blocca“, può semplicemente dover gestire troppi aggiornamenti. Misurate quindi nel Main Thread quante volte al secondo eseguite un update della UI e quanto dura. Non appena i callback della UI richiedono più di pochi millisecondi, è necessaria una limitazione o una semplificazione.
Quando vale davvero la pena usare TParallel.For con Progress-UI?
La parallelizzazione non è un fine a sé. Conviene particolarmente quando:
- le iterazioni sono abbastanza grandi (millisecondi fino a secondi), in modo che l’overhead del threadpool venga assorbito,
- il lavoro è CPU-bound (parsing, compressione, hashing) o ha I/O ben parallelizzabile (più file, più HTTP-request con limiti),
- avete una strategia chiara per Cancel e per la gestione degli errori,
- i requisiti della UI si accontentano di un progresso aggregato.
Conviene meno quando ogni iterazione è estremamente breve (micro-operazioni) o quando tutte le iterazioni contendono lo stesso collo di bottiglia (una transazione DB seriale, un lock globale, un singolo file). In questi casi la leva più rapida è spesso: migliorare l’algoritmo, batchare, ridurre l’accesso ai dati o disaccoppiare esplicitamente il collo di bottiglia.
Checklist pratica: come mantenere stabile la UI
- Il Main Thread non deve essere bloccato: niente wait, nessun loop lungo senza message pump.
- I Worker non devono mai toccare i control: niente accessi VCL/FMX al di fuori dell’UI-Thread.
- Gli update della UI sono limitati: counter/timer o aggiornamenti coalescati della Queue invece che per ogni iterazione.
- Nessuna chiamata a Synchronize da dentro lock.
- Il Cancel è cooperativo, controllato frequentemente e pulisce correttamente.
- Le Exceptions vengono raccolte e gestite ordinate alla fine.
Conclusione: disaccoppiare con la Queue, stabilizzare con l’aggregazione
Una robusta Progress-UI con TParallel.For non nasce inserendo „irgendwo Synchronize rein“, ma seguendo un chiaro principio architetturale: i worker lavorano in modo indipendente, il Main Thread resta libero e gestisce solo pochi, rapidi aggiornamenti dell’interfaccia. TThread.Queue è lo strumento giusto quando lo usate in modo mirato e limitato. Per comportamenti ‚lenti‘ o instabili sono quasi sempre responsabili due cause: il Main Thread è da qualche parte in attesa bloccante – oppure affoga in troppi aggiornamenti accodati.
Una volta impostato correttamente il pattern (Counter/Coalescing, Cancel-Flag, callback di completamento), conviene applicarlo in molte aree di un’applicazione Delphi consolidata: import/export, controlli dei dati, job su file e API – tutto diventa più reattivo, senza che ogni aggiornamento di avanzamento vi procuri nuovi deadlock.
Se avete bisogno di supporto per stabilizzare codice parallelo, per il debugging di blocchi dell’interfaccia utente o per modernizzare in modo pulito applicazioni Delphi consolidate: contattateci.
Per questo argomento sono inoltre rilevanti la Delphi Parallel Programming Library e Tthread.queue Vs Synchronize. L’articolo inquadra questi aspetti in modo comprensibile e mostra cosa conta nella pratica quotidiana.
Discutere un progetto o un intervento di modernizzazione con Net-Base.
Passo successivo
Quando un tema diventa un progetto reale, architettura, sistemi esistenti e gestione operativa dovrebbero essere considerati insieme fin dalle fasi iniziali.
Non forniamo solo supporto per questioni isolate, ma anche quando da frammenti di codice sorgente, tematiche legacy o idee di portale deve nascere un progetto aziendale solido.
- Stato attuale, stato obiettivo e rischi tecnici vengono valutati insieme.
- REST, l'accesso ai dati, i portali e il rollout non vengono rinviati a fasi successive.
- Vede in anticipo quale percorso è economicamente e operativamente sostenibile.