Best practice pratiche sul multithreading: edizione .NET — Cosa decidere prima di aggiungere altri thread

· · 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 di new 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) e lock(typeof(X)) sono vietati. Da .NET 9 in poi usate il tipo dedicato System.Threading.Lock.4
  • Fate transitare i dati tra thread attraverso una coda. Un assetto produttore/consumatore costruito su System.Threading.Channels o 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.Abort lancia 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

Race condition su un contatore condivisoUna 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'altraThread BVariabile condivisa countThread AThread BVariabile condivisa countThread Acount = 10Due incrementi, eppure count = 11l'addizione del Thread A è andata persaLettura (10)Lettura (10)Addizione locale (11)Addizione locale (11)Riscrittura (11)Riscrittura (11)

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

Attesa circolare di un deadlockL'attesa circolare di un deadlock. Nel momento in cui le frecce di attesa formano un anello, ogni thread in quell'anello si ferma per semprein attesa del rilascio del lock 2in attesa del rilascio del lock 1Thread Atiene il lock 1Thread Btiene il lock 2

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.For giri 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.

Rami prima di avviare un threadI 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 eccezionalePrevale l'attesa di I/Ofile, rete, DBCalcolo che usa la CPUApplicare la stessa elaborazionea ogni elemento di una collezioneUn blocco indipendente dielaborazione in backgroundMessage loop, vincolo STA, ecc.è una proprietà del thread stesso a essere richiestaC'è un'elaborazione da eseguire in paralleloCosa domina l'elaborazione?I/O asincrono con async/awaitnon si aggiungono threadChe forma ha il lavoro?Parallel.For / ForEachTask.Run / Task.WhenAllnew Thread(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
Aggregazione thread-localAggregazione 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 fusioneArray di dati (da elaborare)Thread 1elabora la propria quota eaggiunge solo al subtotale localeThread 2elabora la propria quota eaggiunge solo al subtotale localeThread 3elabora la propria quota eaggiunge solo al subtotale localeFusione: Interlocked.Add riflettenel totale una sola volta per thread

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);
}
Produttore/consumatore tramite canaleUn 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 canaleSe è pieno, fa aspettare la scrittura(backpressure)Se è vuoto, fa aspettare la letturaProduttore 1WriteAsyncCanale bounded (capacità 100)coda FIFOla sincronizzazione è gestita dal canaleProduttore 2WriteAsyncConsumatore 1ReadAllAsyncConsumatore 2ReadAllAsync

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».

Cancellazione cooperativaLa 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 coerenteChiama Cancel() una voltaPassa il TokenPassa il TokenPassa il TokenControlla IsCancellationRequestedpulisce e termina da soloThrowIfCancellationRequestedInterrompe subito anche a metà attesaIl lato che fermaCancellationTokenSourceElaborazione worker 1Elaborazione worker 2API della libreriacon supporto alla cancellazioneTerminazione normaleOperationCanceledException= trattato come cancellazione completataCancellazione 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».

Aggiornamento UI come richiestaConvertire 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 controlloRichiesta via Control.Invoke /Dispatcher.InvokeAsyncTocca un controllo direttamenteWindowsmouse, tastiera, ridisegnoCoda messaggi delthread UIThread in background(elaborazione pesante, comunicazione)Thread UIl'unico thread che può toccare i controlliVietatocausa 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.

  1. Questo lavoro è CPU-bound o I/O-bound (se è il secondo, la risposta è async/await, non un thread)?
  2. State per scrivere new Thread (si può esprimere invece con Task, Parallel o il pool di thread)?
  3. Quali dati mutabili sono condivisi tra i thread — sapete elencarli?
  4. Quella condivisione si può eliminare con la suddivisione, l’immutabilità o la consegna via coda?
  5. Per ciascun pezzo di dati condivisi che resta, c’è esattamente un lock corrispondente deciso?
  6. L’ordine di acquisizione dei lock è unico su ogni thread, e state evitando chiamate esterne mentre tenete un lock?
  7. CancellationToken è passato a ogni operazione di lunga durata, e sapete spiegare il percorso di stop?
  8. 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

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».

Riferimenti

  1. 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

  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

  3. 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

  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 this non debbano essere usati come oggetto di lock, e che da .NET 9 / C# 13 in poi si debba usare un’istanza dedicata di System.Threading.Lock; sul fatto che lo statement lock di C# garantisca Monitor.Exit in un blocco finally; sul rilevare i deadlock con un timeout di Monitor.TryEnter; sul fatto che la classe Interlocked sia 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

  5. 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

  6. 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

  7. 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

  8. 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

  9. 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

  10. 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

  11. Microsoft Learn, Data Parallelism (Task Parallel Library). Sul fatto che Parallel.For / Parallel.ForEach forniscano 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. 

  12. 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

  13. 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 lock invece 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

  14. Microsoft Learn, Async semaphores, locks, and reader/writer coordination. Sul fatto che lo statement lock di C# e il tipo Lock abbiano affinità di thread e quindi non siano usabili attraverso un await (perché il thread che esegue la continuazione può cambiare prima e dopo l’await); sull’usare un SemaphoreSlim con conteggio 1, via WaitAsync e un Release in finally, per l’esclusione reciproca nel codice asincrono; e sul fatto che un Channel bounded sia un’alternativa per scopi di throttling. 

Articoli recenti con gli stessi tag per approfondire argomenti vicini.

Queste pagine collocano l’argomento in un contesto più ampio di servizi e decisioni.

L’articolo è direttamente collegato ai servizi seguenti.

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.

Torna al blog