CodeGym /Corsi /C++ SELF /std::jthread e std::stop_token

std::jthread e std::stop_token

C++ SELF
Livello 68, Lezione 3
Disponibile

1. Perché serve std::jthread

Quando si scrive codice multithread per la prima volta, spesso sembra innocuo: «avvio un thread, farà qualcosa e poi vedrò». Il problema è che std::thread è molto severo: se l'oggetto thread viene distrutto mentre il thread è ancora in esecuzione, la standard library preferisce non indovinare cosa volevate fare e terminare il processo con uno scenario di errore. Da un punto di vista del linguaggio questo è più onesto che «a volte funziona».

std::jthread è nato come variante più amichevole per i principianti: prova a rendere lo scenario sicuro comportamento di default. L'idea è semplice: se un thread è una risorsa, gestiamola come una risorsa in stile RAII (come facciamo con file, contenitori, smart pointer): crei l'oggetto — la risorsa è acquisita, esci dallo scope — la risorsa è rilasciata.

std::thread vs std::jthread: thread in stile RAII

Se guardiamo a std::thread e std::jthread come a modalità “manuale” e “automatica”, con std::thread siete voi che dovete ricordarvi di spegnere la luce, chiudere la porta e dare da mangiare al gatto (cioè chiamare join() o deliberatamente detach()). Con std::jthread il gatto è sempre vostro, ma almeno la porta si chiuderà da sola: alla distruzione dell'oggetto std::jthread il thread verrà terminato correttamente (tramite join()), e inoltre verrà inviato una richiesta di arresto (di questo parleremo con stop_token).

Riassumiamo in una tabella — così è più facile ricordare a colpo d'occhio «cosa scegliere».

Proprietà
std::thread
std::jthread
Cosa succede se l'oggetto viene distrutto mentre il thread è attivo terminazione di processo d'emergenza (sgradita sorpresa) attesa corretta della terminazione (senza sorpresa, ma può «attendere a lungo»)
Bisogna chiamare manualmente join()? sì, praticamente sempre opzionale, ma spesso non necessario
Supporto per la «cancellazione gentile» manuale, con propri meccanismi integrato tramite stop_token
Copia non copiabile (move-only) non copiabile (move-only)
Idea principale «sei grande, te ne occupi tu» «facciamo sicuro per default»

Piccolo fatto tecnico (un po' pedante, ma utile): anche nei documenti dello standard si discutono le sottigliezze dei requisiti sugli argomenti dei costruttori thread/jthread (per esempio che i parametri devono essere move-constructible). È un buon indicatore del fatto che il modello di ownership e di move per i thread non è una «dettaglio di implementazione», ma parte del design.

2. Avviare std::jthread: terminazione sicura all'uscita dallo scope

Il primo contatto con std::jthread spesso fa pensare: «Ma è quasi uguale a std::thread». Ed è vero: si passa la stessa funzione/lamdba e gli argomenti. Ma la differenza chiave non si vede all'avvio, bensì al momento dell'uscita dallo scope.

L'esempio più corto: il thread vive quanto la variabile


#include <iostream>
#include <thread>

int main() {
    {
        std::jthread t([] {
            std::cout << "worker: ciao\n"; // worker: ciao
        });
        std::cout << "main: inside scope\n"; // main: inside scope
    } // t viene distrutto -> thread garantito terminato

    std::cout << "main: after scope\n"; // main: after scope
}

Nota: l'ordine delle righe "worker" e "main" dentro lo scope può comunque cambiare (il nondeterminismo non scompare). Ma dopo l'uscita dallo scope non otterrete più «il thread è ancora in esecuzione e noi siamo andati via».

join() esiste ancora ed è talvolta utile

RAII non vieta di dire esplicitamente «aspetta qui la terminazione». A volte è utile perché rende il codice più leggibile: si vede chiaramente il punto dove si attende.

#include <iostream>
#include <thread>

int main() {
    std::jthread t([] {
        std::cout << "worker: done\n"; // worker: done
    });

    t.join(); // punto esplicito di attesa
    std::cout << "main: continue\n"; // main: continue
}

4. std::stop_token: arresto cooperativo del thread

Se la parte RAII di std::jthread risolve il problema «ho dimenticato di terminare il thread», non risolve un altro problema comune: «il mio thread è in realtà infinito (o molto lungo) e ho bisogno di poterlo chiedere di fermarsi».

È importante la parola «chiedere». Nel C++ standard non esiste un pulsante «uccidi il thread» in modo sicuro e corretto a livello di linguaggio. E non è perché i progettisti non ci hanno pensato. L'arresto forzato rompe quasi sicuramente gli invarianti: il thread può tenere una risorsa, essere in mezzo a un'operazione, scrivere dati, e l'«uccisione» lascerebbe il programma in uno stato a mezzo.

std::stop_token introduce la cancellazione cooperativa:

  • qualcuno esterno invia la richiesta di stop,
  • il thread interno periodicamente controlla «mi hanno chiesto di terminare?»,
  • e esce in un punto sicuro.

Mini-schema del protocollo di arresto

flowchart TD
    A[main: creiamo jthread] --> B[worker: ciclo di lavoro]
    B --> C{stop_requested?}
    C -- no --> B
    C -- si --> D[worker: esce con cura]
    A --> E["main: request_stop()"]
    E --> C

Il senso dello schema è che l'arresto è un protocollo, non una «magica istruzione».

5. Come std::jthread passa lo stop_token alla funzione del thread

La parte più pratica: std::jthread può passare automaticamente std::stop_token alla vostra funzione del thread, se questa sa riceverlo.

C'è un dettaglio importante, facile da perdere per i principianti: il token viene passato solo se il callable (funzione/lamdba) ha un parametro std::stop_token (di solito come primo parametro). Se il parametro non è presente — jthread avvierà la funzione normalmente, ma «chiedere di fermarsi» non avrà senso: il thread non controllerà la richiesta.

Thread che sa fermarsi

#include <iostream>
#include <stop_token>
#include <thread>

void worker(std::stop_token st) {
    while (!st.stop_requested()) {
        // simuliamo "lavoro"
        std::cout << "tick\n"; // tick
        std::this_thread::sleep_for(std::chrono::milliseconds{50});
    }
    std::cout << "worker: stop osservato\n"; // worker: stop osservato
}

E l'utilizzo:

#include <chrono>
#include <thread>

int main() {
    std::jthread t(worker);

    std::this_thread::sleep_for(std::chrono::milliseconds{130});
    t.request_stop(); // richiesto di fermarsi

    // non è necessario chiamare join: alla fine di main RAII completa il lavoro
}

Thread che non sa fermarsi, e perché è pericoloso

Immaginate un ciclo infinito senza controllo del token. Allora jthread alla distruzione farà «per protocollo» request_stop() + join(), ma join() aspetterà all'infinito, perché il thread non uscirà mai. Questa è una trappola tipica: «ho messo jthread, quindi è tutto sicuro». No: jthread è più sicuro, ma non è una bacchetta magica.

6. Pratica: indicatore di lavoro in background in un'app console

Per non restare al livello dei concetti astratti «tick», intrecciamo l'argomento in una parte realistica di un'app console che abbiamo sviluppato prima (utility CLI con comandi, log ecc.). Compito frequente: c'è un comando che impiega «molto» (rete, file, calcoli), e vogliamo mostrare all'utente che il programma è vivo.

Importante: non usiamo delay come sincronizzazione. sleep_for qui sotto è solo simulazione di lavoro lungo e un modo per rendere gli esempi osservabili.

«Spinner» in background che si può fermare

#include <chrono>
#include <iostream>
#include <stop_token>
#include <thread>

void spinner(std::stop_token st) {
    const char frames[] = {'|', '/', '-', '\\'};
    std::size_t i = 0;

    while (!st.stop_requested()) {
        std::cout << "\rworking... " << frames[i % 4] << std::flush;
        ++i;
        std::this_thread::sleep_for(std::chrono::milliseconds{80});
    }

    std::cout << "\rworking... done\n"; // working... done
}

Qui ci sono due piccoli «trucchetti da principiante». Primo, la sequenza "\r" riporta il carriage all'inizio della riga, e ridisegniamo la stessa riga (per non spammare la console con centinaia di righe). Secondo, std::flush forza l'output a comparire subito sullo schermo.

«Operazione lunga» nella nostra applicazione

#include <chrono>
#include <thread>

void simulate_long_task() {
    std::this_thread::sleep_for(std::chrono::milliseconds{600});
}

Mettiamo insieme: avviamo lo spinner come jthread, eseguiamo il lavoro, richiediamo lo stop

#include <thread>

int main() {
    std::jthread ui(spinner);

    simulate_long_task();

    ui.request_stop(); // richiesto allo spinner di terminare
}

Notate la bellezza (e l'insidia) della soluzione. Da un lato il codice è breve e pulito: lo spinner vive «accanto» all'operazione. Dall'altro, funziona solo perché lo spinner è scritto correttamente: controlla regolarmente stop_requested().

Perché è proprio una «terminazione controllata»

In questa lezione vogliamo fissare il significato principale di jthread + stop_token: rendiamo la terminazione del thread controllata su due assi.

La prima asse — gestione del lifetime tramite RAII. L'oggetto std::jthread vive in uno scope concreto, e questo diventa un confine visibile dell'attività in background. È utile non solo al computer, ma anche alla persona: leggi il codice e vedi «qui inizia il background e qui finisce».

La seconda asse — controllo dell'arresto tramite protocollo. Invece di «il thread morirà da solo prima o poi» abbiamo un accordo chiaro: il codice esterno può chiamare request_stop(), e il codice interno deve periodicamente controllare il token e terminare in un punto sicuro.

E va detto un limite: stop_token risolve solo la questione dell'arresto. Non risolve «come condividere dati in modo sicuro tra thread». Se cominciate a leggere/scrivere la stessa variabile senza regole, è un altro tipo di problema, e qui evitiamo di approfondire per non trasformare la lezione in «due parole su mutex, due parole su atomics, e poi tutti piangono».

7. Errori tipici usando std::jthread e std::stop_token

Errore №1: pensare che std::jthread «fermi da solo» qualunque thread.
jthread fa davvero per voi una parte importante del lavoro all'uscita dallo scope, ma non legge la vostra mente. Se la funzione del thread è un ciclo infinito senza controlli del token, jthread aspetterà onestamente la sua terminazione. Nel migliore dei casi otterrete un «blocco all'uscita», nel peggiore impiegherete molto tempo a capire perché il programma non si chiude.

Errore №2: chiamare request_stop() e aspettarsi la terminazione istantanea.
request_stop() non è «uccidi il thread». È una richiesta. Il thread la vedrà solo quando arriverà al controllo stop_requested(). Se il controllo avviene ogni 10 secondi, la «reazione allo stop» impiegherà fino a 10 secondi. Questo è normale: scegliete voi la frequenza dei controlli e quindi il bilanciamento tra reattività e semplicità del codice.

Errore №3: usare sleep_for come «sincronizzazione».
A volte si tenta: «dormo 100 ms e il thread avrà sicuramente aggiornato la variabile». È una strada scivolosa: su un altro computer, sotto carico diverso o in un diverso momento dello scheduler tutto può rompersi. sleep_for va bene per simulazioni e dimostrazioni, ma non definisce regole di accesso ai dati e non rende il programma corretto.

Errore №4: avviare un thread che usa riferimenti a variabili locali senza pensare al lifetime.
Anche con jthread si può scrivere codice pericoloso se il thread tiene un riferimento a un oggetto che sparisce prima della fine del thread. In questa lezione abbiamo volutamente scritto esempi in cui il thread vive «nello stesso scope» o usa dati che esistono abbastanza a lungo. Appena uscite da questi confini, fermatevi e chiedetevi onestamente: «Chi possiede i dati e chi garantisce che sono ancora vivi?»

Errore №5: trasformare stop_token in uno «strumento universale per la concorrenza».
stop_token risolve un solo compito: la cancellazione cooperativa. Non rende l'output della console non mischiabile, non protegge i contenitori dall'accesso concorrente e non sostituisce la progettazione dell'architettura. Tenendo questo a mente, jthread + stop_token diventano uno strumento eccellente, semplice e sicuro per «attività in background con confini di vita».

1
Compito
C++ SELF, livello 68, lezione 3
Bloccato
Attesa automatica del thread
Attesa automatica del thread
1
Compito
C++ SELF, livello 68, lezione 3
Bloccato
Contatore cooperativo
Contatore cooperativo
1
Compito
C++ SELF, livello 68, lezione 3
Bloccato
Spinner operativo
Spinner operativo
1
Compito
C++ SELF, livello 68, lezione 3
Bloccato
Uscita anticipata
Uscita anticipata
Commenti
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION