L'API thread pool Win32 — Concorrenza senza creare thread, tramite CreateThreadpoolWork

· · 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 famiglia QueueUserWorkItem), 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.

Scelta tra un thread dedicato e un poolPrima 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 poolNoNoServe personalità(priorità o STA)?Tienilo su un thread dedicatoGira a lungo?Mettilo sul thread poolLavoro 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.

Corrispondenza tra la vecchia API thread pool e la nuova APIQueueUserWorkItem della vecchia API corrisponde all'oggetto work della nuova, le code timer a timer, le attese registrate a wait, e BindIoCompletionCallback a ioQueueUserWorkItemworkCode timertimerAttese registratewaitBindIoCompletionCallbackio

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
I quattro oggetti del thread pool e il meccanismo di callbackwork 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 workerQuale oggetto?work(all'invio)Timer, wait o io?timer(orario / periodo)Wait o io?wait(al segnale)io(completamento I/O)I worker eseguono il callback

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

Sostituire i thread che attendono soltanto con oggetti waitI 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 segnale5 thread waiter dedicati che dormonoConsuma 5 stack e 5 thread5 oggetti waitAggregati sui waiter del poolIl 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.

Ciclo di vita di un oggetto workCreare 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 CloseThreadpoolWorkCrea con CreateThreadpoolWorkInvia con SubmitThreadpoolWork(ripetibile)I callback girano in paralleloFerma i nuovi inviiAttendi con WaitForThreadpoolWorkCallbacksChiudi 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

Legare la configurazione tramite un ambiente di callbackL'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 rilascioAmbiente di callback(TP_CALLBACK_ENVIRON)Pool personalizzato(controlla i thread)Cleanup groupPassato alla creazione di work / timer / wait / ioAttendi 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».

La struttura di un deadlock da starvation del poolSe 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 sempreWorker 1: attende il lavoro XLavori X e Y in attesa di girareWorker 2: attende il lavoro YNessun worker libero per eseguirliTutti 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.

Prepararsi a una corsa tra scaricamento della DLL e un callbackUno 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 FreeLibraryWhenCallbackReturnsUnload durante callbackAccess violationAttendi e chiudiUnload sicuroIn shutdownFreeLibraryWhenCallbackReturnsultimo callbackNon in DllMain

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::thread bastano, sono la prima candidata. Sono portabili, il codice è corto, e anche la semantica di future è 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, ThreadPool e Task svolgono lo stesso ruolo, e il completamento I/O è legato a IOCP. Questa struttura di cantina è spiegata in «IOCP e il thread pool .NET».
Decidere con gli strumenti di quale strato scrivereSe 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 COMNoIntegrazione di timer, wait e ioSpezzamento pool / controllo n.Evitare thread propri in una DLLBastano gli strumenti C++ standard?std::async / std::threadDi cosa hai bisogno?Thread pool Win32

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.

  • 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

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.

Riferimenti

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

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

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

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

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

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

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

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

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

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

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.

Torna al blog