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
void prima_funzione() {
    /* Codice */
}

void seconda_funzione() {
    /* Codice */
}

/* Creiamo il thread T1 */
std::thread T1(prima_funzione);

/*
 * Creiamo il thread T2
 * e trasferiamo la proprietà di T1 a T2
 */
std::thread T2 = std::move(T1);

/* Riutilizziamo T1 per un nuovo thread */
T1 = std::thread(seconda_funzione);

/*
 * Creiamo il thread T3
 * e trasferiamo la proprietà di T2 a T3
 */
std::thread T3 = std::move(T2);

/* Trasferiamo la proprietà di T3 a T1 */
/* ATTENZIONE: Facendo ciò, il programma termina! */
T1 = std::move(T3);

Questo esempio è molto esplicativo perché ci permette di chiarire molti concetti:

  1. Per prima cosa, nella riga 10 creiamo un thread che esegue la funzione prima_funzione. Questo thread è di proprietà dell'oggetto T1. Possiamo ragionare da un altro punto di vista. Abbiamo creato un thread, a cui daremo il nome di t_1, che esegue la funzione prima_funzione e che appartiene all'oggetto T1:

    T1 \rightarrow t_1(\texttt{prima_funzione})
  2. Nella riga 16, trasferiamo la proprietà di t_1 da T1 a T2. Questo significa che T2 diventa il nuovo proprietario di t_1:

    T2 \rightarrow t_1(\texttt{prima_funzione})
    T1 \rightarrow \varnothing

    Da notare che l'oggetto T1 non possiede più il thread t_1, 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.

  3. Nella riga 19, creiamo un nuovo thread che esegue la funzione seconda_funzione e lo assegniamo a T1. Questo significa che T1 diventa il proprietario di un nuovo thread, a cui daremo il nome di t_2. Quindi, dopo la riga 19, la situazione diventa:

    T1 \rightarrow t_2(\texttt{seconda_funzione})
    T2 \rightarrow t_1(\texttt{prima_funzione})

    Come si può vedere, abbiamo riutilizzato l'oggetto T1 per creare un nuovo thread. Quindi, un oggetto di tipo std::thread può essere riutilizzato.

  4. Nella riga 25, trasferiamo la proprietà di t_1 da T2 a T3. Questo significa che T3 diventa il nuovo proprietario di t_1:

    T3 \rightarrow t_1(\texttt{prima_funzione})
    T2 \rightarrow \varnothing
    T1 \rightarrow t_2(\texttt{seconda_funzione})
  5. 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 t_1 da T3 a T1. Questo significa che T1 diventa il nuovo proprietario di t_1. Tuttavia c'è un problema: T1 è già il proprietario di t_2. La situazione diventa, quindi, la seguente:

    T1 \rightarrow t_1(\texttt{prima_funzione})
    T2 \rightarrow \varnothing
    T3 \rightarrow \varnothing
    \varnothing \rightarrow t_2(\texttt{seconda_funzione})

    Il problema è che il thread t_2 è 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 la join(). Questo problema prende il nome di dangling thread o thread orfano.

Ricapitolando:

Definizione

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 funzione std::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.
Nota

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
void mia_funzione() {
    /* Codice */
}

std::thread crea_thread() {
    std::thread t(mia_funzione);
    return t;
}

int main() {
    std::thread t = crea_thread();

    /* Codice */

    t.join();
    return 0;
}

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
void mia_funzione() {
    /* Codice */
}

void esegui_thread(std::thread t) {
    /* Codice */

    t.join();
}

int main() {
    std::thread t(mia_funzione);
    esegui_thread(std::move(t));

    return 0;
}

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:

  1. Acquisisce la proprietà di un thread;
  2. 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
class SentinellaThread {
public:

    /* Nel costruttore acquisiamo la proprietà del thread */
    explicit SentinellaThread(std::thread t) :
            t(std::move(t)) {}

    /* Nel distruttore effettuiamo la join() del thread */
    ~SentinellaThread() {
        if (t.joinable()) {
            t.join();
        }
    }

    /* Disabilitiamo il costruttore di copia e l'assegnamento di copia */
    SentinellaThread(const SentinellaThread&) = delete;
    SentinellaThread& operator=(const SentinellaThread&) = delete;

private:
    std::thread t;
};

Analizziamo il codice:

  1. 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 classe SentinellaThread possa essere costruita soltanto con un oggetto di tipo std::thread.

    All'interno del costruttore trasferiamo la proprietà del thread passato come argomento all'oggetto membro privato t attraverso la funzione std::move.

  2. Nel distruttore, effettuiamo la join() del thread;

    Nel distruttore, controlliamo se il thread è joinable attraverso il metodo joinable(). Se il thread è joinable, effettuiamo la join().

    Questo ci assicura che, nel momento in cui l'oggetto SentinellaThread esce dallo scope, il thread venga effettivamente terminato.

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

  4. 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 classe std::thread implementa già un costruttore di move e un operatore di assegnamento di move. Quindi, implicitamente, quando eseguiamo la std::move su di un oggetto di tipo SentinellaThread, stiamo effettuando la std::move sull'oggetto membro t.

Possiamo utilizzare la classe SentinellaThread come segue:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
void mia_funzione() {
    /* Codice */
}

int main() {
    std::thread t(mia_funzione);

    {
        SentinellaThread sentinella(std::move(t));
    }

    /* Da questo punto in poi siamo sicuri che il thread sia terminato */

    return 0;
}

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
class AutoThread {
private:
    std::thread t;

public:

    /* Costruttore di default */
    AutoThread() noexcept = default;

    /* Costruttore con template variadico */
    template<typename Callable, typename... Args>
    explicit AutoThread(Callable&& f, Args&&... args) :
            t(std::forward<Callable>(f), std::forward<Args>(args)...) {}

    /* Costruttore con passaggio di un oggetto std::thread */
    explicit AutoThread(std::thread t) :
            t(std::move(t)) {}

    /* Costruttore per movimento */
    AutoThread(AutoThread&& other) noexcept :
            t(std::move(other.t)) {}

    /* Operatore di assegnamento per movimento */
    AutoThread& operator=(AutoThread&& other) noexcept {

        /* Dobbiamo effettuare la join() del thread precedente */
        if (joinable()) {
            join();
        }

        /* Assegnamento per movimento del nuovo thread */
        t = std::move(other.t);
        return *this;
    }

    /* Distruttore */
    ~AutoThread() {
        if (joinable()) {
            join();
        }
    }

    /* Metodo join() */
    void join() {
        t.join();
    }

    /* Metodo joinable() */
    bool joinable() const noexcept {
        return t.joinable();
    }

    /* Disabilitiamo il costruttore di copia e l'assegnamento di copia */
    AutoThread(const AutoThread&) = delete;
    AutoThread& operator=(const AutoThread&) = delete;

    /* Altri metodi ... */

};

Analizziamo il codice:

  1. Riga 8. Il costruttore di default è dichiarato come noexcept e default per garantire che il costruttore non possa lanciare eccezioni e che sia generato automaticamente dal compilatore. In questo caso, l'oggetto membro t viene inizializzato con il costruttore di default di std::thread e, quindi, t non possiede alcun thread.

  2. Riga 11. Il costruttore con template variadico è dichiarato come explicit per evitare conversioni implicite. Questo costruttore ci permette di creare un oggetto AutoThread passando un Callable e i relativi argomenti. Questo costruttore crea direttamente il thread t passando il Callable e gli argomenti.

  3. Riga 16. Il costruttore con passaggio di un oggetto std::thread è dichiarato come explicit per evitare conversioni implicite. Questo costruttore ci permette di creare un oggetto AutoThread passando un oggetto std::thread. In questo caso, l'oggetto t acquisisce la proprietà del thread passato come argomento.

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

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

  6. Riga 37. Il distruttore effettua la join() del thread. Questo ci garantisce che, nel momento in cui l'oggetto AutoThread esce dallo scope, il thread venga effettivamente terminato.

  7. Righe 44-51. Il metodo join() effettua la join() del thread, mentre il metodo joinable() restituisce true se il thread è joinable.

  8. 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
void mia_funzione() {
    /* Codice */
}

int main() {
    AutoThread t(mia_funzione);

    /* ... */

    /*
     * Arrivati al termine dello scope
     * sul thread t verrà effettuata la join()
     * automaticamente
     */
    return 0;
}

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
void funzione_lavoro(int id) {
    /* Esegue un lavoro in base all'id passato */
}

int main() {
    /* Numero di thread da eseguire in parallelo */
    int num_thread = 10;

    /* Creiamo un vettore di thread */
    std::vector<std::thread> threads;

    /* Creiamo i thread */
    for (int i = 0; i < num_thread; ++i) {
        threads.emplace_back(funzione_lavoro, i);
    }

    /* Attendiamo che tutti i thread terminino */
    for (auto& t : threads) {
        t.join();
    }

    return 0;
}

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.