Gestione della Proprietà di un Thread in C++ 11
Un oggetto di tipo std::thread
dello standard C++ 11 rappresenta la proprietà, intesa come possesso, o ownership di un thread.
I thread non possono essere, pertanto, copiati ma possono essere soltanto trasferiti. Il che significa, in pratica, che le risorse relative ad un thread reale possono appartenere ad un solo oggetto alla volta.
In questa lezione approfondiremo il concetto di proprietà di un thread in C++ 11 e vedremo come realizzare una classe sentinella che si occupa di acquisire la proprietà di un thread e di effettuare la join()
del thread alla fine del suo ciclo di vita.
Proprietà di un Thread in C++ 11
Come abbiamo visto nelle lezioni precedenti, i thread in C++ 11 sono oggetti a tutti gli effetti. Essi rappresentano concretamente un flusso di esecuzione parallelo rispetto al thread principale del programma. Quando creiamo un thread otteniamo un oggetto su cui possiamo invocare metodi per controllarne il comportamento.
Dal momento che i thread sono oggetti, essi incapsulano e gestiscono risorse al proprio interno. I thread non sono i soli oggetti di questo tipo nella libreria standard del C++. Ad esempio, gli oggetti std::unique_ptr
e std::ifstream
gestiscono rispettivamente la memoria dinamica e i file. In questi casi, i progettisti dello standard hanno deciso che oggetti che gestiscono risorse non possono essere copiati, ma possono essere soltanto trasferiti (moved). I thread, in tal senso, non fanno eccezione.
Questa scelta ha un senso logico: se un thread è un flusso di esecuzione parallelo, cosa potrebbe mai significare copiarlo? Se un thread si trova in un particolare stato e sta eseguendo un certo codice, cosa potrebbe mai significare creare una copia di esso? La risposta è che non ha senso. Per questo motivo, i thread in C++ 11 non possono essere copiati, ma possono essere soltanto trasferiti. In termini pratici, oggetti di tipo std::thread
non supportano il costruttore di copia e l'assegnamento di copia ma supportano la std::move
semantic.
Possiamo comprendere meglio il concetto ragionando in termini di proprietà di un thread, intesa nel senso di possedere un thread. Questa filosofia prende il nome di RAII (Resource Acquisition Is Initialization), un pattern di programmazione che prevede che la gestione delle risorse sia legata al ciclo di vita di un oggetto. In questo caso, la risorsa è il thread e l'oggetto che la gestisce è l'oggetto std::thread
. Possiamo vedere l'oggetto std::thread
come il certificato di proprietà di un thread. Se vogliamo passare la proprietà di un thread da un oggetto ad un altro, dobbiamo farlo in modo esplicito, utilizzando la semantica di movimento e la funzione std::move
.
Vediamo un esempio pratico:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
|
Questo esempio è molto esplicativo perché ci permette di chiarire molti concetti:
-
Per prima cosa, nella riga 10 creiamo un thread che esegue la funzione
prima_funzione
. Questo thread è di proprietà dell'oggettoT1
. Possiamo ragionare da un altro punto di vista. Abbiamo creato un thread, a cui daremo il nome di, che esegue la funzione prima_funzione
e che appartiene all'oggetto: -
Nella riga 16, trasferiamo la proprietà di
da a . Questo significa che diventa il nuovo proprietario di : Da notare che l'oggetto
non possiede più il thread , anzi, non possiede alcun thread. Questo è un concetto molto importante: un oggetto di tipo std::thread
può essere svuotato e riutilizzato per creare un nuovo thread. -
Nella riga 19, creiamo un nuovo thread che esegue la funzione
seconda_funzione
e lo assegniamo a. Questo significa che diventa il proprietario di un nuovo thread, a cui daremo il nome di . Quindi, dopo la riga 19, la situazione diventa: Come si può vedere, abbiamo riutilizzato l'oggetto
per creare un nuovo thread. Quindi, un oggetto di tipo std::thread
può essere riutilizzato. -
Nella riga 25, trasferiamo la proprietà di
da a . Questo significa che diventa il nuovo proprietario di : -
L'ultima riga, la 29, è, forse, la riga più interessante ed è anche quella su cui molti programmatori incappano in errori. In questa riga, trasferiamo la proprietà di
da a . Questo significa che diventa il nuovo proprietario di . Tuttavia c'è un problema: è già il proprietario di . La situazione diventa, quindi, la seguente: Il problema è che il thread
è ancora in esecuzione ma non appartiene a nessun oggetto!. Quello che accade, in questo caso, è che la libreria standard del C++ invocherà automaticamente la funzione std::terminate()
che terminerà il programma. Questo comportamento è voluto e, infatti, bisogna sempre garantire che su di un thread venga effettuata lajoin()
. Questo problema prende il nome di dangling thread o thread orfano.
Ricapitolando:
Proprietà o Ownership di un Thread
- Gli oggetti di tipo
std::thread
in C++ 11 e successivi non possono essere copiati, ma possono essere soltanto trasferiti attraverso la funzionestd::move
; - Un oggetto
std::thread
rappresenta la proprietà o ownership di un thread; - Il possesso di un thread può essere trasferito da un oggetto ad un altro; l'oggetto che riceve la proprietà diventa il nuovo proprietario del thread; l'oggetto che cede la proprietà diventa un oggetto vuoto e su di esso non è possibile invocare metodi che richiedono un thread in esecuzione;
- Un oggetto di tipo
std::thread
può essere riutilizzato per creare un nuovo thread oppure per ricevere la proprietà di un thread già esistente.
Problema del Dangling Thread o Thread Orfano
Un dangling thread o thread orfano è un thread che è ancora in esecuzione ma non appartiene a nessun oggetto. Questo comportamento è pericoloso perché la libreria standard del C++ terminerà automaticamente il programma invocando la funzione std::terminate()
. Per evitare questo problema, è necessario garantire che su di un thread venga effettuata la join()
.
Passaggio della Proprietà di un Thread
Il passaggio della proprietà, o ownership, di un thread, attraverso la funzione std::move
, è un concetto molto importante. Attraverso questo meccanismo, possiamo trasferire la proprietà di thread da una funzione ad un'altra o da un oggetto ad un altro.
Ad esempio, potremmo voler realizzare una funzione che crea un thread ma non aspetta che esso termini, anzi, restituisce il thread appena creato al chiamante. Ad esempio:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
|
In questo caso, la funzione crea_thread
crea un thread che esegue la funzione mia_funzione
e restituisce il thread al chiamante. Questo comportamento è possibile grazie alla semantica di movimento. Inoltre, la restituzione attraverso la semantica di movimento è molto efficiente perché non comporta la copia di risorse ma solo il trasferimento della proprietà.
Analogamente, possiamo creare funzioni che accettano un thread come argomento sfruttando, anche in questo caso, la semantica di movimento. Ad esempio:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
In questo caso, la funzione esegui_thread
accetta un thread come argomento e lo esegue.
Questo meccanismo automatico di trasferimento della proprietà di un thread ci permette di realizzare delle applicazioni molto interessanti. Vediamone alcune di seguito.
join
automatico di un thread
Abbiamo visto che uno dei problemi più frequenti nell'uso dei thread consiste nel dimenticare di invocare la funzione join
su di un thread. Questo problema può essere risolto in modo molto elegante sfruttando la semantica di movimento.
Una prima applicazione del meccanismo visto sopra può essere, infatti, la realizzazione di una classe sentinella che svolge due compiti:
- Acquisisce la proprietà di un thread;
- Si assicura di effettuare la
join()
del thread alla fine del suo ciclo di vita.
Infatti, possiamo realizzare la classe SentinellaThread
come segue:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
|
Analizziamo il codice:
-
Nel costruttore, acquisiamo la proprietà del thread attraverso la semantica di movimento;
Per prima cosa il costruttore è dichiarato come
explicit
per evitare conversioni implicite. Questo comportamento è voluto perché vogliamo che la classeSentinellaThread
possa essere costruita soltanto con un oggetto di tipostd::thread
.All'interno del costruttore trasferiamo la proprietà del thread passato come argomento all'oggetto membro privato
t
attraverso la funzionestd::move
. -
Nel distruttore, effettuiamo la
join()
del thread;Nel distruttore, controlliamo se il thread è joinable attraverso il metodo
joinable()
. Se il thread è joinable, effettuiamo lajoin()
.Questo ci assicura che, nel momento in cui l'oggetto
SentinellaThread
esce dallo scope, il thread venga effettivamente terminato. -
Disabilitiamo il costruttore di copia e l'assegnamento di copia;
Infine, disabilitiamo il costruttore di copia e l'assegnamento di copia per evitare che l'oggetto
SentinellaThread
possa essere copiato. -
Un'ultima, importante, osservazione riguarda il fatto che per la classe
SentinellaThread
non è necessario implementare un costruttore di move e un operatore di assegnamento di move. Questo perché la classestd::thread
implementa già un costruttore di move e un operatore di assegnamento di move. Quindi, implicitamente, quando eseguiamo lastd::move
su di un oggetto di tipoSentinellaThread
, stiamo effettuando lastd::move
sull'oggetto membrot
.
Possiamo utilizzare la classe SentinellaThread
come segue:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
In questo caso, abbiamo creato un oggetto SentinellaThread
all'interno di uno scope. Quando l'oggetto SentinellaThread
esce dallo scope, abbiamo la garanzia che il thread sia terminato e sia stata effettuata la join()
.
Classe Thread con join
automatico
Per lo standard C++ 17 fu proposta una classe, chiamata std::jthread
, che si comportava esattamente come la classe std::thread
ma con la differenza che effettuava automaticamente la join()
nel distruttore. Questa classe non è stata inserita nello standard C++ 17 ma è stata inserita nello standard C++ 20.
In ogni caso, se ci troviamo a dover lavorare con uno standard precedente al C++ 20, possiamo realizzare una classe AutoThread
che si comporta esattamente come la classe std::jthread
. Vediamo come:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 |
|
Analizziamo il codice:
-
Riga 8. Il costruttore di default è dichiarato come
noexcept
edefault
per garantire che il costruttore non possa lanciare eccezioni e che sia generato automaticamente dal compilatore. In questo caso, l'oggetto membrot
viene inizializzato con il costruttore di default distd::thread
e, quindi,t
non possiede alcun thread. -
Riga 11. Il costruttore con template variadico è dichiarato come
explicit
per evitare conversioni implicite. Questo costruttore ci permette di creare un oggettoAutoThread
passando un Callable e i relativi argomenti. Questo costruttore crea direttamente il threadt
passando il Callable e gli argomenti. -
Riga 16. Il costruttore con passaggio di un oggetto
std::thread
è dichiarato comeexplicit
per evitare conversioni implicite. Questo costruttore ci permette di creare un oggettoAutoThread
passando un oggettostd::thread
. In questo caso, l'oggettot
acquisisce la proprietà del thread passato come argomento. -
Riga 20. Il costruttore per movimento è dichiarato come
noexcept
per garantire che l'operazione di movimento non possa lanciare eccezioni. In questo costruttore, acquisiamo la proprietà del thread passato come argomento. -
Riga 24. L'operatore di assegnamento per movimento è dichiarato come
noexcept
per garantire che l'operazione di movimento non possa lanciare eccezioni.Il codice di questo operatore è molto interessante. Per evitare il problema del dangling thread, prima di assegnare il nuovo thread, controlliamo se il thread corrente è joinable. Se il thread corrente è joinable, effettuiamo la
join()
. Questo ci garantisce che, nel momento in cui assegniamo un nuovo thread, il thread corrente venga effettivamente terminato. -
Riga 37. Il distruttore effettua la
join()
del thread. Questo ci garantisce che, nel momento in cui l'oggettoAutoThread
esce dallo scope, il thread venga effettivamente terminato. -
Righe 44-51. Il metodo
join()
effettua lajoin()
del thread, mentre il metodojoinable()
restituiscetrue
se il thread è joinable. -
Righe 54-55. Disabilitiamo il costruttore di copia e l'assegnamento di copia per evitare che l'oggetto
AutoThread
possa essere copiato.
Possiamo utilizzare la classe AutoThread
come segue:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
Contenitori di Thread
Un'altra, importantissima, applicazione del meccanismo di trasferimento della proprietà di un thread è la realizzazione di contenitori di thread. Questi contenitori possono essere utilizzati per gestire un insieme di thread in modo efficiente.
Ad esempio, potremmo voler realizzare un programma in cui abbiamo un insieme di thread che eseguono funzioni diverse. In questo caso, potremmo voler utilizzare un contenitore, come std::vector
, per gestire i thread. Il vantaggio è che i contenitori della libreria standard del C++, dalla versione 11 in poi, sono move-aware, cioè supportano la semantica di movimento.
Vediamo un esempio:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
In questo esempio, abbiamo un carico di lavoro che può essere suddiviso ed eseguito in parallelo. Questo carico di lavoro è rappresentato dalla funzione funzione_lavoro
che, in base ad un id
passato come argomento, esegue un lavoro specifico.
Il programma, nel main
, crea un vettore di thread threads
e una variabile num_thread
che rappresenta il numero di thread da eseguire in parallelo. Successivamente, il programma crea i thread e li inserisce nel vettore threads
. Da notare che abbiamo usato il metodo emplace_back
per creare direttamente il thread all'interno del vettore. Questo metodo crea un oggetto temporaneo e, attraverso la semantica di movimento, lo inserisce nel vettore.
Infine, il programma attende che tutti i thread terminino attraverso un ciclo for
che invoca la join()
su ciascun thread.
L'esempio visto sopra non differisce molto da ciò che si effettua nella pratica. Quando si realizza, infatti, un programma che sfrutta il parallelismo, si utilizza uno schema molto simile. Si suddivide, cioè, un algoritmo in più parti e si esegue ciascuna parte in un thread diverso. Le parti dell'algoritmo da eseguire in parallelo vengono determinate da un identificativo o da un indice. Questo schema è molto comune e viene utilizzato in moltissime applicazioni.
Le cose si complicano nel momento in cui i thread devono condividere dati. Analizzeremo questo aspetto nelle prossime lezioni.
In Sintesi
In questa lezione abbiamo approfondito il concetto di proprietà o ownership di un thread in C++ 11. Abbiamo visto che gli oggetti di tipo std::thread
non possono essere copiati ma possono essere soltanto trasferiti attraverso la funzione std::move
.
In sostanza, un oggetto di tipo std::thread
rappresenta la proprietà di un thread. Questa proprietà può essere trasferita da un oggetto ad un altro. L'oggetto che riceve la proprietà diventa il nuovo proprietario del thread, mentre l'oggetto che cede la proprietà diventa un oggetto vuoto e su di esso non è possibile invocare metodi che richiedono un thread in esecuzione.
Sfruttando la semantica di movimento, abbiamo visto come realizzare una classe sentinella che si occupa di acquisire la proprietà di un thread e di effettuare la join()
del thread alla fine del suo ciclo di vita. Questo ci permette di evitare il problema del dangling thread o thread orfano.
Infine, abbiamo visto come realizzare una classe AutoThread
che si comporta esattamente come la classe std::jthread
introdotta nello standard C++ 20. Questa classe effettua automaticamente la join()
nel distruttore.
Abbiamo anche visto come realizzare contenitori di thread per gestire un insieme di thread in modo efficiente.