Best practice di multithreading in pratica: edizione C — Scrivere in sicurezza alla maniera delle API Win32
· Go Komura · Windows, Multithreading, C, Win32 API, Applicazioni aziendali, Analisi dei bug, Progettazione
«Sto scrivendo in C un processo residente per il controllo di un impianto.» «Abbiamo finito per aggiungere thread a un’applicazione C di vent’anni.» «Fermiamo i thread con TerminateThread, ma ogni tanto si blocca l’intero processo.» — Il multithreading in C è il mondo in cui il linguaggio vi aiuta di meno. Non ci sono eccezioni, né RAII, né template; la correttezza della sincronizzazione poggia interamente su quali API scegliete e su quanto siete disciplinati nel chiamarle.
Questo articolo è l’edizione C della serie pratica sul multithreading. Destinato agli sviluppatori che scrivono C sulle API Win32, prende i principi della progettazione multithread — smettere di produrre thread in massa, ridurre lo stato mutabile condiviso, tenere la disciplina dei lock e progettare come fermare prima di progettare come avviare — e li mappa sugli strumenti Win32: come creare i thread (_beginthreadex), come scegliere gli oggetti di sincronizzazione, come progettare un percorso di stop senza TerminateThread e i vincoli di DllMain, organizzati intorno a fonti primarie aggiornate ad agosto 2026. È scritto per potersi leggere da solo. Gli stessi principi, elaborati per altri linguaggi, sono anche nell’edizione .NET, nell’edizione C++ e nell’edizione Java.
1. Prima di tutto, la conclusione
- Create i thread con
_beginthreadex, non conCreateThread. Se un thread che chiama la CRT viene creato con CreateThread, la CRT può terminare il processo quando la memoria è scarsa.12 - Il lock predefinito all’interno di un processo è un lock SRW; usate una CRITICAL_SECTION solo quando serve l’acquisizione ricorsiva. Usare un Mutex per l’esclusione intra-processo è un «errore comune» che implica sempre una transizione in kernel mode.3
- Aggiornate le variabili singole con la famiglia Interlocked.
volatilenon garantisce né atomicità né ordinamento. La maggior parte delle funzioni Interlocked porta una barriera di memoria completa.4 - Attendete con una variabile di condizione (la famiglia
SleepConditionVariableCS) o con un evento più una funzione di attesa. Un ciclo di polling basato suSleepspreca sia CPU sia reattività.5 - Non usate mai
TerminateThread. È una funzione pericolosa che corrompe lock, heap e stato delle DLL, ed è bersaglio dell’avviso di analisi del codice C6258. Progettate l’arresto come shutdown cooperativo: un evento di stop piùWaitForMultipleObjects.67 - Affidate i lavori brevi al thread pool di Windows (
CreateThreadpoolWork) invece di parallelizzarli con thread vostri. Non terminate mai i thread del pool conExitThread/TerminateThread.89 - Non create thread, non sincronizzate e non attendete la fine di thread dentro DllMain. Viene chiamata mentre è tenuto il loader lock, ed è un terreno fertile per i deadlock.10
<threads.h>di C11 è usabile da VS 2022 17.8 in poi, ma<stdatomic.h>è ancora sperimentale. Per una base di codice solo Windows, l’approccio Win32 è la scelta realistica.11
2. Perché il multithreading è difficile? — Race condition e deadlock
Riducete ai minimi termini i problemi che il multithreading introduce, a prescindere dal linguaggio, e ne restano due tipi.
Una race condition è un bug in cui l’esito cambia a seconda dell’ordine in cui più thread raggiungono un pezzo di codice. L’esempio classico è un contatore condiviso: la singola espressione count++ si spezza a livello di codice macchina in tre passi — leggi, aggiungi, riscrivi. Se due thread entrano in questi tre passi nello stesso momento, l’addizione di un thread viene sovrascritta e persa quando l’altro riscrive.4 Il risultato cambia da un’esecuzione all’altra, e quale risultato otterrete è imprevedibile.
sequenceDiagram
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: count = 11 nonostante due incrementi<br/>L'incremento del thread A è andato perso
Figura 1: La race condition classica in cui un contatore condiviso perde un incremento. Se un altro thread si inserisce nei tre passi di count++, è la riscrittura più tardiva a sovrascrivere l’altra
Un deadlock è uno stato in cui due thread attendono ciascuno un lock tenuto dall’altro, e nessuno dei due può proseguire. Il thread A tiene il lock 1 e attende il lock 2; il thread B tiene il lock 2 e attende il lock 1: questo da solo basta perché entrambi si fermino per sempre.
flowchart LR
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
Entrambi dipendono dal timing: una combinazione di ordini di esecuzione che su una macchina di sviluppo compare una volta ogni decine di migliaia di esecuzioni può accadere ogni giorno su una macchina del cliente con un numero di core e un timing diversi. «Non si riproduce con il debugger attaccato» succede perché l’osservazione stessa cambia il timing: un comportamento tipico dei bug di race. Quindi ogni principio di questo articolo punta in una direzione: ridurre i posti che hanno bisogno di sincronizzazione, prima di preoccuparsi di sincronizzare correttamente.
2.1. Premesse specifiche del C — Il linguaggio non vi protegge da niente
In C, poiché il linguaggio non ha un meccanismo che imponga questi principi, vanno scritti esplicitamente come disciplina.
Primo, costruite la garanzia di rilascio nella struttura. Senza un equivalente del RAII di C++, il rilascio dei lock e la CloseHandle sugli handle vanno protetti da un pattern goto cleanup che fa passare ogni uscita della funzione da un solo punto, o da una convenzione di codice che accoppia ogni acquisizione a un rilascio. Aggiungere un return anticipato e di conseguenza far trapelare un lock è un incidente classico del C.
Secondo, trattate le data race come in C++. Una semplice lettura o scrittura di una variabile a 32 bit correttamente allineata è atomica su Windows, ma nulla oltre — variabili a 64 bit su Windows a 32 bit, operazioni composte o coerenza tra più variabili — è garantito.12 Il codice che «succede che funzioni» si rompe nel momento in cui cambiano il compilatore o il livello di ottimizzazione.
Terzo, decidete la proprietà. Una cultura che esplicita, in un commento di funzione, «quale thread scrive questo buffer, e da quando appartiene a chi» ripaga nel multithreading in C tanto quanto la scelta del primitivo di sincronizzazione.
3. Come creare i thread — _beginthreadex, e nient’altro
3.1. Perché CreateThread è la scelta sbagliata
L’API nativa di Win32 è CreateThread, ma la guida ufficiale è che ogni thread che chiama funzioni CRT (C runtime) deve essere creato con _beginthreadex. _beginthreadex inizializza i dati interni per thread di cui la CRT ha bisogno prima di avviare il thread. Se un thread creato con CreateThread chiama una funzione CRT, la CRT può terminare il processo in condizioni di poca memoria.12 Poiché printf, malloc e strtok sono tutte funzioni CRT, la regola pratica è: «i thread scritti in C usano sempre _beginthreadex».
Evitate anche _beginthread (senza ex). Ha una trappola: se il thread che crea finisce in anticipo, l’handle restituito può essere già invalido — o persino puntare a un thread diverso — mentre _beginthreadex, il cui handle si può passare in sicurezza alle API di sincronizzazione, è la scelta più sicura. Il chiamante chiude l’handle restituito da _beginthreadex con CloseHandle.13
#include <process.h>
static unsigned __stdcall WorkerMain(void* arg)
{
WorkerContext* ctx = (WorkerContext*)arg;
/* ... il ciclo di attesa delle sezioni 5 e 6 ... */
return 0;
}
HANDLE hThread = (HANDLE)_beginthreadex(
NULL, 0, WorkerMain, &ctx, 0, NULL);
if (hThread == NULL) { /* gestione del fallimento */ }
/* ...dopo la richiesta di stop... */
WaitForSingleObject(hThread, INFINITE); /* join */
CloseHandle(hThread);
3.2. I lavori brevi vanno al thread pool di Windows
Se volete «lanciare un gran numero di lavori piccoli» o vi trovate a «creare e distruggere thread di breve vita ripetutamente», usate il thread pool di Windows (l’API del thread pool disponibile da Vista in poi) invece dei vostri thread. Create un oggetto work con CreateThreadpoolWork e inviatelo con SubmitThreadpoolWork, e i thread worker del pool eseguono la callback in parallelo.8 La gestione del numero di thread è lasciata al sistema operativo, e il costo di creare e distruggere thread scompare. Questa è la risposta del C al principio «non create i vostri thread».
La disciplina per usare il pool è anch’essa documentata ufficialmente: non terminate mai un thread del pool con TerminateThread / ExitThread; ripristinate qualsiasi stato che avete cambiato dentro una callback (TLS, priorità del thread e così via) prima di tornare; e tenete in vita gli handle di attesa finché il pool ha finito di usarli.9 Un’altra nota pratica: il pool limita solo il numero di thread worker — il numero di callback non ancora eseguite accodate via SubmitThreadpoolWork può accumularsi senza limite. In una configurazione di lunga durata in cui le sottomissioni restano avanti rispetto all’elaborazione, mettete sul lato applicazione un limitatore di ingresso come un semaforo, o una coda a capacità limitata, così che il lato che sottomette attenda o venga rifiutato una volta piena (è back-pressure per impedire che il sovraccarico diventi un problema di memoria, e lo stesso principio del progetto della coda nella sezione 5).
4. Ridurre al minimo lo stato mutabile condiviso — Partizionare, sola lettura e passaggio
Una race avviene solo quando sono presenti insieme «più thread» e «dati mutabili condivisi». Prima di scegliere un primitivo di sincronizzazione (capitolo successivo), pensate se potete ridurre la condivisione in partenza. Ci sono tre famiglie di tecnica.
Partizionate. Per un’aggregazione parallela, invece di far scrivere ogni thread a un contatore condiviso, costruite un subtotale in una variabile locale per thread (o un buffer allocato per thread) e uniteli una sola volta alla fine con qualcosa come InterlockedAdd. Le scritture sullo stato condiviso scendono da «ogni iterazione» a «una volta per thread», e sia il costo di sincronizzazione sia la finestra di contesa si riducono di ordini di grandezza. La disciplina di proprietà della sezione 2.1 — «a quale thread appartiene questo buffer» — diventa il progetto di come partizionate.
Rendete in sola lettura. Configurazioni e tabelle costruite all’avvio e mai più modificate sono sicure da leggere da un numero qualsiasi di thread una volta finita l’inizializzazione. O finite tutta l’inizializzazione prima che partano i thread, oppure, se serve un’inizializzazione pigra, usate l’inizializzazione una tantum di Win32 (InitOnceExecuteOnce) e rendete esplicito nel codice il confine — «da quando diventa sola lettura».3
Passatelo. Invece di far toccare a entrambi i lati una variabile condivisa, instradate il flusso di dati tra thread attraverso una coda produttore/consumatore. In C, l’implementazione è esattamente il pattern della variabile di condizione della sezione 5 (un buffer circolare a capacità limitata più SleepConditionVariableCS), che è l’esempio ufficiale; un buffer con un limite di capacità vi dà anche back-pressure naturale — la produzione attende una volta che supera il consumo.5
5. Scelta degli oggetti di sincronizzazione e disciplina dei lock
Win32 ha molti tipi di primitivi di sincronizzazione, e sceglierne uno sbagliato vi costa sia prestazione sia correttezza. Ecco la guida ufficiale riassunta in un’unica figura.3
flowchart TB
S{"Serve sincronizzazione<br/>tra processi?"} -->|"Sì"| Q2{"A che serve?"}
Q2 -->|"Esclusione mutua"| MTX["Mutex nominato"]
Q2 -->|"Limitare il numero di accessi concorrenti"| SEM["Semaforo nominato"]
Q2 -->|"Notifica di evento"| EVT["Evento nominato"]
S -->|"No - intra-processo"| Q3{"Serve acquisizione ricorsiva<br/>dallo stesso thread?"}
Q3 -->|"Sì"| CS["CRITICAL_SECTION"]
Q3 -->|"No"| Q4{"Il codice C++ portabile<br/>è una priorità?"}
Q4 -->|"Sì"| STD["std::mutex /<br/>std::shared_mutex"]
Q4 -->|"No"| SRW["Lock SRW - la scelta predefinita"]
Figura 3: Come scegliere un primitivo di sincronizzazione Win32. Il primo ramo è «attraversa i processi?» — il punto chiave è non prendere un oggetto kernel (Mutex) quando non lo fa
| Primitivo | Ambito | Caratteristiche | Dove usarlo |
|---|---|---|---|
| Lock SRW | Intra-processo | Veloce (di solito resta interamente in user mode), della dimensione di un puntatore, non ricorsivo | Il predefinito per il codice nuovo. AcquireSRWLockShared consente anche l’accesso in lettura condivisa |
| CRITICAL_SECTION | Intra-processo | Veloce (fa spin, poi cade in un’attesa kernel), ricorsiva | Quando lo stesso thread deve acquisire in modo ricorsivo |
| Mutex | Intra-processo / tra processi | Sempre un oggetto kernel, quindi più lento | Esclusione tra processi (nominato), o combinato con WaitForMultipleObjects |
| Semaforo | Intra-processo / tra processi | Oggetto kernel | Limitare l’accesso concorrente a un pool di risorse |
| Evento | Intra-processo / tra processi | Oggetto kernel | Notificare che «è successo qualcosa» (non per proteggere dati) |
| Funzioni Interlocked | Intra-processo (anche tra processi, su memoria condivisa) | Operazioni atomiche lock-free | Contatori, flag, scambio di puntatori4 |
Una nota a piè di tabella e flowchart. Oggetti kernel come eventi, semafori e mutex funzionano benissimo per la sincronizzazione intra-processo se creati senza nome (l’evento di stop della sezione 6 è esattamente un evento senza nome). Oggetto kernel non significa solo tra processi. Né, al contrario, «senza nome» significa in senso stretto «confinato a un processo»: se lasciate ereditare l’handle a un processo figlio, o lo duplicate in un altro processo con DuplicateHandle, lo stesso oggetto kernel si può usare da più processi anche senza un nome. Il modo accurato di dirlo è che il nome è un modo rappresentativo per far riaprire lo stesso oggetto ai processi. Il ramo della Figura 3 cattura il punto della scelta come «non scegliete un oggetto kernel per un lock intra-processo» — per una notifica intra-processo (eventi) o un limite di concorrenza (semafori), un oggetto kernel senza nome resta la risposta giusta.
La famiglia Interlocked corrisponde alla classe Interlocked nell’edizione .NET e a std::atomic nell’edizione C++. InterlockedIncrement / InterlockedExchange / InterlockedCompareExchange eseguono operazioni su una singola variabile in modo indivisibile, e poiché la maggior parte di queste funzioni porta una barriera di memoria completa, ottenete anche una garanzia di ordinamento.4 «Va bene perché l’ho marcato volatile» è un equivoco: volatile non garantisce né atomicità né ordinamento (si veda le FAQ). C’è un altro prerequisito: l’allineamento. La variabile bersaglio di una funzione Interlocked deve essere allineata su un confine naturale (un confine di 4 byte per un valore a 32 bit, di 8 byte per un valore a 64 bit); se non lo è, il comportamento è imprevedibile.12 Non prendete mai come bersaglio di una funzione Interlocked un campo dentro una struct con #pragma pack, o un campo in un buffer mappato direttamente su un formato di rete. Limitate contatori e flag a variabili dichiarate in modo ordinario — quelle che il compilatore allinea per voi. C’è anche un monito specifico sullo scambio di puntatori con qualcosa come InterlockedExchangePointer: solo lo scambio stesso è indivisibile, e nessuno garantisce la vita del blocco vecchio dopo lo scambio. Se un lettore carica il puntatore vecchio proprio mentre uno scrittore lo scambia e chiama free, è un accesso a memoria liberata. Un progetto che aggiorna dati condivisi scambiando puntatori funziona solo se è accoppiato a un protocollo di reclaim — un lock, un conteggio dei riferimenti o simile (in caso di dubbio, proteggerlo con un lock SRW è il predefinito sicuro).
Per attendere qualcosa, ci sono le variabili di condizione. Createne una con InitializeConditionVariable; il consumatore dorme su SleepConditionVariableCS (accoppiata a una CRITICAL_SECTION) e il produttore lo sveglia con WakeConditionVariable: questa è esattamente la forma dell’esempio ufficiale di coda produttore/consumatore su un buffer a capacità limitata.5 La disciplina importante: allo sveglio, ricontrollate sempre la condizione (se la coda non è vuota) dentro il lock, e tornate ad attendere se è falsa. Le variabili di condizione possono avere spurious wakeup senza alcuna notifica, e nel momento in cui vi svegliate un altro consumatore può già aver preso l’elemento per primo — quindi «sono stato svegliato» non significa necessariamente «la condizione vale». Questo è lo strumento del C per costruire la stessa forma del canale nella sezione 4.3 dell’edizione .NET e della BlockingQueue nella sezione 4 dell’edizione C++. Quando accoppiate a un lock SRW, usate SleepConditionVariableSRW.
5.1. Disciplina dei lock — Tre principi che valgono qualunque primitivo scegliate
Scegliere il primitivo giusto non basta da solo: senza un uso disciplinato, non eviterete comunque le race.
- Decidete, uno a uno, quale lock protegge quali dati. Assegnate esattamente un lock (un lock SRW o una CRITICAL_SECTION) a ciascun insieme di dati mutabili che volete protetti, e prendete quello stesso lock in ogni punto che tocca quei dati. In C in particolare, ripaga esplicitarlo in un commento di header — «questa struct è protetta da
g_lockFoo». - Non fate niente di lento o esterno mentre tenete un lock. L’unica cosa lecita mentre tenete un lock è leggere o scrivere i dati che protegge. I/O su file, chiamate di rete o invocazioni di callback fatte ancora tenendo il lock allungano quanto lo tenete e rischiano che il chiamato tenti di prendere un altro lock, creando l’attesa circolare della Figura 2.
- Fissate l’ordine di acquisizione per più lock. Ovunque si prendano due o più lock, fate regola che ogni thread li prenda nello stesso ordine (una gerarchia di lock). La documentazione delle best practice delle DLL afferma esplicitamente che invertire quell’ordine (lock order inversion) produce deadlock difficili da debuggare, e che dovete definire una gerarchia e seguirla in modo coerente.10
6. Progettare come fermare — Mai TerminateThread
6.1. Cosa rompe TerminateThread
TerminateThread cancella il thread bersaglio senza lasciargli eseguire alcun codice in user mode. Le conseguenze che elenca la documentazione ufficiale sono gravi. Se il thread bersaglio teneva una critical section, non viene mai rilasciata; se era in mezzo a un’operazione sull’heap, il lock dell’heap resta tenuto (e ogni thread successivo che chiama malloc si blocca); e se stava manipolando lo stato globale di una DLL, quello stato resta corrotto. La posizione ufficiale è che è «una funzione pericolosa da usare solo nei casi più estremi», e l’analisi del codice la segnala come avviso C6258.67
Trovare TerminateThread mentre si indaga un’app che «ogni tanto si blocca del tutto» è davvero uno spettacolo comune in pratica. Se lo trovate, trattatelo come qualcosa da sistemare.
6.2. Il pattern corretto: un evento di stop più WaitForMultipleObjects
Il pattern consolidato per lo shutdown cooperativo in C è creare un singolo evento di stop a reset manuale, e far attendere a ogni thread worker il «segnale di lavoro» e il «segnale di stop» nello stesso momento. La documentazione dell’avviso C6258 stessa indica esattamente questo pattern — create un evento, fate osservare a ogni thread lo stato con WaitForSingleObject e lasciate che il thread termini da sé — come il modo corretto di terminare.7
HANDLE hStopEvent; /* CreateEvent(NULL, TRUE, FALSE, NULL): reset manuale */
HANDLE hWorkEvent; /* CreateEvent(NULL, FALSE, FALSE, NULL): reset automatico.
Torna da solo a non segnalato quando viene ricevuto
(un evento a reset manuale lascerebbe passare le attese
dopo essere stato segnalato una volta, diventando un busy
loop che gira su una coda vuota) */
static unsigned __stdcall WorkerMain(void* arg)
{
HANDLE waits[2] = { hStopEvent, hWorkEvent };
for (;;) {
DWORD r = WaitForMultipleObjects(2, waits, FALSE, INFINITE);
if (r == WAIT_FAILED) { /* es. un handle invalido. Lasciato non gestito, gira a vuoto */
LogLastError(); /* Registrate GetLastError() e uscite */
break;
}
if (r == WAIT_OBJECT_0) /* Stop richiesto */
break;
if (r == WAIT_OBJECT_0 + 1) { /* Lavoro disponibile */
/* Passate l'evento di stop anche a ProcessNextItem: se attende a lungo
internamente per un elemento, e non può osservare lo stop anche lì,
lo shutdown finisce ostaggio di quell'unico elemento */
while (ProcessNextItem(hStopEvent)) { /* Elabora un elemento dalla coda; FALSE se vuota */
/* Controllate la richiesta di stop anche durante lo svuotamento. Saltalo e
non potete fermarvi finché il lavoro continua ad accumularsi (stop starvation) */
if (WaitForSingleObject(hStopEvent, 0) == WAIT_OBJECT_0)
break;
}
}
}
Cleanup(); /* Fate da soli la vostra pulizia */
return 0; /* Terminate da soli */
}
BOOL StopWorkers(HANDLE* threads, DWORD count)
{
BOOL ok = TRUE;
if (!SetEvent(hStopEvent)) { /* Se la richiesta di stop non è passata, */
LogLastError(); /* non proseguite verso un join senza limite */
return FALSE;
}
/* Tutti hanno ora la richiesta di stop alzata insieme */
for (DWORD i = 0; i < count; i++) {
if (WaitForSingleObject(threads[i], INFINITE) == WAIT_OBJECT_0) {
CloseHandle(threads[i]); /* Chiudete solo gli handle di cui avete confermato il join */
} else {
LogLastError(); /* WAIT_FAILED: es. un handle invalido */
ok = FALSE; /* Non riferite «tutti fermati» */
}
}
return ok; /* Se FALSE, non proseguite a rilasciare le risorse condivise */
}
C’è un motivo per cui chi ferma fa join un thread alla volta con WaitForSingleObject. WaitForMultipleObjects può attendere al massimo MAXIMUM_WAIT_OBJECTS (64) handle alla volta; passategli un array più grande e l’attesa stessa fallisce con WAIT_FAILED, lasciandovi a chiudere handle credendo di aver atteso tutti quando in realtà non avevate atteso nessuno. Se tutto ciò che vi serve è attendere che tutti finiscano, un ciclo uno-alla-volta senza limite superiore è la scelta sicura.
flowchart TB
OWNER["Chi ferma"] -->|"SetEvent(hStopEvent)"| SE["Evento di stop<br/>reset manuale - visibile a tutti insieme"]
SE --> W1["Worker 1 - attende stop e lavoro<br/>insieme via WaitForMultipleObjects"]
SE --> W2["Worker 2 - attende stop e lavoro<br/>insieme via WaitForMultipleObjects"]
W1 --> C1["Pulisce e ritorna da solo"]
W2 --> C2["Pulisce e ritorna da solo"]
C1 --> J["Chi ferma attende gli handle dei thread e fa join<br/>solo ora si può dire fermato"]
C2 --> J
Figura 4: Il pattern dell’evento di stop. Usare un evento a reset manuale per il segnale di stop significa che un solo SetEvent sveglia insieme ogni worker in attesa. Ogni thread decide da sé come finisce, e lo stop si considera completo solo una volta finito il join
Ci sono tre punti chiave. Rendete l’evento di stop a reset manuale (così un solo SetEvent è visibile a ogni worker); mettete l’evento di stop per primo nell’array di attesa (così che se entrambi sono segnalati insieme, lo stop ha priorità); e chi ferma deve sempre fare join degli handle dei thread prima di chiuderli.
Due moniti sull’ambito. Primo, questo pattern «evento più svuota-tutto» è per una configurazione a un solo worker. Quante volte chiamiate SetEvent su un evento a reset automatico, può esprimere solo «c’è uno stato segnalato» (i segnali successivi coalescono), quindi con più worker se ne sveglia uno solo e finisce per elaborare in serie l’intero burst. Se più worker condividono una coda, passate il segnale di lavoro a un semaforo, incrementando il conteggio con ReleaseSemaphore(hSem, 1, NULL) ogni volta che accodate un elemento. Un’attesa riuscita sul semaforo consuma un conteggio, dando la corrispondenza corretta: si svegliano, uno alla volta, esattamente tanti worker in attesa quanti elementi accodati (questo uso è pienamente nel mandato di un semaforo, lo stesso di «limitare l’accesso concorrente a un pool di risorse» nella tabella della Figura 3). Ma quando passate a un semaforo, cambiate anche il lato consumatore così che un’attesa riuscita equivalga a elaborare esattamente un elemento dalla coda. Lasciate il ciclo svuota-tutto del campione sopra com’è, e una sola attesa — che consuma un solo permesso — svuoterà l’intera coda, sballando i conti: altri worker si svegliano su una coda vuota con i permessi avanzati, e il ReleaseSemaphore del produttore inizia a fallire per superamento del conteggio massimo. Tenere la corrispondenza «un permesso equivale a un lavoro» è la precondizione dell’approccio a semaforo. Secondo, fate passare il percorso di stop anche attraverso l’elaborazione di un singolo elemento. Se ProcessNextItem fa internamente un’attesa bloccante lunga, o passate anche lì l’evento di stop e attendete entrambi insieme, o attaccate un timeout finito. Controllare solo tra un elemento e l’altro lascia un buco in cui «lo shutdown attende per sempre perché un elemento non finisce mai». Dice esattamente la stessa cosa di StopAsync nell’edizione .NET e di jthread più join nell’edizione C++.
Un thread in attesa su I/O bloccante (una pipe, un socket, una porta seriale) non può tornare a controllare l’evento, quindi il lato I/O ha bisogno di un progetto proprio — o I/O OVERLAPPED combinato con un evento che attendete insieme, o svegliare l’I/O con CancelIoEx (per un esempio concreto di comunicazione seriale, si veda «Pitfall delle app di comunicazione seriale - Riconnessione e progettazione dei log»).
7. DllMain e il loader lock — un campo minato per gli autori di DLL
I componenti condivisi scritti in C finiscono spesso come DLL, e le DLL portano un vincolo proprio: il loader lock. Il loader del sistema operativo chiama DllMain tenendo il loader lock, quindi fare una qualsiasi delle seguenti cose al suo interno diventa una fonte di deadlock o crash.10
- Sincronizzare con altri thread (acquisire lock, attendere che un thread finisca)
- Chiamare
LoadLibrary/FreeLibrary, direttamente o indirettamente - Creare thread (pericoloso se implica sincronizzazione), o chiamare
ExitThread
«Attendere dentro DllMain che un thread worker finisca quando la DLL viene scaricata» sembra del tutto ragionevole ma è un deadlock classico: il thread che sta terminando tenta di prendere il loader lock per consegnare DLL_THREAD_DETACH, e i due lati finiscono ad attendersi a vicenda. Una DLL con thread propri dovrebbe esporre funzioni esplicite di inizializzazione e shutdown — qualcosa come MyLib_Init / MyLib_Shutdown — e fare lì l’avvio e il join dei thread. Il DllMain ideale è vicino a uno stub vuoto.10
8. L’opzione dei thread C11 — Dove siamo
Se volete scrivere C portabile che non dipende da Win32, l’opzione è <threads.h> di C11 (thrd_create / mtx_lock / cnd_wait) e <stdatomic.h>. Secondo la tabella ufficiale di conformità, il supporto di MSVC sta così: <threads.h> è supportato da Visual Studio 2022 17.8 (richiede /std:c11 e un Windows SDK corrispondente), mentre <stdatomic.h> è ancora sperimentale, allo stadio in cui richiede l’opzione /experimental:c11atomics.11
Se condividere il codice con Linux è un requisito, i thread C11 (o un wrapper pthread) hanno un valore reale, ma per una base di codice solo Windows l’approccio Win32 di questo articolo ha il vantaggio nel volume di informazioni disponibili, nel track record e nella facilità di debug. Qualunque cosa scegliate, i principi di progetto coperti finora — ridurre la condivisione, la corrispondenza tra lock e dati, e lo shutdown cooperativo — non cambiano.
9. Verifica e debug — Prepararsi a «non si riproduce»
Non potete aspettarvi che i test catturino i bug di race. I test ordinari contano come successo un’esecuzione in cui la race «proprio non è scattata». Pensate alle difese in tre strati.
La prima linea di difesa è il progetto. In review, confermate con una tabella: quali dati mutabili sono condivisi, quale lock protegge ciascun pezzo (la corrispondenza della sezione 5.1), se l’ordine di acquisizione dei lock è univoco, e se l’evento di stop raggiunge ogni worker. Un progetto che non riesce a compilare questa tabella non è ancora finito, anche se al momento funziona.
Secondo, rendete osservabili le anomalie. Invece di attendere incondizionatamente con INFINITE, attaccate un timeout nei punti chiave e registrate il timeout quando scatta, trasformando un hang che altrimenti durerebbe per sempre in un fallimento rilevabile. Quando un hang avviene sul campo, catturate un dump, controllate lo stack di ogni thread e cercate un ciclo in chi attende il lock di chi. Il controllo con Application Verifier è raccomandato ufficialmente per gli errori intorno alle DLL.10 Costruire dump e logging è trattato in «Designing Windows Apps to Leave Logs and Dumps When They Crash».
Terzo, scuotete le cose con il carico. Gli stress test — eseguire con più thread di quanti avete core, randomizzare l’ordine di elaborazione, inserire ritardi artificiali — sono un modo pratico per rendere più probabile che colpirete un «jackpot» di race su una macchina di sviluppo. Non dimenticate di testare anche la riproduzione su una build di release ottimizzata sotto carico pesante.
10. Riepilogo — la checklist dell’edizione C
- Ogni thread è creato con
_beginthreadex(senzaCreateThread/_beginthreadmescolati)? - Fate join degli handle dei thread (
WaitForSingleObject) prima di chiamareCloseHandle? - State producendo in massa thread vostri per lavori di breve vita (potrebbero essere affidati all’API del thread pool)?
- L’esclusione intra-processo usa un lock SRW / CRITICAL_SECTION (invece di abusare di un Mutex)?
- Contatori e flag condivisi sono aggiornati con le funzioni Interlocked invece di affidarsi a
volatile? - È rimasto del polling con
Sleep(è stato sostituito con una variabile di condizione o un’attesa su evento)? TerminateThread(uccidere forzatamente un altro thread) è assente ovunque? I worker terminano conreturndalla funzione del thread invece di chiamareExitThread(così la pulizia CRT corre correttamente attraverso_endthreadex)?- Ogni worker ha un percorso di stop via evento di stop più
WaitForMultipleObjects, e potete svegliare anche i thread bloccati su I/O? - Il rilascio di lock e handle è garantito su ogni percorso di ritorno (la disciplina
goto cleanup)? DllMainevita di creare thread, sincronizzare o attendere la fine di thread?
In cambio di non avere aiuto dal linguaggio, la qualità del multithreading in C è esattamente ciò che le vostre scelte di API e la vostra disciplina ne fanno. Rendete _beginthreadex, i lock SRW, le funzioni Interlocked e l’evento di stop il vostro set predefinito di quattro pezzi, e anche in C potete progettare in modo da stare lontani da «ogni tanto si blocca».
Articoli correlati
- Best practice di multithreading in pratica: edizione .NET — Cosa decidere prima di aggiungere altri thread
- Best practice di multithreading in pratica: edizione C++ — Eliminare gli incidenti per struttura con RAII e jthread
- Best practice di multithreading in pratica: edizione Java — Convenzioni per l’era dei virtual thread
- Shared Memory Pitfalls and Practical Best Practices
- Perché preferire l’attesa su evento a Sleep(1) su Windows
- Pitfall delle app di comunicazione seriale - Riconnessione e progettazione dei log
- Designing Windows Apps to Leave Logs and Dumps When They Crash
Aree di consulenza correlate
KomuraSoft LLC si occupa di revisione della progettazione multithread per processi residenti, applicazioni di controllo impianti e DLL scritte in C; di indagine sulle cause (analisi dei dump) di hang e crash provocati da TerminateThread o da lock non rilasciati; e di consulenza tecnica sull’aggiunta di thread a codice C legacy.
- Consulenza tecnica e revisione del progetto
- Analisi dei bug e delle cause
- Sviluppo di applicazioni Windows
- Contattaci
Riferimenti
-
Microsoft Learn, CreateThread function. Sul fatto che i thread all’interno di un eseguibile che chiama la CRT debbano essere gestiti con _beginthreadex / _endthreadex e non con CreateThread / ExitThread, e sul fatto che la CRT possa terminare il processo in condizioni di poca memoria quando un thread creato con CreateThread chiama la CRT. ↩ ↩2
-
Microsoft Learn, Multithreading with C and Win32. Sul fatto che i programmi che chiamano la libreria CRT debbano avviare i thread con _beginthread / _beginthreadex e non con CreateThread / ExitThread di Win32; sul fatto che la famiglia _beginthread inizializzi le variabili per thread della CRT; e sul fatto che SuspendThread possa fermare un thread mentre accede a strutture di dati interne della CRT, il che può portare a deadlock. ↩ ↩2
-
Microsoft Learn, About Synchronization. Sulla guida alla scelta dei primitivi di sincronizzazione Win32: i lock SRW come predefinito per il codice nuovo, della dimensione di un puntatore e di solito restano in user mode; CRITICAL_SECTION per i casi che richiedono acquisizione ricorsiva; Mutex sempre un oggetto kernel, usato per la sincronizzazione nominata tra processi e in combinazione con WaitForMultipleObjects; usare un Mutex per la sincronizzazione intra-processo è un «errore comune» molto più lento sotto operazioni frequenti; e i semafori usati per limitare l’accesso concorrente a un pool di risorse, gli eventi per la notifica. ↩ ↩2 ↩3
-
Microsoft Learn, Interlocked Variable Access. Sul fatto che le funzioni Interlocked sincronizzino l’accesso a una variabile condivisa tra più thread ed eseguano l’operazione in modo indivisibile; sul fatto che InterlockedIncrement / Decrement raggruppino lettura, addizione e riscrittura in una sola operazione atomica, poiché senza sincronizzazione un incremento simultaneo da due thread può perdere uno degli incrementi; sulla famiglia InterlockedExchange / InterlockedCompareExchange; sull’essere usabili tra thread in processi diversi quando la variabile è in memoria condivisa; e sul fatto che la maggior parte delle funzioni Interlocked fornisca una barriera di memoria completa, con varianti Acquire / Release disponibili per selezionare la semantica di ordinamento. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Using Condition Variables. Sull’esempio lavorato di una coda produttore/consumatore implementata su un buffer circolare a capacità limitata protetto da una CRITICAL_SECTION; sulla struttura in cui InitializeConditionVariable crea una variabile di condizione, il consumatore attende con SleepConditionVariableCS e il produttore lo sveglia con WakeConditionVariable; e sul supporto delle variabili di condizione da Windows Vista in poi. ↩ ↩2 ↩3
-
Microsoft Learn, TerminateThread function. Sul fatto che TerminateThread termini il thread bersaglio senza lasciargli eseguire alcun codice in user mode; sul fatto che la critical section del bersaglio non venga rilasciata se ne teneva una; sul fatto che il lock dell’heap non venga rilasciato se il thread stava allocando memoria dall’heap; sul fatto che lo stato di kernel32 o lo stato globale di una DLL possano essere corrotti; e sul fatto che sia «una funzione pericolosa da usare solo nei casi più estremi», da non chiamare a meno che non conosciate e controlliate del tutto ogni percorso di codice che il thread bersaglio potrebbe stare eseguendo. ↩ ↩2
-
Microsoft Learn, Warning C6258. Sul fatto che l’avviso di analisi del codice C6258 rilevi l’uso di TerminateThread; sul fatto che TerminateThread non possa eseguire una pulizia corretta del thread; e sulla procedura di terminazione corretta mostrata come creare un evento con CreateEvent, far monitorare a ogni thread lo stato dell’evento con WaitForSingleObject, e far terminare al thread la propria esecuzione una volta che l’evento diventa segnalato. ↩ ↩2 ↩3
-
Microsoft Learn, CreateThreadpoolWork function. Sul creare un oggetto work con CreateThreadpoolWork e far eseguire la callback a un thread worker del pool ogni volta che si chiama SubmitThreadpoolWork; sul poter specificare l’ambiente di esecuzione tramite un ambiente di callback (TP_CALLBACK_ENVIRON); e sulla disponibilità da Windows Vista in poi. ↩ ↩2
-
Microsoft Learn, Thread Pools. Sul fatto che il thread pool sia adatto alle applicazioni che eseguono grandi numeri di lavori asincroni brevi, o che creano di frequente thread di breve vita; sui componenti della nuova API del thread pool ridisegnata in Vista; sulle best practice di non terminare mai un thread del pool con TerminateThread né chiamare ExitThread da una callback, di pulire qualsiasi stato creato in una callback prima di tornare, e di tenere in vita gli handle di attesa finché il pool ha finito di usarli. ↩ ↩2
-
Microsoft Learn, Dynamic-Link Library Best Practices. Sul fatto che DllMain venga chiamata mentre è tenuto il loader lock, il che pone restrizioni serie su quali API si possano chiamare in sicurezza; sul fatto che sincronizzare con altri thread dentro DllMain porti a deadlock; sul fatto che chiamare LoadLibrary sia nell’elenco delle azioni proibite; sul pattern in cui attendere che un thread finisca dentro DllMain durante lo scaricamento della DLL vada in deadlock contro il tentativo di quel thread di acquisire il loader lock per consegnare DLL_THREAD_DETACH; sul fatto che il DllMain ideale sia vicino a uno stub vuoto, con l’inizializzazione differita il più possibile; e sul definire una gerarchia di lock con il loader lock in cima. ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Microsoft C/C++ language conformance by Visual Studio version. Sulla tabella di conformità della libreria standard C, che mostra i thread C11 (threads.h) supportati da Visual Studio 2022 17.8; stdatomic.h trattato come sperimentale (dietro l’opzione /experimental:c11atomics); e il supporto del compilatore C11 / C17 che richiede Visual Studio 2019 16.8 o successivo insieme a un Windows SDK corrispondente. ↩ ↩2
-
Microsoft Learn, Interlocked Variable Access. Sul fatto che una semplice lettura o scrittura di una variabile a 32 bit correttamente allineata sia atomica, ma che la sincronizzazione (ordinamento) dell’accesso non sia garantita; sul fatto che una semplice lettura o scrittura di una variabile a 64 bit sia atomica su Windows a 64 bit ma non garantita su Windows a 32 bit; e sul fatto che variabili di altre dimensioni non siano garantite atomiche su alcuna piattaforma. ↩ ↩2
-
Microsoft Learn, _beginthread, _beginthreadex. Sul perché _beginthreadex è più sicuro di _beginthread: un thread creato con _beginthread può lasciare l’handle restituito invalido (o puntare a un thread diverso) se finisce in anticipo; l’handle di _beginthreadex deve essere chiuso dal chiamante con CloseHandle e la sua validità è garantita; _beginthreadex consente di passare l’handle alle API di sincronizzazione; la funzione del thread restituisce un codice di uscita del thread con la convenzione di chiamata __stdcall; e serve il collegamento alla CRT multithread. ↩
Articoli correlati
Articoli recenti con gli stessi tag per approfondire argomenti vicini.
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...
L'API thread pool Win32 — Concorrenza senza creare thread, tramite CreateThreadpoolWork
State spargendo chiamate CreateThread per tutto il codice nativo? Questo articolo spiega l'API thread pool Win32 ridisegnata in Vista — i...
App che si rompono alla ripresa dalla sospensione — Come funzionano gli eventi di alimentazione di Windows e come costruire app aziendali che li sopravvivono
Aprite il portatile e le connessioni dell'app aziendale sono morte — la causa è un progetto che non ha mai tenuto conto della sospensione...
DllMain e il loader lock — Il vero motivo per cui vi dicono di «non fare niente nell'inizializzazione della DLL»
Perché non dovete chiamare LoadLibrary o sincronizzarvi con altri thread da DllMain. A partire dalle fonti primarie, l'articolo spiega co...
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.
- Devo usare CreateThread o _beginthreadex?
- Usate _beginthreadex per ogni thread che chiama funzioni della libreria di runtime C (CRT). _beginthreadex inizializza i dati interni per thread di cui la CRT ha bisogno prima di avviare il thread. La documentazione ufficiale afferma chiaramente che se un thread creato con CreateThread chiama una funzione CRT, la CRT può terminare il processo quando la memoria è scarsa. In pratica, i thread di un'applicazione C chiamano quasi sempre una funzione CRT da qualche parte (printf, malloc, strtok e così via), quindi non fa male ricordare la regola come «sempre _beginthreadex». Evitate anche _beginthread (senza ex): ha la trappola per cui l'handle restituito può diventare invalido se il thread creato finisce in anticipo, quindi la scelta è _beginthreadex, il cui handle si può passare alle API di sincronizzazione.
- Non posso fermare un thread con TerminateThread?
- No, non dovete. TerminateThread cancella il thread bersaglio senza lasciargli eseguire alcun codice in user mode, quindi se quel thread teneva una critical section non viene mai rilasciata, se stava allocando memoria dall'heap il lock dell'heap resta tenuto, e se stava manipolando lo stato globale di una DLL quello stato viene corrotto. La documentazione ufficiale lo definisce esplicitamente «una funzione pericolosa da usare solo nei casi più estremi», e l'analisi del codice lo segnala come avviso C6258. Il modo corretto di fermare è lo shutdown cooperativo: create un evento di stop, fate in modo che ogni thread lo osservi con WaitForSingleObject / WaitForMultipleObjects e lasciate che ogni thread faccia da sé la pulizia e termini.
- Usavo un Mutex per l'esclusione all'interno di un processo. Cosa c'è di sbagliato?
- Funziona, ma vi costa molta prestazione. Un Mutex Win32 è sempre un oggetto kernel, quindi ogni acquisizione e rilascio provoca una transizione in kernel mode. Per l'esclusione all'interno di un solo processo, un lock SRW o una CRITICAL_SECTION — che restano in user mode e cadono in un'attesa kernel solo in caso di contesa — sono decisamente più veloci, e la documentazione ufficiale definisce esplicitamente l'uso di un Mutex per la sincronizzazione intra-processo un «errore comune». Un Mutex vale la pena quando serve esclusione tra processi come oggetto nominato, o quando volete attenderlo insieme ad altri oggetti kernel con WaitForMultipleObjects.
- Posso usare threads.h e stdatomic.h di C11 su Windows?
- In MSVC, i thread C11 (threads.h) sono supportati da Visual Studio 2022 17.8 (richiedono /std:c11 e un Windows SDK corrispondente). stdatomic.h, invece, è ancora trattato come sperimentale e richiede l'opzione /experimental:c11atomics (secondo la tabella ufficiale di conformità di agosto 2026). È un'opzione valida se la portabilità è la priorità assoluta, ma per una base di codice solo Windows scrivere sulle API Win32 (_beginthreadex, lock SRW, variabili di condizione, funzioni Interlocked) è la scelta realistica, dati il track record e il volume di informazioni disponibili.
- Aggiungere volatile rende sicuro un flag condiviso?
- No. Il volatile di C sopprime solo ottimizzazioni del compilatore come la cache di un valore in un registro: non garantisce né l'atomicità di un'operazione né l'ordinamento di memoria tra processori. Una semplice lettura o scrittura di una variabile a 32 bit correttamente allineata è di per sé atomica su Windows, ma «leggi, aggiungi e riscrivi» si spezza in passi distinti, e non c'è garanzia sul suo ordinamento rispetto alle operazioni di memoria circostanti. Usate la famiglia Interlocked per aggiornare un contatore o un flag condiviso. La maggior parte delle funzioni Interlocked porta una barriera di memoria completa, quindi ottenete insieme anche una garanzia di ordinamento. Quando dovete proteggere insieme più variabili, usate un lock SRW o una CRITICAL_SECTION.
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.