L'API thread pool Win32 — Concorrenza senza creare thread, tramite CreateThreadpoolWork
· Go Komura · Windows, Multithreading, C++, Sviluppo Windows, Win32 API, Miglioramento delle prestazioni
«Un CreateThread per client.» «Uno per il timer.» «Uno per attendere un evento.» — Nel codice nativo Windows, i thread tendono a proliferare così. Ogni thread consuma uno stack e un oggetto kernel, e crearli e distruggerli ha un costo. Il lavoro è a grana fine, eppure il thread è pesante: il thread pool è ciò che il sistema operativo fornisce per assorbire quel disallineamento.
La comodità di ThreadPool e Task.Run di .NET è ben nota, ma di fatto anche Win32 nativo ha un’API thread pool ben progettata e standard di sistema. Ridisegnata in modo complessivo in Windows Vista, questa API è il fondamento della concorrenza nativa: può gestire work, timer, wait e I/O asincrono tramite un meccanismo di callback unificato. Rivolto agli sviluppatori che scrivono app, servizi e DLL Windows in C/C++, questo articolo spiega la struttura e l’uso di questa API, e le trappole in cui è facile cadere, a partire dalle fonti primarie.
1. Prima di tutto, la conclusione
- Per emettere un gran numero di lavori di breve durata, e per sostituire i thread che esistono solo per attendere, un thread pool batte il proprio
CreateThread. Si lascia la gestione dei thread al sistema operativo e si possono ridurre il numero di thread e i context switch.1 - Ciò che si deve usare è la nuova API (la famiglia
CreateThreadpoolWork). Il ridisegno di Vista l’ha resa più semplice, più affidabile e più performante della vecchia API (la famigliaQueueUserWorkItem), e si possono anche creare più pool indipendenti in un processo.12 - Ci sono quattro tipi di oggetto. work, a cui si inviano i lavori; timer, che scatta a un orario o a un periodo; wait, che scatta quando un oggetto kernel diventa signaled; e io, che scatta quando un I/O asincrono completa. Tutti viaggiano sullo stesso meccanismo di callback.3
- Lo shutdown è «attendi, poi chiudi». Serve una disciplina di non lasciare indietro un callback in esecuzione — attendere il completamento con la famiglia
WaitForThreadpoolWorkCallbacks, o gestirlo in blocco tramite un cleanup group.4 - Dentro un callback: non bloccare a lungo (se lo si farà,
CallbackMayRunLong); non attendere in modo sincrono il completamento sullo stesso pool; non sporcare lo stato del thread. Queste tre sono regole di ferro.56 - Uso da una DLL: attenzione a una corsa con lo scaricamento. Attendere il completamento in una funzione di shutdown esplicita, e conoscere le API dedicate come
FreeLibraryWhenCallbackReturns.3
2. Perché un pool, e quando un pool
L’idea di un thread pool è semplice. Invece di creare un thread per lavoro, si lanciano lavori (callback) a un gruppo di thread worker gestiti dal sistema operativo. I worker eseguono i lavori uno dopo l’altro, e il sistema operativo regola il numero in base al carico.
La documentazione ufficiale elenca tipi concreti di app in cui un pool conviene.1
- App che emettono in parallelo un gran numero di piccoli work item (ricerca, I/O di rete, e così via)
- App che creano e smontano di frequente thread di breve durata
- App che elaborano in parallelo lavori indipendenti in background
- App che tengono thread dedicati ad attendere oggetti kernel o eventi
L’ultimo punto è facile da perdere. Se si hanno cinque thread che esistono solo per dormire così da «girare quando l’evento diventa signaled», quelli si possono sostituire con cinque oggetti wait sul pool, e l’attesa viene aggregata sui thread waiter del pool.
Viceversa, c’è anche lavoro che non si adatta a un pool. Lavoro che richiede un cambio di priorità del thread, che richiede COM STA, che continua per tutta la vita del processo: il lavoro che ha bisogno di una «personalità» sul thread si tiene su un thread dedicato. Un thread worker è una risorsa condivisa; si prende in prestito.
flowchart TB
accTitle: Scelta tra un thread dedicato e un pool
accDescr: Prima si verifica se serve una personalità del thread come priorità o STA, e se gira a lungo; solo il lavoro di breve durata, ad alto volume o di tipo attesa che non rientra in nessuno dei due va sul thread pool
q1{"Serve personalità(priorità o STA)?"} -->|"Sì"| ded["Tienilo su un thread dedicato"]
q1 -->|"No"| q2{"Gira a lungo?"}
q2 -->|"Sì"| ded
q2 -->|"No"| pool["Mettilo sul thread pool"]
pool -.-> ex["Lavoro breve, attese, timer, I/O"]
Figura 1: L’unico lavoro che si può mettere su un pool è il lavoro che «non ha bisogno di personalità e finisce in breve». Tutto il resto resta su un thread dedicato come prima.
C’è anche un pezzo di storia da fissare. L’API thread pool ha due generazioni. La vecchia API che continua da Windows 2000 (QueueUserWorkItem, RegisterWaitForSingleObject, e così via), e la nuova API ridisegnata in modo complessivo in Vista (la famiglia CreateThreadpoolWork). La nuova API unifica i tipi di thread worker, fornisce thread persistenti dedicati, più pool in un processo, cleanup group e altro, e la documentazione ufficiale afferma a chiare lettere che è «più semplice, più affidabile, più performante e più flessibile».1 La vecchia API ha anche vincoli strutturali come «non si può annullare un lavoro una volta accodato».2 Da qui in poi, questo articolo tratta solo la nuova API.
flowchart TB
accTitle: Corrispondenza tra la vecchia API thread pool e la nuova API
accDescr: QueueUserWorkItem della vecchia API corrisponde all'oggetto work della nuova, le code timer a timer, le attese registrate a wait, e BindIoCompletionCallback a io
o1["QueueUserWorkItem"] --> n1["work"]
o2["Code timer"] --> n2["timer"]
o5["Attese registrate"] --> n3["wait"]
o4["BindIoCompletionCallback"] --> n4["io"]
Figura 2: L’obiettivo di migrazione dalla vecchia API è uno-a-uno. Un inventario del codice esistente può partire da questa corrispondenza.
3. I quattro oggetti — work, timer, wait e io
Al centro della nuova API ci sono quattro tipi di oggetto le cui condizioni di scatto del callback differiscono.3
| Oggetto | Funzione di creazione | Quando scatta il callback |
|---|---|---|
| work | CreateThreadpoolWork | Quando viene inviato con SubmitThreadpoolWork |
| timer | CreateThreadpoolTimer | Quando arriva l’orario o il periodo specificato |
| wait | CreateThreadpoolWait | Quando un oggetto kernel diventa signaled |
| io | CreateThreadpoolIo | Quando completa l’I/O asincrono sull’handle associato |
flowchart TB
accTitle: I quattro oggetti del thread pool e il meccanismo di callback
accDescr: work scatta su un invio esplicito, timer sul tempo, wait su un segnale di oggetto kernel e io sul completamento di I/O asincrono; tutti girano come callback sullo stesso gruppo di thread worker
kind{"Quale oggetto?"}
kind --> w["work(all'invio)"]
kind --> more{"Timer, wait o io?"}
more --> t["timer(orario / periodo)"]
more --> rest{"Wait o io?"}
rest --> wt["wait(al segnale)"]
rest --> io["io(completamento I/O)"]
w --> pool["I worker eseguono il callback"]
t --> pool
wt --> pool
io --> pool
Figura 3: Le condizioni di scatto differiscono, ma tutti e quattro sono unificati in un meccanismo in cui «i worker dello stesso pool eseguono il callback».
Questa unificazione è un punto di forza pratico. Invece di scrivere l’elaborazione periodica, la risposta agli eventi e l’elaborazione del completamento I/O ciascuna su un thread dedicato, le si può allineare su uno stile di callback. I timer sono raccolti in una sola coda timer per l’intero pool, e le attese sono aggregate su un numero ridotto di thread waiter: i thread che «dormono soltanto» scompaiono dal processo.1
flowchart TB
accTitle: Sostituire i thread che attendono soltanto con oggetti wait
accDescr: I thread che esistevano solo per dormire, uno per evento, diventano oggetti wait e vengono aggregati sui thread waiter del pool, così il callback gira solo quando c'è un segnale
old2["5 thread waiter dedicati che dormono"] -.-> waste["Consuma 5 stack e 5 thread"]
new2["5 oggetti wait"] --> agg["Aggregati sui waiter del pool"]
agg --> cb2["Il callback gira solo al segnale"]
Figura 4: I thread che «dormono e attendono soltanto» si possono togliere trasformandoli in oggetti wait. È una prima mossa chiara per una migrazione al pool.
4. Il motivo di base — Un andata e ritorno con un oggetto work
Percorriamo le maniere una volta con l’oggetto work, che è il più usato.4
VOID CALLBACK WorkCallback(PTP_CALLBACK_INSTANCE instance,
PVOID context, PTP_WORK work)
{
// The context is fixed at creation time. Per-item data is passed through a synchronised queue
WORK_QUEUE* queue = (WORK_QUEUE*)context;
ITEM* item = Dequeue(queue); // Take one item under exclusive control
ProcessItem(item);
}
// 1) Create (bind the callback to the shared context = the queue)
PTP_WORK work = CreateThreadpoolWork(WorkCallback, &queue, NULL);
if (!work) { /* Failure handling with GetLastError */ }
// 2) Submit once for each item you enqueue (keep the item count and the submit count in step)
Enqueue(&queue, item);
SubmitThreadpoolWork(work);
// 3) Stop the submitter, then wait for completion (TRUE also attempts to cancel work that has not yet started)
WaitForThreadpoolWorkCallbacks(work, FALSE);
// 4) Close
CloseThreadpoolWork(work);
Ci sono due punti da tenere. Primo: si può fare SubmitThreadpoolWork dello stesso oggetto work più di una volta. Ogni invio esegue il callback (in parallelo).7 Tuttavia, il contesto passato al callback è fisso alla creazione, quindi quando si scrive «N elementi dello stesso tipo di lavoro» con un solo oggetto work, si mette una coda sincronizzata nel contesto come nel codice sopra e si prende un elemento per invio (va bene anche un progetto che crea un oggetto work per elemento). Secondo: attendere sempre il completamento prima di chiudere. Chiudere l’oggetto mentre resta un callback in esecuzione o in coda, o liberare memoria a cui il callback fa riferimento, è use-after-free così com’è. Perché questa attesa sia una chiusura sicura, fermare prima chi invia è un prerequisito: in una struttura in cui un altro thread può ancora fare Submit in parallelo all’attesa, un invio dopo l’attesa entra in corsa con Close. Passare TRUE come secondo argomento di WaitForThreadpoolWorkCallbacks tenta anche di annullare gli invii che non sono ancora partiti.
flowchart TB
accTitle: Ciclo di vita di un oggetto work
accDescr: Creare con CreateThreadpoolWork; inviare con SubmitThreadpoolWork e i callback girano in parallelo. Allo shutdown, prima fermare i nuovi invii, attendere il completamento di ogni callback con WaitForThreadpoolWorkCallbacks, poi chiudere con CloseThreadpoolWork
c["Crea con CreateThreadpoolWork"] --> s["Invia con SubmitThreadpoolWork(ripetibile)"]
s --> run["I callback girano in parallelo"]
run --> stop3["Ferma i nuovi invii"]
stop3 --> w["Attendi con WaitForThreadpoolWorkCallbacks"]
w --> cl["Chiudi con CloseThreadpoolWork"]
Figura 5: L’ordine di shutdown è «ferma gli invii → attendi il completamento → chiudi». Saltarne uno e si ottiene use-after-free o una corsa.
Per default, i callback girano sul pool predefinito del processo. Per molti usi basta. Il capitolo successivo è per quando si vogliono spezzare i pool.
5. Pool personalizzati e cleanup group
Spezzare il pool. Si può creare un pool indipendente con CreateThreadpool e impostare i limiti superiore e inferiore sul numero di thread con SetThreadpoolThreadMaximum / SetThreadpoolThreadMinimum.8 L’uso tipico è l’isolamento. Perché «lavoro di tipo batch che può essere lento» non mangi i worker di «lavoro che deve rispondere subito», si spezzano i pool e a ciascuno si dà il proprio budget di thread.
Legarli con un ambiente di callback. Su quale pool gira il lavoro si specifica inizializzando un TP_CALLBACK_ENVIRON (ambiente di callback), puntandolo al pool con SetThreadpoolCallbackPool e passandolo come terzo argomento di CreateThreadpoolWork e simili.9
Raccoglierli con un cleanup group. In un modulo che crea molti oggetti, l’elaborazione di shutdown tende a diventare una recita di «attendili tutti, chiudili tutti». Se si crea un gruppo con CreateThreadpoolCleanupGroup e si attacca ogni oggetto tramite l’ambiente di callback, un solo CloseThreadpoolCleanupGroupMembers esegue attesa di completamento e rilascio di ogni oggetto membro insieme.34
flowchart TB
accTitle: Legare la configurazione tramite un ambiente di callback
accDescr: L'ambiente di callback punta a un pool personalizzato e a un cleanup group; work e timer creati con quell'ambiente girano su quel pool, e un'operazione in blocco sul cleanup group raccoglie attesa di completamento e rilascio
env["Ambiente di callback(TP_CALLBACK_ENVIRON)"] --> cp["Pool personalizzato(controlla i thread)"]
env --> cg["Cleanup group"]
env --> obj["Passato alla creazione di work / timer / wait / io"]
cg -.-> close["Attendi e rilascia in un colpo"]
Figura 6: Un ambiente di callback è il meccanismo che inietta «su quale pool gira, e chi pulisce» al momento della creazione dell’oggetto.
6. Trappole — Disciplina dentro un callback
Quasi ogni bug di thread pool viene da «fare come si vuole su un thread in prestito».
Bloccare a lungo. Il pool regola il numero di thread nell’ipotesi che i callback tornino in tempi brevi. Fare lavoro lungo o un’attesa lunga con i default ritarda l’esecuzione degli altri callback. Un callback che può durare a lungo dovrebbe dichiarare «questo durerà a lungo» con CallbackMayRunLong (il pool lo prende come accenno per aggiungere un thread), oppure essere mandato in partenza su un thread dedicato. Si noti che CallbackMayRunLong restituisce FALSE quando non riesce a preparare un worker per gli altri callback. Se si continua a bloccare senza controllare il valore di ritorno si intasa comunque il pool, quindi quando è FALSE si cade sul lato che non blocca — spezzare il lavoro, mandarlo su un thread dedicato, e così via.5
Attendere in modo sincrono il completamento sullo stesso pool. Una forma in cui, dentro il callback A, si attende con WaitForThreadpoolWorkCallbacks o simile il completamento del lavoro B inviato allo stesso pool diventa un deadlock da starvation del pool nel momento in cui ogni worker è «in attesa di un altro worker». Riscrivere una dipendenza tra lavori non come un’attesa ma come una continuazione che «invia il prossimo dal callback di completamento di B».
flowchart TB
accTitle: La struttura di un deadlock da starvation del pool
accDescr: Se ogni thread worker attende in modo sincrono il completamento di altro lavoro inviato allo stesso pool, non resta un worker libero per eseguire quel lavoro, e tutti attendono per sempre
w1["Worker 1: attende il lavoro X"] --> q["Lavori X e Y in attesa di girare"]
w2["Worker 2: attende il lavoro Y"] --> q
q -.-> none["Nessun worker libero per eseguirli"]
none -.-> dead["Tutti attendono per sempre(starvation)"]
Figura 7: Se si attende in modo sincrono un worker da dentro un worker, non resta nessuno a eseguire il lavoro atteso.
Sporcare lo stato del thread. Un thread worker viene riusato per il callback successivo. Un cambio di priorità del thread, lo stato di inizializzazione COM, un valore lasciato in TLS, un lock che si è dimenticato di lasciare: ognuno di questi diventa contaminazione del callback successivo (non correlato). «Una funzione che si lancia a un pool non deve dipendere dalla personalità del thread» è un monito ufficiale già dall’epoca della vecchia API.6 Per la pulizia ci sono meccanismi dedicati; ad esempio, LeaveCriticalSectionWhenCallbackReturns può chiedere al pool di «rilasciare questo lock quando questo callback torna».3
Una corsa con lo scaricamento della DLL. Se la DLL che contiene il codice viene scaricata mentre un callback gira, si ottiene un access violation. La forma di base è attendere a fondo il completamento nella funzione di shutdown della DLL; FreeLibraryWhenCallbackReturns è previsto per la situazione «questo callback è l’ultimo lavoro, e quando finisce voglio che la DLL sia liberata compreso me stesso». Questa API, però, «lascia andare un riferimento quando il callback in esecuzione torna»; non impedisce uno scaricamento prima che il callback sia partito. La si usa in coppia: prendere un riferimento al modulo in proprio con GetModuleHandleEx prima di inviare, e far sì che il callback lasci andare quel riferimento con questa API.3 E non si deve fare questa attesa di completamento dentro DllMain: come detto in «DllMain e il loader lock», attendere un altro thread dentro DllMain è un pattern di deadlock.
flowchart TB
accTitle: Prepararsi a una corsa tra scaricamento della DLL e un callback
accDescr: Uno scaricamento della DLL mentre un callback gira diventa un access violation, quindi la forma di base è attendere il completamento in una funzione di shutdown esplicita e poi chiudere; quando è l'ultimo callback stesso a liberare la DLL, usare FreeLibraryWhenCallbackReturns
risk["Unload durante callback"] -.-> av["Access violation"]
g1["Attendi e chiudi"] --> safe["Unload sicuro"]
g1 -.-> g1N["In shutdown"]
g2["FreeLibraryWhenCallbackReturns"] --> safe
g2 -.-> g2n["ultimo callback"]
g1 -.-> ng["Non in DllMain"]
av ~~~ g1
Figura 8: La forma di base è fare «attendi, poi chiudi» in una funzione di shutdown esplicita. Attendere dentro DllMain invita un deadlock diverso.
Eccezioni e crash nel lavoro inviato. Un’eccezione non gestita su un thread worker porta via il processo. Applicare una politica di catturare le eccezioni in modo complessivo all’ingresso del callback e registrarle, nello stesso modo in cui si farebbe per la funzione thread di un thread dedicato.
7. Come si colloca rispetto alla libreria standard e a .NET — A quale strato si scrive
Infine, ordiniamo come sta rispetto agli altri strumenti.
- Se C++
std::async/std::threadbastano, sono la prima candidata. Sono portabili, il codice è corto, e anche la semantica difutureè fissata dallo standard.10 - Le ragioni per usare il thread pool Win32 direttamente sono quando si (1) vuole un meccanismo di callback unificato che includa timer, wait e io, (2) si vuole lo spezzamento dei pool o il controllo del numero di thread, o (3) non si vogliono tenere thread propri dentro una DLL o un componente COM.
- Lato .NET,
ThreadPooleTasksvolgono lo stesso ruolo, e il completamento I/O è legato a IOCP. Questa struttura di cantina è spiegata in «IOCP e il thread pool .NET».
flowchart TB
accTitle: Decidere con gli strumenti di quale strato scrivere
accDescr: Se async o thread dello standard C++ bastano, usare quelli; usare il thread pool Win32 direttamente quando serve l'integrazione di timer, attese e completamenti I/O, lo spezzamento dei pool o il controllo del numero di thread, o non si vogliono thread propri dentro una DLL o un componente COM
q1{"Bastano gli strumenti C++ standard?"} -->|"Sì"| std["std::async / std::thread"]
q1 -->|"No"| q2{"Di cosa hai bisogno?"}
q2 -->|"Integrazione di timer, wait e io"| tp["Thread pool Win32"]
q2 -->|"Spezzamento pool / controllo n."| tp
q2 -->|"Evitare thread propri in una DLL"| tp
Figura 9: Nel dubbio si parte dalla libreria standard; il turno di questa API arriva quando compare un requisito che quella non sa esprimere.
In altre parole, questa API è il fondamento della concorrenza nel punto in cui si è deciso di «scrivere nativo». Una pulizia realistica è a stadi: come obiettivo di migrazione da una proliferazione di CreateThread propri, si inizia introducendo l’oggetto work, poi si sostituiscono i thread che attendono soltanto con wait e i thread timer con timer.
8. Riepilogo
- Per emettere un gran numero di lavori di breve durata, e per ripulire i thread che esistono solo per attendere o solo per i timer, il thread pool standard di sistema piuttosto che i propri thread. Ciò che si usa è la nuova API da Vista in poi.
- Al centro ci sono i quattro oggetti work, timer, wait e io. Le condizioni di scatto differiscono; sono unificati sullo stesso gruppo di worker e sullo stesso stile di callback.
- Le maniere sono «crea → invia → attendi il completamento → chiudi». Più invii girano in parallelo. Un cleanup group può raggruppare l’elaborazione di shutdown.
- Le tre regole di ferro di un callback: non bloccare a lungo (se lo si farà,
CallbackMayRunLong); non attendere in modo sincrono sullo stesso pool; non sporcare lo stato del thread. - Uso da una DLL: attenzione a una corsa con lo scaricamento. Attendere il completamento in una funzione di shutdown esplicita; non farlo in
DllMain. - Dove bastano lo standard C++ o .NET, usare quelli. Il turno di questa API è quando serve l’integrazione di timer/wait/io o il controllo del pool.
L’API thread pool è, tra le API Win32, dal lato più recente e meglio progettato. Una volta finito il passaggio dall’idea di «creare un thread» all’idea di «lanciare un callback», la concorrenza nel codice nativo diventa notevolmente più chiara da scrivere.
Articoli correlati
- Le profondità dell’I/O di Windows (parte 3) — I/O Completion Ports (IOCP) e il thread pool .NET: la cantina sotto async/await
- Best practice di multithreading in pratica: edizione C++ — Eliminare gli incidenti per struttura con RAII e jthread
- Best practice di multithreading in pratica: edizione C — Scrivere in sicurezza alla maniera delle API Win32
- Sveglie spurie — Perché le condition variable si svegliano «senza essere notificate» e come attendere correttamente su Windows
- DllMain e il loader lock — Il vero motivo per cui vi dicono di «non fare niente nell’inizializzazione della DLL»
- Perché preferire l’attesa su evento a Sleep(1) su Windows
Aree di consulenza correlate
KomuraSoft LLC si occupa di progetti di migrazione dal codice nativo i cui thread sono proliferati verso il thread pool, di revisioni di progetto dell’elaborazione concorrente in app e DLL C++, e di indagini sulle cause di hang e crash provocati da starvation del pool o dai callback. Siete i benvenuti a consultarci partendo da un inventario del codice esistente.
- Sviluppo di app Windows
- Consulenza tecnica e revisione della progettazione
- Indagine sui difetti e analisi delle cause
- Contattaci
Riferimenti
-
Microsoft Learn, Thread Pools. Sul fatto che un thread pool sia una collezione di thread worker che eseguono in modo efficiente callback asincroni per conto di un’app; sui tipi di app a cui si adatta (emettere in parallelo un gran numero di piccoli work item, creare e smontare di frequente thread di breve durata, elaborare in parallelo lavori indipendenti, attese esclusive su oggetti kernel, e così via); e sul ridisegno complessivo di Vista (unificazione dei tipi di thread worker, una sola coda timer, thread persistenti dedicati, cleanup group, più pool in un processo, e la nuova API). ↩ ↩2 ↩3 ↩4 ↩5
-
Microsoft Learn, Thread Pooling. Sulla struttura delle API thread pool più vecchie (QueueUserWorkItem, code timer, attese registrate, BindIoCompletionCallback); sul fatto che non ci sia modo di annullare un lavoro una volta accodato; e sul fatto che la nuova API thread pool introdotta in Vista sia indicata come più semplice e superiore in affidabilità, prestazioni e flessibilità. ↩ ↩2
-
Microsoft Learn, threadpoolapiset.h header. Sull’elenco di funzioni che include le quattro funzioni di creazione oggetto CreateThreadpoolWork, CreateThreadpoolTimer, CreateThreadpoolWait e CreateThreadpoolIo; i cleanup group (CreateThreadpoolCleanupGroup); e la pulizia legata al completamento del callback (LeaveCriticalSectionWhenCallbackReturns, FreeLibraryWhenCallbackReturns, e così via). ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, Using the Thread Pool Functions. Sulla procedura di base di creare con CreateThreadpoolWork, inviare con SubmitThreadpoolWork, attendere il completamento con WaitForThreadpoolWorkCallbacks e chiudere con CloseThreadpoolWork; e su un esempio di configurazione che combina un pool personalizzato con un ambiente di callback e un cleanup group. ↩ ↩2 ↩3
-
Microsoft Learn, CallbackMayRunLong function (threadpoolapiset.h). Sulla notifica al pool che il callback corrente può durare a lungo, così che il pool possa usarla come materiale per decidere se assicurare un thread per gli altri callback; e sul considerare un thread dedicato per un callback di lunga durata dove possibile. ↩ ↩2
-
Microsoft Learn, Thread Pooling. Sul fatto che i work item inviati a un thread pool, e le funzioni che chiamano, debbano essere thread-pool safe; sul fatto di non presupporre che il thread in esecuzione sia un thread dedicato e persistente; e sull’evitare l’uso di TLS e di chiamate asincrone che richiedono un thread persistente. ↩ ↩2
-
Microsoft Learn, SubmitThreadpoolWork function (threadpoolapiset.h). Sul fatto di poter inviare lo stesso oggetto work più di una volta senza attendere il completamento di un callback precedente, così che i callback girino in parallelo; e sul fatto che il pool possa regolare (throttling) il numero di thread per efficienza. ↩
-
Microsoft Learn, SetThreadpoolThreadMaximum function (threadpoolapiset.h). Sul fatto di poter impostare un limite superiore sul numero di thread worker per un pool creato con CreateThreadpool (il limite inferiore è SetThreadpoolThreadMinimum). ↩
-
Microsoft Learn, CreateThreadpoolWork function (threadpoolapiset.h). Sulla creazione di un oggetto work da una funzione callback e un puntatore di contesto; e sul fatto che il terzo argomento, TP_CALLBACK_ENVIRON, possa specificare l’ambiente di esecuzione del callback (il pool a cui appartiene, e così via), con NULL che significa che gira nell’ambiente predefinito. ↩
-
Microsoft Learn, <future>. Sull’esecuzione asincrona per task tramite std::async e future fornita come libreria standard, così da poter scrivere concorrenza senza gestire i thread direttamente. ↩
Articoli correlati
Articoli recenti con gli stessi tag per approfondire argomenti vicini.
Named pipe in pratica — L'IPC standard di Windows, dalla progettazione alla sicurezza
Guida pratica alle named pipe, il meccanismo standard di comunicazione tra processi su Windows. L'articolo organizza, a partire dalle fon...
Outsourcing e sviluppo su commissione di app Windows: cosa chiarire prima di affidare l'incarico
Prima di affidare in outsourcing o su commissione lo sviluppo di un'app Windows, ecco i punti da chiarire: revisione del software esisten...
Il curioso amore di uno sviluppatore, ovvero: come ho imparato a non preoccuparmi e ad amare Windows
Windows è complicato. Ma questa complicazione è anche quella di un sistema operativo che da decenni porta sulle spalle il lavoro reale.
Checklist per gestire in sicurezza processi figli nelle app Windows - Job Object, propagazione exit, stdio e watchdog
Come far morire i processi figli con il padre su Windows: Job Object, propagazione exit, drenaggio stdout/stderr e posizionamento del wat...
Distribuzione single-file di app Windows - Binari singoli e limiti delle dipendenze OS
Quando vuoi distribuire un'app Windows come singolo EXE, conta la differenza tra consegnare un artefatto ed eliminare le dipendenze OS. A...
Argomenti correlati
Queste pagine collocano l’argomento in un contesto più ampio di servizi e decisioni.
Argomenti tecnici Windows
Portale su sviluppo Windows, analisi dei problemi e valorizzazione delle risorse esistenti.
Servizi collegati all’argomento
L’articolo è direttamente collegato ai servizi seguenti.
Sviluppo di applicazioni Windows
Applicazioni aziendali, integrazione di dispositivi e strumenti di comunicazione, dai requisiti allo sviluppo.
Domande frequenti
Domande che ricorrono nelle consulenze sull’argomento dell’articolo.
- In cosa un thread pool è meglio di creare i propri thread con CreateThread?
- L'efficienza quando si hanno molti lavori di breve durata da fare, e una riduzione del codice di gestione dei thread. Creare e distruggere un thread ha un costo che non si può ignorare, quindi un'app che ripete «CreateThread per ogni lavoro e distruggerlo a fine lavoro», o che tiene molti thread che esistono solo per dormire in attesa di un evento, può ridurre il numero di thread e i context switch passando a un pool. La documentazione ufficiale elenca come candidate al pool le app che emettono in parallelo un gran numero di piccoli work item, le app che creano molti thread di breve durata, e le app che hanno thread dedicati unicamente ad attendere oggetti kernel. Viceversa, il lavoro che «ha bisogno di una personalità propria sul thread» — un cambio di priorità, COM STA, un'elaborazione dedicata di lunga durata — va tenuto su un thread dedicato come prima.
- In cosa differisce dalle vecchie funzioni thread pool come QueueUserWorkItem?
- Il thread pool è stato ridisegnato in modo complessivo in Windows Vista. Le API attuali della famiglia threadpoolapiset (CreateThreadpoolWork e simili) sono la nuova API; QueueUserWorkItem, RegisterWaitForSingleObject e simili sono la vecchia API (legacy). La nuova API unifica i tipi di thread worker, consente di creare più pool indipendenti in un processo e fornisce meccanismi come il rilascio in blocco tramite un cleanup group e il rilascio di un lock o lo scaricamento di una DLL legati al completamento del callback. La documentazione ufficiale dice anche che la nuova API è più semplice e superiore in affidabilità, prestazioni e flessibilità. La vecchia API ha inoltre vincoli strutturali come «non c'è modo di annullare un lavoro una volta accodato», quindi nel codice nuovo si usa la nuova API.
- Ci sono cose che non si devono fare dentro un callback?
- Ce ne sono tre grandi. Prima: bloccare a lungo o fare lavoro lungo con i default. Il pool regola il numero di thread nell'ipotesi che i callback finiscano in tempi brevi, quindi per un lavoro che durerà a lungo o lo si dichiara con CallbackMayRunLong o si usa un thread dedicato. Seconda: attendere in modo sincrono il completamento di altro lavoro inviato allo stesso pool. Se ogni worker finisce «in attesa di un altro worker», si ottiene un deadlock da starvation del pool. Terza: dipendere dalla personalità del thread. I thread worker sono condivisi tra i callback, quindi tornare con una priorità o uno stato di inizializzazione COM cambiati, o lasciare stato in TLS, contamina il callback successivo. Per la pulizia alla fine (rilasciare un lock o scaricare una DLL) sono previsti meccanismi dedicati come LeaveCriticalSectionWhenCallbackReturns e FreeLibraryWhenCallbackReturns.
- Ci sono punti da osservare quando si usa il thread pool da una DLL?
- Il pericolo più grande è «la DLL viene scaricata mentre un callback è ancora in esecuzione». Se il callback gira dopo lo scaricamento, si ottiene un access violation. Il lato DLL deve, nella sua elaborazione di shutdown, attendere in modo affidabile il completamento dei callback che ha emesso — con una funzione di attesa come WaitForThreadpoolWorkCallbacks, o CloseThreadpoolCleanupGroupMembers su un cleanup group — e solo allora chiudere gli oggetti. Attendere questo dentro DllMain, però, può andare in deadlock per interazione con il loader lock, quindi la regola è farlo in una funzione di shutdown esplicita, non in DllMain. Per la situazione in cui è il callback stesso a voler liberare la DLL perché «questo lavoro è l'ultimo», è prevista un'API dedicata, FreeLibraryWhenCallbackReturns.
- Ora che esistono C++ std::async e il ThreadPool di .NET, ci sono ancora occasioni per usare questa API direttamente?
- Ci sono. Il criterio è «basta lo strumento di quello strato». Se la granularità di concorrenza di cui si ha bisogno in C++ è coperta da std::async o std::thread, la libreria standard è la prima candidata anche dal punto di vista della portabilità. D'altra parte, voler unificare timer, attese su oggetti kernel e completamenti di I/O asincrono in un unico meccanismo di callback; voler spezzare i pool e controllare i numeri di thread per tipo di lavoro; non voler tenere thread propri dentro una DLL o un componente COM — quei requisiti sono ciò che copre il thread pool Win32. Il rapporto con il ThreadPool di .NET e IOCP è trattato in un articolo correlato, e finché si scrive nativo conoscere questo meccanismo che sta nello strato sotto non è tempo perso.
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.