Best practice pratiche sul multithreading: edizione .NET — Cosa decidere prima di aggiungere altri thread
· Go Komura · Windows, Multithreading, C#, .NET, Applicazioni aziendali, Analisi dei bug, Progettazione
«L’elaborazione era lenta, quindi abbiamo avviato dei thread per parallelizzarla, e ora i totali aggregati ogni tanto non tornano.» «Abbiamo aggiunto un’elaborazione in background, e ora l’app si blocca una volta al mese.» «Ci dicono che sotto debugger non si riproduce, ma dal cliente succede davvero.» — Ciò che rende spaventosa la programmazione multithread è che, nel momento in cui finite di scriverla, sembra corretta. I bug da race condition dipendono dal timing: passano tra le maglie dei test e mostrano la faccia solo in produzione.
Allo stesso tempo, ora che l’hardware multicore è la norma, anche nelle applicazioni aziendali ci sono situazioni in cui il multithreading non si può evitare — requisiti come «eseguire un’elaborazione pesante senza congelare l’UI» o «elaborare in parallelo più dispositivi o file». Ciò che conta è fissare i principi di progettazione prima di aggiungere thread. I bug da multithreading non si eliminano in debug: si progettano in modo che non abbiano spazio per insinuarsi.
Questo articolo è l’edizione .NET della serie pratica sul multithreading. Rivolto agli sviluppatori che costruiscono applicazioni aziendali su Windows e si trovano a dover introdurre il multithreading, enuncia principi di progettazione validi indipendentemente dal linguaggio o dall’OS, insieme agli strumenti concreti di C#/.NET, fondandosi su fonti primarie aggiornate ad agosto 2026. I principi in sé non cambiano né su Linux né in C++. Se scrivete codice nativo, consultate gli articoli compagni che traducono gli stessi principi negli strumenti di ciascun linguaggio — l’«edizione C++» e l’«edizione C» — e se scrivete in Java, l’«edizione Java».
1. Prima la conclusione
- La prima best practice è non creare i thread da soli. Appoggiatevi ad API di livello superiore — Task, il pool di thread, la classe
Parallel— invece dinew Thread, e lasciate la gestione del numero di thread al runtime.12 - La prima cosa da tagliare quando si parallelizza è lo «stato mutabile condiviso». I punti in cui più thread scrivono la stessa variabile sono l’origine delle race; prima di proteggerli con un lock, riducete la condivisione stessa suddividendo i dati, rendendoli immutabili o consegnandoli.3
- Date disciplina ai lock. Decidete, uno a uno, «quale lock protegge quali dati», e fate dell’oggetto di lock un’istanza dedicata, non visibile dall’esterno.
lock(this)elock(typeof(X))sono vietati. Da .NET 9 in poi usate il tipo dedicatoSystem.Threading.Lock.4 - Fate transitare i dati tra thread attraverso una coda. Un assetto produttore/consumatore costruito su
System.Threading.Channelso su una collezione concorrente è più semplice da progettare che spargere lock un po’ ovunque, e dà anche un confine netto.56 - Progettate prima come si ferma. La cancellazione cooperativa via
CancellationTokenè l’unica risposta corretta per l’arresto;Thread.Abortlancia un’eccezione a runtime su .NET (la linea Core).78 - L’UI appartiene in esclusiva al thread UI. Né i controlli WinForms né gli elementi WPF si possono toccare da un thread diverso da quello che li ha creati. Da un altro thread, fate la richiesta via
Control.Invoke/Dispatcher.910 - «Parallelo» non significa sempre «più veloce». Un ciclo il cui lavoro per iterazione è piccolo può finire più lento a causa dell’overhead della parallelizzazione. Misurate sempre prima di adottarlo.3
2. Perché il multithreading è difficile — race condition e deadlock
Ridotti all’osso, i problemi che il multithreading introduce sono di due tipi.4
Una race condition è un bug in cui il risultato cambia a seconda dell’ordine in cui più thread raggiungono un pezzo di codice. L’esempio classico è l’incremento di un contatore condiviso: la singola riga count++ si spezza in realtà in tre passi — «lettura → addizione → riscrittura». Se due thread eseguono questi tre passi nello stesso momento, la riscrittura di uno sovrascrive l’addizione dell’altro, e l’incremento va perso. Il risultato cambia a ogni esecuzione, e quale risultato otterrete è imprevedibile.4
sequenceDiagram
accTitle: Race condition su un contatore condiviso
accDescr: Una race condition classica in cui un incremento su un contatore condiviso viene perso. Se un altro thread si intreccia durante i tre passi di count++, la riscrittura che avviene per ultima sovrascrive l'altra
participant A as Thread A
participant M as Variabile condivisa count
participant B as Thread B
Note over M: count = 10
A->>M: Lettura (10)
B->>M: Lettura (10)
A->>A: Addizione locale (11)
B->>B: Addizione locale (11)
A->>M: Riscrittura (11)
B->>M: Riscrittura (11)
Note over M: Due incrementi, eppure count = 11<br/>l'addizione del Thread A è andata persa
Figura 1: Una race condition classica in cui un incremento su un contatore condiviso viene perso. Se un altro thread si intreccia durante i tre passi di count++, la riscrittura che avviene per ultima sovrascrive l’altra
Un deadlock è uno stato in cui due thread aspettano ciascuno un lock tenuto dall’altro, e nessuno dei due può procedere. Il thread A tiene il lock 1 e aspetta il lock 2; il thread B tiene il lock 2 e aspetta il lock 1 — questo da solo basta perché entrambi si fermino per sempre.4
flowchart LR
accTitle: Attesa circolare di un deadlock
accDescr: L'attesa circolare di un deadlock. Nel momento in cui le frecce di attesa formano un anello, ogni thread in quell'anello si ferma per sempre
A["Thread A<br/>tiene il lock 1"] -->|"in attesa del rilascio del lock 2"| B["Thread B<br/>tiene il lock 2"]
B -->|"in attesa del rilascio del lock 1"| A
Figura 2: L’attesa circolare di un deadlock. Nel momento in cui le frecce di attesa formano un anello, ogni thread in quell’anello si ferma per sempre
Ciò che rende scomodi entrambi è che dipendono dal timing. È del tutto normale che un intreccio (una particolare combinazione di ordini di esecuzione) che su una macchina di sviluppo colpisce una volta su decine di migliaia di esecuzioni accada ogni giorno sulla macchina del cliente, dove sia il numero di core sia il timing sono diversi. «Non si riproduce con il debugger attaccato» e «è sparito quando ho aggiunto i log» succedono perché l’osservazione stessa cambia il timing — è il comportamento da manuale di un bug da race.
È esattamente per questo che ogni principio da qui in poi punta in una sola direzione: prima di «sincronizzare correttamente», ridurre i posti che hanno bisogno di sincronizzazione — questo è il principio di base della progettazione multithread.
3. Principio 1: non creare i thread da soli
3.1. Basarsi su Task e sul pool di thread
Creare un thread direttamente con new Thread(...) è, nel .NET di oggi, un’ultima risorsa eccezionale. Da .NET Framework 4 in poi, lo strumento raccomandato per il codice multithread e parallelo è la TPL (Task Parallel Library) — cioè la famiglia di API centrate su Task. La TPL regola dinamicamente il grado di parallelismo in funzione dei processori disponibili, e si prende in carico tutte le faccende di basso livello: suddividere il lavoro, schedularlo sul pool di thread, gestire la cancellazione e lo stato.1
Il pool di thread è un’infrastruttura che .NET stesso usa in modo esteso — per eseguire i Task, completare l’I/O asincrono, le callback dei timer e altro — e finché gli lanciate pezzi di lavoro brevi, gli sviluppatori non devono gestire il ciclo di vita dei thread.2
// Eseguire in background un calcolo pesante per la CPU
var result = await Task.Run(() => HeavyCalculation(input));
// Far girare in parallelo più elaborazioni indipendenti e aspettare tutte (quando i pezzi sono pochi)
// * Questa forma assume che ProcessAsync sia un metodo asincrono I/O-bound.
// WhenAll si limita ad «aspettare Task già in esecuzione», quindi se volete
// far girare in parallelo calcoli CPU-bound, avvolgete ciascun pezzo in Task.Run(() => Calc(x))
// per metterlo sul pool di thread
var results = await Task.WhenAll(items.Select(x => ProcessAsync(x)));
// Se gli elementi sono molti, mettete un tetto al grado di concorrenza
await Parallel.ForEachAsync(items,
new ParallelOptions { MaxDegreeOfParallelism = 8, CancellationToken = callerCt },
async (x, ct) => await ProcessAsync(x, ct));
// Qui contano due punti: collegare il token del chiamante a ParallelOptions
// (se lo dimenticate, il ct nel corpo è sempre None) e passare quello stesso ct
// anche al corpo (non scartarlo)
C’è un avvertimento. Task.WhenAll(items.Select(...)) avvia l’elaborazione di ogni elemento in un colpo solo, nel momento in cui viene enumerato. Va bene per una manciata fissa, fino a qualche decina di elementi, ma usatelo su una collezione grande e esaurirete in un colpo socket, connessioni al DB e memoria. Per un lavoro il cui volume non potete prevedere, o mettete un tetto al grado di concorrenza come con Parallel.ForEachAsync sopra, o controllate il flusso con un canale bounded, descritto più avanti.
Creare un thread proprio è giustificato quasi solo quando è una proprietà del thread stesso a essere il requisito — cose come «deve avere un proprio message loop dedicato», «deve specificare un threading apartment (STA)», o «deve continuare a girare per tutta la vita dell’app».
3.2. Per il parallelismo sui dati, usate Parallel.For / ForEach
Per il parallelismo sui dati — «applicare la stessa elaborazione a ogni elemento di una collezione per accelerare il tutto» — usate Parallel.For / Parallel.ForEach invece di spartire voi il ciclo tra i thread. La TPL si occupa di spezzare la sorgente dati (partizionamento) e di ribilanciare il carico, e per un ciclo di base non servono nemmeno i lock.11
Ci sono però due trappole che la documentazione ufficiale enuncia esplicitamente.3
- Non date per scontato che il parallelo sia sempre più veloce. Un ciclo con poche iterazioni, o il cui lavoro per iterazione è leggero, può finire più lento perché l’overhead della parallelizzazione supera il corpo del lavoro. Le prestazioni dipendono da molti fattori, quindi misurate sempre e decidete da lì.
- Non fate aspettare le iterazioni l’una con l’altra. Non c’è garanzia che ciascuna iterazione di
Parallel.Forgiri davvero in parallelo. Un codice in cui un’iterazione aspetta un evento impostato da un’altra può andare in deadlock, a seconda dello scheduling.
3.3. L’elaborazione che «aspetta» va all’I/O asincrono, non ai thread
Un’elaborazione dominata dall’attesa di I/O — file, rete, database — non è una candidata ad aggiungere thread. Tenere occupato un intero thread mentre aspetta è solo spreco; l’I/O asincrono via async/await non consuma thread mentre aspetta. Questa distinzione — parallelizzare il lavoro CPU-bound, rendere asincrono quello I/O-bound — è la prima linea da tracciare all’ingresso della progettazione multithread.
flowchart TB
accTitle: Rami prima di avviare un thread
accDescr: I rami da percorrere prima di «avviare un thread». La maggior parte delle elaborazioni aziendali cade in una delle tre uscite superiori, e arrivare a new Thread è il caso eccezionale
S["C'è un'elaborazione da eseguire in parallelo"] --> Q1{"Cosa domina l'elaborazione?"}
Q1 -->|"Prevale l'attesa di I/O<br/>file, rete, DB"| ASYNC["I/O asincrono con async/await<br/>non si aggiungono thread"]
Q1 -->|"Calcolo che usa la CPU"| Q2{"Che forma ha il lavoro?"}
Q2 -->|"Applicare la stessa elaborazione<br/>a ogni elemento di una collezione"| PAR["Parallel.For / ForEach"]
Q2 -->|"Un blocco indipendente di<br/>elaborazione in background"| TASK["Task.Run / Task.WhenAll"]
Q2 -->|"Message loop, vincolo STA, ecc.<br/>è una proprietà del thread stesso a essere richiesta"| TH["new Thread<br/>(ultima risorsa eccezionale)"]
Figura 3: I rami da percorrere prima di «avviare un thread». La maggior parte delle elaborazioni aziendali cade in una delle tre uscite superiori, e arrivare a new Thread è il caso eccezionale
Le decisioni pratiche su async/await sono trattate in «Una tabella decisionale pratica per C# async / await - Task.Run e ConfigureAwait», e come pool di thread e I/O asincrono si collegano sotto il cofano è trattato in dettaglio in «Le profondità dell’I/O di Windows (parte 3) — I/O Completion Ports (IOCP) e il pool di thread .NET: il piano interrato di async/await».
4. Principio 2: minimizzare lo stato mutabile condiviso
Una race nasce solo quando sono presenti insieme «più thread» e «dati mutabili condivisi». Il numero di thread è dettato dai requisiti, quindi ciò che il progetto può tagliare è la condivisione. I mezzi sono tre.
4.1. Suddividere — far toccare a ogni thread solo i propri dati
L’approccio più semplice e più potente è spartire i dati per thread. Per un’aggregazione in un ciclo parallelo, invece di scrivere a ogni iterazione su una variabile di totale condivisa, usate l’overload di Parallel.For che accetta stato thread-local, così ogni thread costruisce il proprio subtotale in locale e li fondete una sola volta alla fine. Le scritture sullo stato condiviso scendono da «ogni iterazione» a «una volta per thread», e sia il costo di sincronizzazione sia la finestra per le race si riducono di ordini di grandezza.3
long total = 0;
Parallel.For(0, items.Length,
() => 0L, // Valore iniziale thread-local
(i, state, local) => local + Weigh(items[i]), // Ogni iterazione aggiunge solo al proprio local
local => Interlocked.Add(ref total, local)); // La fusione avviene una volta per thread
flowchart TB
accTitle: Aggregazione thread-local
accDescr: Aggregazione thread-local. Durante l'elaborazione ogni thread tocca solo i propri dati, quindi non c'è spazio per una race, e le scritture sullo stato condiviso avvengono una sola volta per thread, al momento della fusione
SRC["Array di dati (da elaborare)"] --> T1["Thread 1<br/>elabora la propria quota e<br/>aggiunge solo al subtotale locale"]
SRC --> T2["Thread 2<br/>elabora la propria quota e<br/>aggiunge solo al subtotale locale"]
SRC --> T3["Thread 3<br/>elabora la propria quota e<br/>aggiunge solo al subtotale locale"]
T1 --> M["Fusione: Interlocked.Add riflette<br/>nel totale una sola volta per thread"]
T2 --> M
T3 --> M
Figura 4: Aggregazione thread-local. Durante l’elaborazione ogni thread tocca solo i propri dati, quindi non c’è spazio per una race, e le scritture sullo stato condiviso avvengono una sola volta per thread, al momento della fusione
4.2. Renderlo immutabile — ciò che non riscrivete, potete condividerlo liberamente
I dati che vengono solo letti sono sicuri da leggere contemporaneamente da un numero qualsiasi di thread. Valori di configurazione, dati anagrafici, input di calcolo e simili si possono condividere liberamente senza sincronizzazione se li rendete immutabili — mai riscritti dopo la costruzione. In C#, i tipi record e le proprietà init sostengono questo progetto. Decidere semplicemente che «quando serve un cambiamento, si costruisce una nuova istanza e la si sostituisce, invece di riscrivere quella esistente» toglie un altro pezzo di stato mutabile da proteggere.
Ma «sembra in sola lettura» e «è immutabile» sono cose diverse. Un’interfaccia di sola lettura come IReadOnlyList<T> significa solo «non potete riscriverla attraverso quella interfaccia» — non impedisce affatto che la List<T> sottostante venga riscritta attraverso un altro riferimento. Anche la garanzia di record / init è superficiale: non protegge gli oggetti a cui una proprietà punta. Per dati che volete davvero condividere in sicurezza tra thread, o usate una collezione immutabile di System.Collections.Immutable, come ImmutableArray<T>, o passate una copia nel momento in cui condividete, tagliando del tutto il percorso di riscrittura. In quel caso, la condizione è che anche il tipo elemento T stesso debba essere immutabile. Una collezione immutabile protegge solo l’«ordine» — i riferimenti a oggetti elemento mutabili restano condivisi così come sono, quindi se il contenuto di un elemento può essere riscritto per un’altra via, la race resta. O rendete immutabile il grafo di oggetti fino alle foglie, o passate una copia profonda.
4.3. Consegnare — inviare attraverso una coda invece di condividere
Anche così, i dati tra thread vanno comunque spostati. Quando lo fate, invece di «entrambi i lati che toccano una variabile condivisa», usate un assetto produttore/consumatore in cui un lato scrive e l’altro legge, con una coda in mezzo.
La prima scelta su .NET è System.Threading.Channels. È una FIFO in cui il produttore scrive i dati in asincrono e il consumatore li legge in asincrono, e il canale stesso gestisce tutte le faccende di sincronizzazione.5
var channel = Channel.CreateBounded<WorkItem>(100); // Capacità 100: applica backpressure
// Lato produttore
await channel.Writer.WriteAsync(item, ct); // Se è pieno, aspetta che si liberi spazio
// …quando ogni produttore ha finito di scrivere:
channel.Writer.Complete(); // Dichiara «non ne arrivano più». Senza questo il ciclo del lettore non può mai finire
// Lato consumatore
await foreach (var item in channel.Reader.ReadAllAsync(ct))
{
Process(item);
}
flowchart LR
accTitle: Produttore/consumatore tramite canale
accDescr: Un assetto produttore/consumatore costruito intorno a un canale. Nessuno dei due lati tocca direttamente una variabile condivisa; sia l'attesa sia il controllo della capacità sono lasciati al canale
P1["Produttore 1<br/>WriteAsync"] --> CH["Canale bounded (capacità 100)<br/>coda FIFO<br/>la sincronizzazione è gestita dal canale"]
P2["Produttore 2<br/>WriteAsync"] --> CH
CH --> C1["Consumatore 1<br/>ReadAllAsync"]
CH --> C2["Consumatore 2<br/>ReadAllAsync"]
CH -.->|"Se è pieno, fa aspettare la scrittura<br/>(backpressure)"| P1
CH -.->|"Se è vuoto, fa aspettare la lettura"| C1
Figura 5: Un assetto produttore/consumatore costruito intorno a un canale. Nessuno dei due lati tocca direttamente una variabile condivisa; sia l’attesa sia il controllo della capacità sono lasciati al canale
Ciò che in pratica conta è scegliere un canale con capacità limitata (bounded). Il comportamento di default al raggiungimento del limite è «lo scrittore aspetta che si liberi spazio», e questo diventa una backpressure (contropressione) naturale. Usate una coda illimitata in un assetto in cui la produzione supera il consumo, e ottenete una bomba a orologeria che continua a girare mentre la memoria cresce.5
Nel mondo sincrono, il ruolo che un canale bounded svolge è riempito da BlockingCollection<T> con una capacità specificata. Combina blocco e controllo della capacità: il limite di capacità impedisce al produttore di distaccarsi troppo dal consumatore, e quando è vuota si blocca e fa aspettare il consumatore.12 ConcurrentQueue<T> / ConcurrentStack<T>, d’altra parte, sono collezioni veloci che ottengono la thread-safety usando solo operazioni Interlocked anziché lock6, ma sono code thread-safe nude, senza limite di capacità né meccanismo «aspetta quando è vuota». Consideratele un componente, non il protagonista del progetto di consegna. Notate anche che BlockingCollection<T> non è stata progettata pensando all’accesso asincrono, quindi se la accoppiate con async/await scegliete invece Channel<T>.12
Un avvertimento: diffidate dell’assunto che «passare un dizionario a ConcurrentDictionary lo rende thread-safe». Anche quando le singole operazioni sono thread-safe, le operazioni composite come «controlla se esiste, poi aggiungi» fanno ancora race (usate un metodo pensato per le operazioni composite, come GetOrAdd). E GetOrAdd stesso ha un avvertimento suo: mentre il valore che finisce memorizzato è garantito essere uno solo, la factory che costruisce il valore può essere chiamata più di una volta sotto contesa. Mettete un effetto collaterale nella factory — aprire una connessione, creare un file, e così via — e l’esecuzione duplicata lo fa trapelare, quindi o rendete la factory priva di effetti collaterali, o, per un’inizializzazione che deve avvenire esattamente una volta, memorizzate invece un Lazy<T> come valore. Cambiare il tipo della collezione non sostituisce la riduzione dello stato mutabile condiviso.
5. Principio 3: dare disciplina ai lock
Anche dopo aver ridotto lo stato mutabile condiviso, spesso non si arriva a zero. Usate l’esclusione (il lock) per ciò che resta condiviso, ma un lock non è uno strumento per «avvolgere lock intorno a tutto ciò che sembra sospetto, tanto per». I punti di disciplina sono quattro.
5.1. Decidete «cosa state proteggendo», e lockate con un oggetto dedicato
Pensate l’unità di lock come «dati», non come «un tratto di codice». Assegnate un oggetto di lock a ciascun insieme di dati mutabili che volete proteggere, e prendete quello stesso lock in ogni punto che tocca quei dati — un bug da race, in pratica, è ciò che ottenete quando questa tabella di corrispondenza si è rotta.
Fate dell’oggetto di lock un’istanza dedicata, non esposta all’esterno. lock(this) condivide il lock con qualsiasi codice esterno che possa referenziare la vostra istanza, e lock(typeof(X)) lo condivide con l’intero application domain — in entrambi i casi è terreno fertile per i deadlock. Da .NET 9 / C# 13 in poi, usare come oggetto di lock un’istanza del tipo dedicato System.Threading.Lock è la raccomandazione.4
public class OrderBook
{
private readonly Lock _gate = new(); // .NET 9+ (prima, readonly object)
private readonly List<Order> _orders = []; // I dati che _gate protegge
public void Add(Order order)
{
lock (_gate) { _orders.Add(order); }
}
}
Lo statement lock di C# garantisce che il lock venga rilasciato anche se viene lanciata un’eccezione. Come viene espanso dipende dal tipo dell’oggetto di lock: per un oggetto ordinario diventa una chiamata a Monitor.Exit in un blocco finally, e per il tipo Lock diventa una chiamata a EnterScope() e il suo dispose.413 In altre parole, un campo di tipo Lock è un meccanismo diverso da Monitor, e se solo una parte del codice scrive a mano Monitor.Enter(_gate), l’esclusione reciproca con lock (_gate) non vale. Con entrambi i tipi, è più sicuro smettere di scrivere a mano Monitor.Enter / Exit e standardizzare del tutto sulla sintassi lock.4
5.2. Non fate nulla di lento o di esterno mentre tenete un lock
Più breve è il tempo in cui tenete un lock, meglio è; l’unica cosa che dovreste fare mentre lo tenete è leggere e scrivere i dati che protegge. Scrivere codice che esegue I/O tenendo un lock, o che chiama codice esterno via evento o callback, non si limita ad allungare quanto lo tenete — apre un percorso in cui il codice chiamato tenta di prendere un lock diverso e va in deadlock. Preparare fuori dal lock, e dentro il lock fare solo la sostituzione: questa è la forma di base.
Notate che non potete fare await dentro un lock (è un errore di compilazione). È una protezione, non una mera restrizione: Monitor ha affinità di thread — il thread che ha preso il lock deve essere quello che lo rilascia — il che è incompatibile con il codice asincrono, in cui il thread in esecuzione può cambiare attraverso un await. Per l’esclusione nel codice asincrono, usate SemaphoreSlim con conteggio iniziale 1.14
private readonly SemaphoreSlim _asyncGate = new(1, 1);
public async Task SaveAsync(Data data, CancellationToken ct)
{
await _asyncGate.WaitAsync(ct);
try { await WriteToFileAsync(data, ct); }
finally { _asyncGate.Release(); }
}
5.3. Prendete sempre più lock nello stesso ordine
Quando avete due o più lock, il pattern classico di deadlock è che l’ordine di acquisizione si inverte da un thread all’altro. La correzione è semplice: fate una regola che ogni thread prenda i lock nello stesso ordine. Dove non potete garantire l’ordine, usate l’overload con timeout di Monitor.TryEnter, e se non ottenete il lock fate backoff e riprovate (o registrate l’anomalia) — questo trasforma un hang che durerebbe per sempre in un fallimento rilevabile.4
5.4. Interlocked per gli aggiornamenti semplici, ReaderWriterLockSlim quando dominano le letture
Per gli aggiornamenti atomici di una singola variabile — incrementare o decrementare un contatore, scambiare un flag — la classe Interlocked (Increment / Add / CompareExchange) è più veloce di lock. Senza contesa, può costare quanto un singolo prefisso di istruzione CPU.4 Viceversa, Interlocked arriva fin lì; non può tenere consistenti insieme più variabili. Una struttura lock-free fatta a mano combinata con volatile è uno strumento da esperti, che richiede una comprensione profonda del modello di memoria, e non è qualcosa che dovreste scrivere in un’applicazione aziendale.
Per dati condivisi in cui «le letture sono frequenti ma le scritture rare», c’è anche l’opzione di ReaderWriterLockSlim, che esclude solo le scritture lasciando passare le letture in concorrenza.13
6. Principio 4: progettare prima come si ferma
La prima domanda da fare in una review di un progetto multithread è «come si ferma». Potete scrivere codice che parte senza pensarci, ma il codice che si ferma in sicurezza non nasce se non lo progettate.
6.1. La cancellazione cooperativa (CancellationToken) è l’unica risposta corretta
Il modello di arresto di .NET è unificato intorno alla cancellazione cooperativa. Il lato che vuole fermare qualcosa crea un CancellationTokenSource e passa il suo Token a ciascuna elaborazione. Quando vuole fermare, chiama Cancel(). Il lato elaborazione osserva il token e, in un punto comodo di sua scelta, pulisce e termina — poiché è cooperazione e non forza, il lato elaborazione può finire mantenendo lo stato coerente dall’inizio alla fine.7
private CancellationTokenSource? _cts;
private Task? _worker;
public void Start()
{
if (_worker is { IsCompleted: false }) // Rifiutare un doppio Start mentre è ancora in esecuzione
throw new InvalidOperationException("Il worker è già in esecuzione.");
if (_worker is { IsFaulted: true }) // Non ricostruire sopra un fallimento precedente ingoiato
throw new InvalidOperationException("Il worker precedente è fallito.", _worker.Exception);
_cts = new CancellationTokenSource();
var token = _cts.Token; // Catturarlo prima in un locale, così non può fare race con un re-Start dopo lo stop
_worker = Task.Run(() => WorkLoop(token), token);
}
private void WorkLoop(CancellationToken ct)
{
while (!ct.IsCancellationRequested) // Osservarlo per polling
{
ProcessNextItem(ct); // Passare ct a ogni chiamata bloccante per l'interruzione immediata
}
}
public async Task StopAsync()
{
var cts = _cts; // Fissarli in locali così, anche se i campi vengono
var worker = _worker; // sostituiti mentre aspettiamo, non fermiamo il bersaglio sbagliato
if (cts is null || worker is null) return;
Exception? cancelFailure = null;
try { cts.Cancel(); } // Una callback registrata sul token può lanciare
catch (Exception ex) { cancelFailure = ex; } // Tenerla e segnalarla dopo il join
try
{
try { await worker; } // Fare sempre join indipendentemente dal successo di Cancel, e osservare ogni fallimento in volo
catch (OperationCanceledException ex) when (ex.CancellationToken == cts.Token)
{ } // Trattare come «normale» solo la cancellazione che abbiamo richiesto noi
catch (Exception ex) when (cancelFailure is not null)
{
throw new AggregateException(cancelFailure, ex); // Non perdere nessuno dei due fallimenti
}
}
finally
{
cts.Dispose(); // Disporre la source una volta fatto join (rilascia risorse OS come il suo WaitHandle).
if (ReferenceEquals(_cts, cts))
{
_cts = null; // Impedire a un StopAsync successivo di usare una source già disposta
_worker = null;
}
}
if (cancelFailure is not null)
throw new AggregateException(cancelFailure);
}
Notate che questo Start / StopAsync è una costruzione minima che assume di essere chiamata in sequenza da un singolo thread (il thread UI, per esempio). Se più thread potrebbero operare contemporaneamente sul ciclo di vita, serializzate Start / StopAsync stessi con qualcosa come SemaphoreSlim — vanificherebbe lo scopo se le operazioni di gestione del ciclo di vita facessero race tra loro, prima ancora di arrivare a proteggere il worker.
Anche in questo piccolo campione ci sono diversi accorgimenti che in pratica pagano. Primo, Start rifiuta una doppia chiamata mentre è in esecuzione. Sovrascrivere incondizionatamente _cts e _worker perderebbe il riferimento al worker precedente, lasciando un «thread randagio» che gira accanto, che non potete né fermare né raggiungere con un join. È standard che un’API di ciclo di vita (Start/Stop) imponga da sé «uno solo alla volta». Oltre a questo, altri tre punti. Primo, l’API di stop aspetta il completamento. Cancel() si limita a «richiedere» la cancellazione; nel momento in cui ritorna, il worker potrebbe essere ancora a metà di ProcessNextItem. Se ne fate uno Stop() che si limita a richiedere e poi ritorna, create una nuova race in cui il chiamante inizia la propria pulizia mentre il worker sta ancora girando. Secondo, tenete il Task invece di scartarlo. Buttatelo via con _ = Task.Run(...) e nessuno si accorgerà se il worker muore per un’eccezione. Terzo, catturate il token in una variabile locale prima di passarlo, invece di referenziare _cts.Token dentro la lambda. Referenziarlo dentro la lambda significa che viene valutato al momento dell’esecuzione, e se un re-Start avviene subito dopo lo stop ottenete uno scambio in cui il vecchio worker afferra il nuovo token. Passare quello stesso token anche come secondo argomento di Task.Run significa che, quando il lato elaborazione termina via ThrowIfCancellationRequested o via OperationCanceledException di un’API consapevole della cancellazione, il Task viene classificato come «Cancelled» e non come «Faulted» (in questo esempio, uscire normalmente attraverso la condizione del ciclo, come mostrato, conta comunque come completamento con successo). Un’altra cosa: il catch in StopAsync usa un filtro when per intercettare solo la cancellazione originata dal proprio token. Ingoiare incondizionatamente OperationCanceledException farebbe apparire come «si è fermato, quindi va bene» anche un fallimento genuino lanciato da un token diverso dentro l’elaborazione — un timeout per elemento, per dire. Notate che questa identificazione per corrispondenza di token si rompe se WorkLoop internamente usa un token collegato (la composizione per link della sezione 6.1), perché l’eccezione che esce porta il token del lato collegato. In quella configurazione, scegliete esplicitamente, come decisione di progetto, o di chiamare ct.ThrowIfCancellationRequested() all’uscita di WorkLoop per «tradurla» di nuovo nel token esterno prima di uscire, o di allentare il filtro a when (cts.IsCancellationRequested) e accettare che «una cancellazione mentre uno stop era richiesto conta come normale».
flowchart TB
accTitle: Cancellazione cooperativa
accDescr: La forma della cancellazione cooperativa. Il lato che ferma si limita a chiamare Cancel(); ogni elaborazione decide da sé «quando e come» termina. È per questo che può fermarsi mantenendo uno stato coerente
OWNER["Il lato che ferma"] -->|"Chiama Cancel() una volta"| CTS["CancellationTokenSource"]
CTS -->|"Passa il Token"| W1["Elaborazione worker 1"]
CTS -->|"Passa il Token"| W2["Elaborazione worker 2"]
CTS -->|"Passa il Token"| W3["API della libreria<br/>con supporto alla cancellazione"]
W1 -->|"Controlla IsCancellationRequested<br/>pulisce e termina da solo"| E1["Terminazione normale"]
W2 -->|"ThrowIfCancellationRequested"| E2["OperationCanceledException<br/>= trattato come cancellazione completata"]
W3 -->|"Interrompe subito anche a metà attesa"| E3["Cancellazione completata"]
Figura 6: La forma della cancellazione cooperativa. Il lato che ferma si limita a chiamare Cancel(); ogni elaborazione decide da sé «quando e come» termina. È per questo che può fermarsi mantenendo uno stato coerente
C’è una convenzione consolidata anche dal lato libreria. Un’operazione cancellabile dovrebbe fornire un metodo pubblico che accetta un CancellationToken, e dentro un ciclo di calcolo o controllare periodicamente IsCancellationRequested o chiamare ThrowIfCancellationRequested(). Quest’ultimo lancia OperationCanceledException, che Task tratta come «cancellazione che si completa» e non come «fallimento». Quando volete fermarvi sia su un token fornito dall’esterno sia su una preoccupazione interna (un timeout, per dire), componeteli con un token collegato.7
6.2. Trattate Thread.Abort come inesistente
Thread.Abort — «uccidere dall’esterno un thread che non ascolta» — su .NET Core / .NET 5 e successivi si limita a lanciare PlatformNotSupportedException; non si può più usare affatto. Lanciare un’eccezione in un thread senza sapere dove sta eseguendo in quel momento rischia di interrompere la pulizia delle risorse e di corrompere lo stato. Se dovete terminare forzatamente codice di terze parti che non risponde alla cancellazione cooperativa (o che non potete riscrivere perché risponda), la guida ufficiale è eseguirlo in un processo separato e fermarlo con Process.Kill.8
6.3. Quando aspettate, usate un wait handle, non il polling
Scrivere «aspetta in un ciclo Sleep(100) finché un flag non viene impostato» spreca sia CPU sia reattività. Ci sono primitive di sincronizzazione come ManualResetEventSlim e SemaphoreSlim per i segnali tra thread, che mettono correttamente un thread a dormire finché non viene segnalato.13 La scelta tra precisione dei timer e attese su evento su Windows è trattata in dettaglio in «Perché preferire l’attesa su evento a Sleep(1) su Windows».
7. Il caso speciale del thread UI — la legge delle app desktop Windows
Le app desktop Windows hanno un vincolo forte in più, sopra i principi generali: solo il thread che ha creato l’UI (il thread UI) può toccarla.
I controlli WinForms non sono thread-safe; operarli da più thread spinge un controllo in uno stato incoerente e causa race, deadlock e freeze. Windows richiede che un’app abbia un thread dedicato che riceve i messaggi di sistema, e creazione e manipolazione dell’UI devono essere concentrate su quel thread.9 WPF ha esattamente la stessa struttura: solo il thread UI può cambiare gli elementi UI.10
Quando volete aggiornare l’UI da un thread diverso, non toccatela direttamente — trasformate l’operazione in «una richiesta al thread UI».
flowchart LR
accTitle: Aggiornamento UI come richiesta
accDescr: Convertire un aggiornamento UI in una «richiesta». Il lavoro del thread in background si ferma al far mettere il lavoro nella coda messaggi: è sempre il thread UI stesso a toccare il controllo
OS["Windows<br/>mouse, tastiera, ridisegno"] --> Q["Coda messaggi del<br/>thread UI"]
BG["Thread in background<br/>(elaborazione pesante, comunicazione)"] -->|"Richiesta via Control.Invoke /<br/>Dispatcher.InvokeAsync"| Q
Q --> UI["Thread UI<br/>l'unico thread che può toccare i controlli"]
BG -.->|"Tocca un controllo direttamente"| NG["Vietato<br/>causa race, deadlock, freeze"]
Figura 7: Convertire un aggiornamento UI in una «richiesta». Il lavoro del thread in background si ferma al far mettere il lavoro nella coda messaggi: è sempre il thread UI stesso a toccare il controllo
| Framework | Mezzo di richiesta |
|---|---|
| WinForms | Control.Invoke (sincrono) / Control.BeginInvoke (asincrono) / da .NET 9 in poi, Control.InvokeAsync9 |
| WPF | Dispatcher.Invoke (sincrono) / Dispatcher.InvokeAsync / Dispatcher.BeginInvoke (asincrono)10 |
Di questi, le forme sincrone (Control.Invoke / Dispatcher.Invoke) vanno usate con cautela. Se il thread UI sta aspettando in modo sincrono che quel worker finisca, e il worker chiama Invoke, ottenete un deadlock in cui ciascuno aspetta l’altro (esattamente l’attesa circolare della sezione 2). Fate delle forme asincrone (BeginInvoke / InvokeAsync) il default per le notifiche e i report di avanzamento da un thread in background, e riservate le forme sincrone alle situazioni in cui potete essere certi che il thread UI non sta aspettando voi.
In pratica c’è una risposta ancora migliore. Scrivete con async/await un’elaborazione iniziata sul thread UI, e await cattura il SynchronizationContext del thread UI e riprende automaticamente la continuazione sul thread UI, il che riduce di molto i posti in cui dovete scrivere a mano Invoke. Questa però non è una proprietà incondizionata. Il codice raggiunto da una callback in background, o una continuazione dopo un ConfigureAwait(false), non torna al thread UI, quindi se toccate l’UI lungo quel percorso serve ancora un dispatch esplicito. Allinearsi alla forma «il lavoro pesante va a Task.Run o all’I/O asincrono, e riflettere il risultato sullo schermo avviene nella continuazione dopo await» è la forma di base di un’app Windows moderna. Il rapporto tra thread UI e async/await è riassunto in un solo diagramma in «WPF / WinForms asincrono e il thread dell’UI su un foglio».
Inoltre, quando entra in gioco COM — integrazione Office, componenti legacy, e così via — si aggiunge un altro strato: il modello di threading proprio di COM (STA/MTA). Incidenti come «abbiamo creato l’oggetto COM sul thread UI ma l’abbiamo chiamato da un thread diverso e si è bloccato» appartengono a questo strato, e sono spiegati in «Fondamenti STA/MTA di COM - Modelli di threading e come evitare blocchi».
8. Quando si scrive in codice nativo (C++/C)
I principi fin qui — non creare thread direttamente, ridurre lo stato mutabile condiviso, disciplina dei lock, progettare come si ferma — si applicano direttamente anche al codice nativo. Ciò che cambia è la cassetta degli attrezzi. In C++ le controparti sono RAII insieme a std::jthread / std::mutex / std::atomic; in C, _beginthreadex dell’API Win32, gli SRW lock, le condition variable e il pattern dell’evento di stop. Ciascuno è trattato, comprese le trappole specifiche del linguaggio (il distruttore di std::thread, i pericoli di TerminateThread, DllMain e il loader lock, e altro), nell’«edizione C++» e nell’«edizione C» di questa serie.
9. Verifica e debug — prepararsi nell’assunto che non si riprodurrà
Non potete aspettarvi che i bug da multithreading vengano trovati dai test. Un unit test ordinario conta come successo un’esecuzione in cui la race semplicemente non è accaduta. Pensate la preparazione in tre strati.
La prima linea di difesa sono i principi di progettazione coperti fin qui, esattamente come sono. Tra un’app con cinque pezzi di stato mutabile condiviso e una con cinquanta, il numero di posti da sospettare differisce di un fattore dieci. In review, controllate con una tabella: «quali dati mutabili sono condivisi», «quale lock protegge ciascuno», «l’ordine di acquisizione dei lock è unico», e «dove sono i percorsi di stop». Un progetto per cui non potete scrivere questa tabella non è ancora finito, anche se sta girando.
Secondo, rendete osservabili le anomalie invece di nasconderle. Rilevate le anomalie di attesa sui lock con un timeout di Monitor.TryEnter e registratele,4 registrate invece di ingoiare le eccezioni non osservate del lavoro lanciato sul pool di thread, e siate pronti a catturare un dump completo su un hang così da poter controllare lo stack di ogni thread — la lotta contro un bug che «succede solo di tanto in tanto» si decide da quante informazioni riuscite a estrarre dall’unica volta in cui accade. L’allestimento di dump e logging è trattato in «Progettare logging e cattura dump per i crash delle app Windows».
Terzo, scuotete le cose sotto carico. Uno stress test che rende più facile colpire un intreccio sfortunato su una macchina di sviluppo — girare a lungo con più parallelismo di quanti core avete, randomizzare l’ordine di elaborazione, iniettare ritardi artificiali, e così via — è un modo realistico di far uscire le race prima della consegna. Un bug che sparisce sotto debugger spesso si riproduce sotto una build release più un carico pesante.
10. Sintesi — una checklist prima di aggiungere altri thread
Ridotta all’osso, la best practice della programmazione multithread non è «l’abilità di scrivere la sincronizzazione correttamente» ma «un progetto che vi permette di evitare di scrivere sincronizzazione del tutto». Se potete rispondere alle otto domande seguenti prima di iniziare, potete prevenire quasi ogni incidente importante.
- Questo lavoro è CPU-bound o I/O-bound (se è il secondo, la risposta è async/await, non un thread)?
- State per scrivere
new Thread(si può esprimere invece con Task,Parallelo il pool di thread)? - Quali dati mutabili sono condivisi tra i thread — sapete elencarli?
- Quella condivisione si può eliminare con la suddivisione, l’immutabilità o la consegna via coda?
- Per ciascun pezzo di dati condivisi che resta, c’è esattamente un lock corrispondente deciso?
- L’ordine di acquisizione dei lock è unico su ogni thread, e state evitando chiamate esterne mentre tenete un lock?
CancellationTokenè passato a ogni operazione di lunga durata, e sapete spiegare il percorso di stop?- Il codice che tocca l’UI è concentrato sul thread UI?
I bug da multithreading non si mostrano il giorno in cui scrivete il codice — mostrano i denti dal cliente, molto dopo che ve ne siete dimenticati. Detto all’inverso, se percorrete questa checklist in fase di progetto, potete strappare il tipo di guasto più costoso — «crasha di tanto in tanto», «si blocca una volta al mese» — prima di scrivere una riga di codice.
Articoli correlati
- Best practice pratiche sul multithreading: edizione C++ — eliminare gli incidenti strutturalmente con RAII e jthread
- Best practice di multithreading in pratica: edizione C — Scrivere in sicurezza alla maniera delle API Win32
- Buone pratiche di multithreading in pratica: edizione Java — convenzioni dell’era dei thread virtuali
- Una tabella decisionale pratica per C# async / await - Task.Run e ConfigureAwait
- WPF / WinForms asincrono e il thread dell’UI su un foglio
- Le profondità dell’I/O di Windows (parte 3) — I/O Completion Ports (IOCP) e il pool di thread .NET: il piano interrato di async/await
- Fondamenti STA/MTA di COM - Modelli di threading e come evitare blocchi
- Perché preferire l’attesa su evento a Sleep(1) su Windows
- Memoria condivisa: trappole e best practice pratiche
Aree di consulenza correlate
KomuraSoft LLC si occupa di review di progettazione di applicazioni aziendali che coinvolgono il multithreading, di indagine sulle cause di guasti difficili da riprodurre come «crasha/si blocca di tanto in tanto» — analisi dump e identificazione delle race — e di consulenza tecnica sulla parallelizzazione o sull’asincronizzazione di applicazioni esistenti. Siamo lieti di entrare in gioco già da una fase come «potete controllare se questo progetto può fare race».
- Consulenza tecnica e revisione del progetto
- Analisi dei bug e delle cause
- Sviluppo di applicazioni Windows
- Contattaci
Riferimenti
-
Microsoft Learn, Task Parallel Library (TPL). Sul fatto che la TPL sia lo strumento raccomandato per il codice multithread e parallelo da .NET Framework 4 in poi; sul fatto che regoli dinamicamente il grado di parallelismo in funzione dei processori disponibili; sul fatto che si prenda in carico la suddivisione del lavoro, lo scheduling sul pool di thread, la gestione della cancellazione e dello stato; sul fatto che un ciclo il cui lavoro per iterazione è piccolo possa rallentare per l’overhead della parallelizzazione; e sul fatto che anche usando la TPL resti raccomandata una comprensione di base di lock, deadlock e race condition. ↩ ↩2
-
Microsoft Learn, The managed thread pool. Sul fatto che la classe ThreadPool fornisca un pool di worker thread gestiti dal sistema, lasciando agli sviluppatori di concentrarsi sui task dell’app invece che sulla gestione dei thread; e sul fatto che .NET usi il pool di thread in modo esteso per le operazioni TPL, il completamento dell’I/O asincrono, le callback dei timer, le attese registrate, le connessioni socket e altro. ↩ ↩2
-
Microsoft Learn, Potential Pitfalls in Data and Task Parallelism. Sul fatto che un ciclo parallelo a volte sia più lento di uno sequenziale e vada sempre misurato; sull’evitare scritture in memoria condivisa dentro un ciclo parallelo, con l’overload che accetta stato thread-local raccomandato; e sul fatto che non ci sia garanzia che ciascuna iterazione di For/ForEach giri davvero in parallelo, così che un codice che aspetta tra iterazioni possa andare in deadlock. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Managed threading best practices. Sulle definizioni di race condition (un esempio in cui l’incremento di un contatore si spezza in lettura, addizione e riscrittura, e viene sovrascritto e perso) e di deadlock; sull’usare la cancellazione cooperativa invece di Thread.Abort; sul fatto che un tipo o
thisnon debbano essere usati come oggetto di lock, e che da .NET 9 / C# 13 in poi si debba usare un’istanza dedicata diSystem.Threading.Lock; sul fatto che lo statementlockdi C# garantiscaMonitor.Exitin un bloccofinally; sul rilevare i deadlock con un timeout diMonitor.TryEnter; sul fatto che la classeInterlockedsia più veloce per i cambiamenti di stato semplici; e sulla guida di progettazione secondo cui i dati statici dovrebbero essere thread-safe per default e i dati di istanza non dovrebbero esserlo per default. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8 ↩9 ↩10 -
Microsoft Learn, System.Threading.Channels library. Sul fatto che un canale sia una FIFO per il modello produttore/consumatore che gestisce internamente la sincronizzazione; sul fatto di poter creare un canale con limite di capacità usando CreateBounded; sul fatto che il comportamento di default al raggiungimento del limite sia che lo scrittore aspetti, con altri FullMode come DropOldest anch’essi selezionabili; e sul fatto che venga applicata backpressure quando la scrittura supera la lettura. ↩ ↩2 ↩3
-
Microsoft Learn, Thread-safe collections. Sul fatto che le collezioni sotto System.Collections.Concurrent ottengano la thread-safety tramite locking a grana fine o meccanismi lock-free; e sul fatto che ConcurrentQueue e ConcurrentStack siano implementate senza lock, usando operazioni Interlocked, così da reggere aggiunte e rimozioni frequenti da più thread. ↩ ↩2
-
Microsoft Learn, Cancellation in Managed Threads. Sulla procedura di cancellazione cooperativa usando CancellationTokenSource e CancellationToken; sul fatto che la cancellazione sia cooperativa e non forzata, con l’ascoltatore che decide come fermarsi; sui tre approcci di monitoraggio — polling, registrazione di callback e wait handle; sul fatto che ThrowIfCancellationRequested lanci OperationCanceledException, che Task tratta come cancellazione che si completa; sul comporre più token con un token collegato; e sul fatto che una libreria debba fornire metodi pubblici che accettano un CancellationToken. ↩ ↩2 ↩3
-
Microsoft Learn, Using threads and threading. Sul fatto che CancellationToken sia il modo corretto di fermare un thread; sul fatto che Thread.Abort lanci PlatformNotSupportedException su .NET Core e .NET 5 in poi, con un avviso di deprecazione a compile-time (SYSLIB0006) da .NET 5 in poi; e sul fatto che terminare forzatamente codice di terze parti che non risponde alla cancellazione cooperativa richieda di eseguirlo in un processo separato e fermarlo con Process.Kill. ↩ ↩2
-
Microsoft Learn, How to handle cross-thread operations with controls. Sul fatto che l’accesso ai controlli WinForms non sia thread-safe, con operazioni da più thread che portano a uno stato incoerente, race, deadlock e freeze; sul fatto che ogni controllo debba essere creato e acceduto sullo stesso thread, con Windows che richiede un thread UI dedicato per consegnare i messaggi di sistema; e sul chiamare in sicurezza da un altro thread usando Control.Invoke, Control.InvokeAsync da .NET 9 in poi, o un BackgroundWorker. ↩ ↩2 ↩3
-
Microsoft Learn, Threading model (WPF). Sul fatto che in WPF i cambiamenti UI siano ristretti a un singolo thread, con un thread in background che registra work item sul Dispatcher del thread UI per richiederli; sul fatto che Dispatcher.Invoke sia sincrono mentre InvokeAsync e BeginInvoke siano asincroni; e sul fatto che il Dispatcher elabori il lavoro come una coda a priorità. ↩ ↩2 ↩3
-
Microsoft Learn, Data Parallelism (Task Parallel Library). Sul fatto che
Parallel.For/Parallel.ForEachforniscano parallelismo sui dati con quasi la stessa sensazione di scrivere un ciclo for; sul fatto che non serva creare thread o mettere in coda work item, e che in un ciclo di base non servano lock; e sul fatto che la TPL spezzi la sorgente dati tra più thread e ribilanci il carico se diventa irregolare. ↩ -
Microsoft Learn, BlockingCollection<T> Class. Sul fatto che BlockingCollection sia un’implementazione produttore/consumatore con blocco e limiti di capacità; sul fatto che il limite di capacità impedisca al produttore di distaccarsi troppo dal consumatore; e sul fatto che non sia progettata per l’accesso asincrono, con Channel<T> raccomandato per un produttore/consumatore asincrono. ↩ ↩2
-
Microsoft Learn, Overview of synchronization primitives. Sul fatto che Monitor fornisca esclusione reciproca attraverso un oggetto di lock e abbia affinità di thread; sul fatto che il codice C# sia atteso usare lo statement
lockinvece di Monitor direttamente; sul fatto che ReaderWriterLockSlim renda esclusive le scritture lasciando letture concorrenti; e sul fatto che SemaphoreSlim sia un semaforo leggero per l’uso all’interno di un singolo processo, mentre Semaphore è con nome e si può usare per la sincronizzazione interprocesso. ↩ ↩2 ↩3 -
Microsoft Learn, Async semaphores, locks, and reader/writer coordination. Sul fatto che lo statement
lockdi C# e il tipoLockabbiano affinità di thread e quindi non siano usabili attraverso unawait(perché il thread che esegue la continuazione può cambiare prima e dopo l’await); sull’usare unSemaphoreSlimcon conteggio 1, viaWaitAsynce unReleaseinfinally, per l’esclusione reciproca nel codice asincrono; e sul fatto che unChannelbounded sia un’alternativa per scopi di throttling. ↩
Articoli correlati
Articoli recenti con gli stessi tag per approfondire argomenti vicini.
Best practice di multithreading in pratica: edizione C — Scrivere in sicurezza alla maniera delle API Win32
L'approccio consolidato al multithreading in C con Win32 è la creazione dei thread con _beginthreadex, i lock SRW e le variabili di condi...
Buone pratiche di multithreading in pratica: edizione Java — convenzioni dell'era dei thread virtuali
In Java la pratica consolidata del multithreading è non creare mai i thread direttamente, ma basarsi su ExecutorService e thread virtuali...
Best practice pratiche sul multithreading: edizione C++ — eliminare gli incidenti strutturalmente con RAII e jthread
In C++ il multithreading è un mondo in cui una data race è comportamento indefinito. L'articolo tratta la trappola del distruttore di std...
Usare WMI/CIM da C# e PowerShell — Guida pratica a lettura hardware, monitoraggio processi e interrogazioni remote
Il modo classico per leggere il numero di serie del PC, monitorare lo spazio disco e rilevare l'avvio dei processi è WMI/CIM. L'articolo ...
Sveglie spurie — Perché le condition variable si svegliano «senza essere notificate» e come attendere correttamente su Windows
L'attesa di una condition variable può ritornare anche quando non è arrivata nessuna notifica (una sveglia spuria). L'articolo spiega, a ...
Argomenti correlati
Queste pagine collocano l’argomento in un contesto più ampio di servizi e decisioni.
Argomenti tecnici Windows
Portale su sviluppo Windows, analisi dei problemi e valorizzazione delle risorse esistenti.
Servizi collegati all’argomento
L’articolo è direttamente collegato ai servizi seguenti.
Sviluppo di applicazioni Windows
Applicazioni aziendali, integrazione di dispositivi e strumenti di comunicazione, dai requisiti allo sviluppo.
Domande frequenti
Domande che ricorrono nelle consulenze sull’argomento dell’articolo.
- Perché conviene evitare lock(this) o lock(typeof(MyClass))?
- Perché l'oggetto su cui si prende il lock è visibile anche da codice diverso dal vostro. this è l'istanza stessa, quindi qualsiasi codice esterno che possa referenziare quell'istanza può lockare lo stesso oggetto e provocare contesa o deadlock non voluti. typeof(MyClass) è ancora più pericoloso: esiste un solo oggetto Type per application domain, quindi finite per condividere il lock con codice del tutto estraneo. Usate come oggetto di lock un'istanza dedicata, mai esposta all'esterno. Da .NET 9 / C# 13 in poi la raccomandazione è usare come oggetto di lock un'istanza del tipo dedicato System.Threading.Lock.
- Quanti thread si possono creare? Qual è il numero ottimale di thread?
- La risposta moderna è «non decidete voi il numero di thread». Usate Task e la classe Parallel, e il pool di thread regola da solo il grado di parallelismo in funzione del numero di core CPU e del carico. Un progetto che ripete new Thread a mano tende a sovradimensionarsi o sottodimensionarsi sulle macchine dei clienti, che hanno un numero di core diverso. Ciò che va tenuto d'occhio non è un numero, ma il tipo di lavoro: un calcolo che satura la CPU non diventa più veloce se lo parallelizzate oltre il numero di core, e un'elaborazione dominata dall'attesa di I/O non va affatto risolta aggiungendo thread — la mossa corretta è l'I/O asincrono con async/await.
- Aggiungere volatile rende qualcosa thread-safe?
- No. Ciò che volatile garantisce è l'ordinamento — che l'accesso a quel campo non venga riordinato rispetto alle operazioni di memoria circostanti (semantica acquire/release) — non l'atomicità di un'operazione composta come «leggi, calcola, riscrivi». Per esempio, anche se più thread fanno ++ su un contatore volatile int, gli incrementi si perdono lo stesso. Usate la classe Interlocked per incrementare/decrementare un contatore o per un compare-and-swap, e usate lock quando dovete proteggere insieme più variabili. volatile merita di essere considerato quasi solo nel caso semplice di un flag di stop, in cui un thread scrive e gli altri si limitano a leggere — e anche quel flag oggi si esprime in modo standard con CancellationToken.
- Come distinguo se un bug che succede solo di tanto in tanto è dovuto al multithreading?
- I tre indizi da sospettare sono: «la stessa operazione a volte si riproduce e a volte no», «smette di riprodursi quando attaccate il debugger o aggiungete i log», e «succede solo sotto carico pesante o subito dopo l'avvio». Un bug dipendente dal timing si riconosce dal fatto che il risultato cambia a ogni esecuzione — ed è esattamente la definizione di una race condition. Per circoscriverlo, prima enumerate tutti i dati mutabili che condividete e, per ciascuno, tabulate quale lock lo protegge. Anche un solo accesso non protetto è un sospetto. In caso di hang, catturate gli stack di ogni thread e verificate se formano un ciclo in attesa dei lock l'uno dell'altro. Mettete in pausa nel debugger di Visual Studio e guardate gli «stack paralleli», oppure, in produzione, catturate un dump e analizzatelo.
Profilo dell’autore
Pagina di presentazione dell’autore dell’articolo.
Go Komura
Rappresentante di KomuraSoft LLC
Specializzato nello sviluppo di software Windows, nella consulenza tecnica e nell’analisi dei malfunzionamenti, soprattutto nei progetti con sistemi esistenti e guasti difficili da riprodurre.