Vai al contenuto

std::thread, std::jthread e gestione dei thread

Impara a creare e gestire i thread con std::thread e con std::jthread del C++20. Comprendi la differenza tra join e detach, la cancellazione cooperativa con gli stop token, la memorizzazione thread-local e la gestione delle eccezioni attraverso i confini tra thread.

Perché la concorrenza?

Le CPU moderne hanno più core e, per sfruttarli, il tuo programma deve eseguire il lavoro in modo concorrente. Il C++11 ha introdotto una libreria di threading portabile in , e il C++20 l'ha migliorata con std::jthread. Un thread è un percorso di esecuzione indipendente che condivide lo stesso spazio di indirizzamento del thread che l'ha creato — ed è proprio qui che stanno la sua forza e il suo pericolo.

A differenza dei processi, i thread condividono la memoria. Questo rende veloce la comunicazione, ma introduce anche i data race se due thread leggono e scrivono la stessa variabile senza sincronizzazione. Prima di affrontare la sincronizzazione (nella prossima lezione), impariamo a creare, gestire e arrestare in modo pulito i thread.

Creare thread con std::thread

Crei un std::thread passandogli un callable (funzione, lambda, functor) e gli eventuali argomenti. Il thread inizia a eseguire immediatamente alla costruzione.

create_thread.cpp
#include <iostream>
#include <thread>

void greet(const std::string& name, int times) {
    for (int i = 0; i < times; ++i) {
        std::cout << "Hello, " << name << "! (" << i + 1 << ")\n";
    }
}

int main() {
    // Launch a thread running greet("Alice", 3)
    std::thread t1(greet, "Alice", 3);

    // Lambda-based thread
    std::thread t2([]() {
        std::cout << "Running in a lambda thread\n";
    });

    // MUST join or detach before t1/t2 are destroyed
    t1.join();  // block until t1 finishes
    t2.join();

    std::cout << "All threads finished\n";
    return 0;
}

join() vs detach()

Ogni std::thread che rappresenta un thread in esecuzione deve essere sottoposto a join oppure a detach prima che venga eseguito il suo distruttore. Se te ne dimentichi, viene chiamata std::terminate() e il tuo programma va in crash.

join() blocca il thread chiamante finché il thread di destinazione non termina. È l'impostazione predefinita sicura — sai che il thread ha finito e che i suoi risultati sono pronti.

detach() recide il legame tra l'oggetto std::thread e il thread del sistema operativo. Il thread continua a girare in background (un "thread daemon") e non puoi più fargli join. Fai attenzione: se il thread staccato accede a variabili sullo stack della funzione che l'ha generato e quella funzione ritorna, ottieni comportamento indefinito.

Insidia

Non chiamare mai detach() su un thread che cattura o referenzia variabili locali dello scope che l'ha creato. Quando la funzione creatrice ritorna, quelle variabili locali vengono distrutte e il thread staccato accede a memoria pendente. Questo è comportamento indefinito e spesso si manifesta come crash sporadici. Se devi proprio fare detach, passa i dati per valore oppure usa std::shared_ptr per estenderne il tempo di vita.

std::jthread — il thread RAII (C++20)

std::jthread risolve due problemi di std::thread: (1) esegue automaticamente il join nel suo distruttore, quindi non puoi dimenticartene, e (2) supporta la cancellazione cooperativa tramite std::stop_token.

jthread_demo.cpp
#include <iostream>
#include <thread>
#include <chrono>

void worker(std::stop_token stoken, int id) {
    while (!stoken.stop_requested()) {
        std::cout << "Worker " << id << " running...\n";
        std::this_thread::sleep_for(std::chrono::milliseconds(200));
    }
    std::cout << "Worker " << id << " stopping gracefully\n";
}

int main() {
    std::jthread jt1(worker, 1);  // stop_token is auto-passed as first arg
    std::jthread jt2(worker, 2);

    std::this_thread::sleep_for(std::chrono::seconds(1));

    // Request both threads to stop
    jt1.request_stop();
    jt2.request_stop();

    // No need to call join() — jthread's destructor does it
    std::cout << "Main thread exiting\n";
    return 0;
}

Interrogare l'hardware e memorizzazione thread-local

std::thread::hardware_concurrency() restituisce il numero di thread concorrenti supportati dall'hardware. La durata di memorizzazione thread_local dà a ogni thread la propria copia di una variabile.

hardware_tls.cpp
#include <iostream>
#include <thread>
#include <vector>

thread_local int tls_counter = 0;  // each thread gets its own copy

void count_work(int id) {
    for (int i = 0; i < 1000; ++i) {
        ++tls_counter;  // no data race — each thread has its own
    }
    std::cout << "Thread " << id << ": tls_counter = " << tls_counter << "\n";
}

int main() {
    unsigned int n = std::thread::hardware_concurrency();
    std::cout << "Hardware concurrency: " << n << " threads\n";

    std::vector<std::thread> threads;
    for (unsigned int i = 0; i < n; ++i) {
        threads.emplace_back(count_work, i);
    }
    for (auto& t : threads) {
        t.join();
    }
    // Each thread printed 1000 — they don't share tls_counter
    return 0;
}
Buona pratica

In C++20 e versioni successive, preferisci std::jthread a std::thread. Esegue automaticamente il join nel suo distruttore, eliminando il rischio che venga chiamata std::terminate se ti dimentichi di fare join. Il suo meccanismo integrato std::stop_token offre un modo pulito e standardizzato per richiedere la cancellazione cooperativa — non serve costruirti a mano un flag atomic shouldStop.

Gestione delle eccezioni attraverso i thread

Un'eccezione lanciata dentro un thread e non catturata all'interno di quel thread provoca la chiamata a std::terminate(). Le eccezioni non si propagano automaticamente al thread che esegue il join.

Per trasferire le eccezioni tra thread, catturale con std::current_exception() all'interno del thread worker e conservale in uno std::exception_ptr. Il thread che fa join può poi rilanciarle con std::rethrow_exception(). Il meccanismo di std::future (trattato nella Lezione 4) fa tutto questo automaticamente.

Concetti chiave
  • std::thread inizia a eseguire immediatamente; devi chiamare join() o detach() prima che venga eseguito il suo distruttore
  • std::jthread (C++20) esegue il join automaticamente nel distruttore e supporta la cancellazione cooperativa tramite std::stop_token
  • thread_local dà a ogni thread la propria copia indipendente di una variabile — senza bisogno di sincronizzazione
  • std::thread::hardware_concurrency() ti dice quanti thread l'hardware può eseguire in parallelo
  • Le eccezioni in un thread non si propagano al chiamante — usa std::exception_ptr o std::future per trasferirle
  • Non lasciare mai che un thread staccato referenzi variabili locali dello scope che l'ha generato

Quiz — Metti alla prova le tue conoscenze

(15 XP)

1. Cosa succede se un oggetto `std::thread` che rappresenta un thread joinable viene distrutto senza chiamare `join()` o `detach()`?

2. In che modo `std::jthread` supporta la cancellazione cooperativa?

3. Cosa fa la parola chiave `thread_local`?