Thread Cancel? No, grazie! – considerazioni sul perchè non usare la pthread_cancel(3) in C e C++ – pt.1

T

Thread Cancel? No, grazie! considerazioni sul perché non usare la pthread_cancel(3) in C e C++ – pt.1

Dutch: Non toccare quell'arma, non ti ha ucciso perché non eri armata...è uno sportivo.

Ok, ci risiamo: ho ottenuto uno spunto da fatti reali (cose di lavoro) e ho ridetto “Ma qui c’è materiale per un articolo!”, per cui oggi parleremo di pthread_cancel(3). Uhm, e questa volta c’è un film collegato? No, anche questo articolo appartiene alla serie dei serie dei “No Grazie!” (ah: ne ho scritti altri e vi invito a leggerli o rileggerli, qui, quiqui, qui e qui) e non farò giochi di parole con il titolo, e ne sceglierò uno a caso tra quelli che ho visto/rivisto ultimamente… si, Predator, va benissimo: è un gran film action di John McTiernan, un autore che ha smesso prematuramente di regalarci grandi opere per colpa di alcune “birichinate” commesse nella sua vita privata… peccato, era un grande. E se proprio vogliamo forzare una attinenza film/articolo possiamo dire: pthread_cancel(3) è una di quelle cose che funzionano ma è meglio starne alla larga, è pericolosa come cercare di cancellare un Predator…

Thread Cancel
…giuro che non userò mai più la pthread_cancel(3). Te lo giuro…

Non so se ricordate, ma nell’articolo System? No, grazie! ho evidenziato i mille problemi dell’uso della system(3), ma, a parziale scusante (per gli impavidi che la usano) ho anche detto: “system(3), è una funzione della libc usata e abusata: è molto comoda e semplice da usare…”. Ecco, l’argomento di questo articolo, la pthread_cancel(3), non ha neanche questa scusante: se proprio la si vuole usare bisogna usarla bene, e usarla bene è complicatissimo!

Ok, è ora di entrare nel dettaglio. Cosa è la pthread_cancel(3) e cosa permette di fare? Il manuale della glibc titola semplicemente questo:

pthread_cancel - send a cancellation request to a thread

Ecco, a quanto pare permette inviare una richiesta di cancellazione a un thread (un po’ come inviare un sigkill o un sigterm a un processo): sembra facile ma non lo è! E perché non è facile? Perché quando inviamo la richiesta non sappiamo cosa sta facendo il thread: potrebbe essere, ad esempio, in una zona critica eseguendo una attività che, se interrotta bruscamente, potrebbe corrompere la memoria o lasciare il multitreading in uno stato indefinito, ecc. ecc. Insomma: un giochetto da niente. E uno potrebbe dire “Ma sicuramente se ne occupa la libpthread di chiudere bene… si, si, sicuro: la libreria è dotata di una AI che decide cosa è meglio fare per qualunque codice scritto, nel passato, nel presente e nel futuro, non ti preoccupare…”

Ok, scendiamo con i piedi per terra: la povera libpthread non può occuparsi di chiudere bene un thread che può contenere qualsiasi cosa: per farlo dovrebbe fornire degli strumenti specifici, mentre, essendo una (gran) libreria generica, può solo fornire degli strumenti generici per farlo, e il programmatore deve usarli adeguatamente in base al codice che sta scrivendo. E quindi consiglio caldamente di leggere accuratamente il manuale della pthread_cancel(3) prima di usarla (e, magari, la lettura del manuale vi toglierà ogni residua voglia di usarla). E, già che ci sono, vi evidenzio i 2 punti chiave, estratti direttamente dal manuale:

1. tipo di cancellazione (supponendo che il “cancelability state” del thread sia “cancellabile”):
A thread's cancellation type, determined by thread_setcanceltype(3),
may be either asynchronous or deferred (the default for new threads). Asynchronous cancelability means that the thread can be canceled at any time (usually immediately, but the system does not guarantee this). Deferred cancelability means that cancellation will be delayed until the thread next calls a function that is a cancellation point. A list of functions that are or may be cancellation points is provided in pthreads(7).
2. esecuzione della cancellation request:
1. Cancellation clean-up handlers are popped (in the reverse of the order in which they were pushed) and called. (See pthread_cleanup_push(3).)
2. Thread-specific data destructors are called, in an unspecified or
der. (See pthread_key_create(3).)
3. The thread is terminated. (See pthread_exit(3).)

È evidente che bisogna avere ben chiare quali sono le zone critiche del thread, dove sono i cancellation-point, e altre cosucce… E vabbè, ma se proprio la voglio usare che faccio? Ok, è il momento dei consigli:

  1. In creazione del thread si dovrebbe usare: pthread_setcancelstate(3), pthread_setcanceltype(3) e pthread_create(3) (e magari anche pthread_key_create(3)).
  2. Durante la vita utile del thread usare: pthread_cleanup_push(3) e pthread_cleanup_pop(3).
  3. Se in creazione abbiamo usato pthread_key_create(3), durante la vita utile del thread si dovrebbe anche usare: pthread_getspecific(3) e pthread_setspecific(3).
  4. In cancellazione del thread usare: pthread_cancel(3) e pthread_join(3).
  5. E se dopo aver letto i punti da 1 a 4 volete ancora usare la pthread_cancel(3) prendetevi un momento di riflessione, fatevi una buona dormita, e al risveglio, magari, avrete le idee più chiare…

il quadro esposto sopra è quello minimale, e si può complicare a piacere, ad esempio mischiando sapientemente i punti 2 e 3. Ripeto però: è il quadro minimale, che prevede una buona inizializzazione (punto 1), un buon trattamento delle zone critiche (punti 2 o 3 o anche punti 2 e 3), e una buona gestione della chiusura dei thread (punto 4). Se si gioca al risparmio e si omette qualche passo il disastro è dietro l’angolo. E, se ancora non è chiaro, rispettare queste norme è abbastanza difficile, specialmente se i thread eseguono attività complesse. Ok, nessun problema, seguiamo le istruzioni et voilà!, codice perfetto! Ma…

MA, SPESSO, I PROGRAMMATORI SONO PIGRI

Ebbene si, ho visto molto codice scritto pigramente, e ve ne fornisco un esempio (molto semplificato, ovviamente). Vai col codice!

// threaderrcancel.c -esempio di cancel thread erronea
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

// prototipi globali
static void* myThread(void *arg);
static void  mySleep(unsigned int milliseconds);

// main() - funzione main()
int main(int argc, char* argv[])
{
    // crea il thread
    pthread_t tid;
    pthread_create(&tid, NULL, &myThread, NULL);

    // aspetto 2 secondi e poi cancello il thread
    mySleep(2000);
    pthread_cancel(tid);

    // join del thread
    pthread_join(tid, NULL);

    // esco
    printf(
    return 0;
}

// myThread() - thread routine
void* myThread(void *arg)
{
    // loop del thread
    printf("thread partito\n");
    for (;;) {
        // il thread fa cose...
        // ...

        // malloc sul buffer
        char *p = (char *)malloc(100);

        // simulo un ritardo perché il thread fa altre cose...
        mySleep(2);

        // free del buffer
        free(p);

        // sleep del thread (10 ms)
        mySleep(10);
    }

    // il thread esce
    printf("thread finito\n");
    pthread_exit(NULL);
}

// mySleep() - wrapper per nanosleep()
static void mySleep(unsigned int milliseconds)
{
    struct timespec ts;
    ts.tv_sec = milliseconds / 1000;
    ts.tv_nsec = (milliseconds
    nanosleep(&ts, NULL);
}

Ecco, questo è un programma multithread che usa la pthread_cancel() in modo pigro, “alla speraindio”  (nota a parte: ho usato anche il mio vecchio wrapper per nanosleep(): direi che è l’unica parte buona di questo programma). Se compilate ed eseguite con l’ottimo analizzatore dinamico Valgrind (per trovare eventuali memory leak) otterrete (quasi sempre) questo risultato estratto dal logfile del Valgrind:

==10064== HEAP SUMMARY:
==10064==     in use at exit: 100 bytes in 1 blocks
==10064==   total heap usage: 166 allocs, 165 frees, 18,906 bytes allocated
==10064== 100 bytes in 1 blocks are definitely lost in loss record 1 of 1
==10064==    at 0x483B7F3: malloc (in /usr/lib/x86_64-linux-gnu/valgrind/vgpreload_memcheck-amd64-linux.so)
==10064==    by 0x10930A: myThread (in /home/aldo/Blog/myblog/post2022/post119-xx082022/threaderrcancel)
==10064==    by 0x486A608: start_thread (pthread_create.c:477)
==10064==    by 0x49A4132: clone (clone.S:95)
==10064== LEAK SUMMARY:
==10064==    definitely lost: 100 bytes in 1 blocks

Cosa è successo? È successo che la brusca interruzione del thread non gli ha dato tempo di eseguire la free(3). Con un po’ di fortuna la cancellazione potrebbe arrivare dopo l’esecuzione della free(3), ma vi sembra serio scrivere un programma basato sulla fortuna? Ok, vediamo allora una versione abbastanza migliorata del programma precedente, con una funzione di cleanup per liberare la memoria in qualunque momento arrivi la cancellazione. Vai col codice!

// threadcancel.c -esempio di cancel thread
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>

// prototipi globali
static void* myThread(void *arg);
static void  cleanupHandler(void *arg);
static void  mySleep(unsigned int milliseconds);

// main() - funzione main()
int main(int argc, char* argv[])
{
    // crea il thread
    pthread_t tid;
    pthread_create(&tid, NULL, &myThread, NULL);

    // aspetto 2 secondi e poi cancello il thread
    mySleep(2000);
    pthread_cancel(tid);

    // join del thread
    pthread_join(tid, NULL);

    // esco
    printf(
    return 0;
}

// myThread() - thread routine
void* myThread(void *arg)
{
    // per usi particolari posso chiamare (nei punti opportuni) anche:
    //pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL);  // default
    //pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, NULL);
    //pthread_setcanceltype(PTHREAD_CANCEL_DEFERRED, NULL); // default
    //pthread_setcanceltype(PTHREAD_CANCEL_ASYNCHRONOUS, NULL);

    // loop del thread
    printf("thread partito\n");
    for (;;) {
        // il thread fa cose...
        // ...

        // malloc sul buffer  e pop cleanup di cancellazione
        char *p = (char *)malloc(100);
        pthread_cleanup_push(cleanupHandler, p);

        // simulo un ritardo perchè il thread fa altre cose...
        mySleep(2);

        // free del buffer e pop cleanup di cancellazione
        free(p);
        pthread_cleanup_pop(0);

        // sleep del thread (10 ms)
        mySleep(10);
    }

    // il thread esce
    printf("thread finito\n");
    pthread_exit(NULL);
}

// cleanupHandler() - funzione di cleanup di cancellazione
static void cleanupHandler(void *arg)
{
    printf("Called clean-up handler\n");
    free(arg);
}

// mySleep() - wrapper per nanosleep()
static void mySleep(unsigned int milliseconds)
{
    struct timespec ts;
    ts.tv_sec = milliseconds / 1000;
    ts.tv_nsec = (milliseconds
    nanosleep(&ts, NULL);
}

Ecco, questo va abbastanza meglio, e il Valgrind ci mostra (sempre) questo:

==10086== HEAP SUMMARY:
==10086==     in use at exit: 0 bytes in 0 blocks
==10086==   total heap usage: 168 allocs, 168 frees, 19,106 bytes allocated
==10086== All heap blocks were freed -- no leaks are possible

Ok, di nuovo sembra facile… ma provate a scrivere varie funzioni di cleanup (mantenendole con pthread_cleanup_push(3) e pthread_cleanup_pop(3)) per un thread che esegue molte attività complesse e che chiama funzioni esterne che chiamano altre funzioni esterne che chiamano… Insomma, è molto complicato, e lo sarebbe ancora di più usando la gestione delle thread key con pthread_key_create(3) (gestione che, tra l’altro, è la più indicata per liberare la memoria, visto che, in realtà, le funzioni pthread di push e pop sono più adatte per gestire il reset di stati, lock, ecc.).

E quale è la conclusione allora? Semplicissimo; pthread_cancel(3) non si usa! Perché è difficilissimo usarla bene e, anche mettendocela tutta, potrebbe sfuggirci qualche dettaglio che può causare dei veri disastri a run-time. La libpthread fornisce questa funzione perché è una libreria molto completa che cerca di coprire tutti gli aspetti del multithreading, ma la fornisce sottintendendo “Usatela a vostro rischio e pericolo…”.

E concludo con una domanda: “Ma secondo voi, perché i nuovi supporti built-in ai thread in C11 (threads) e C++11 (std::thread), pur essendo spesso basati sulla onnipresente libpthread (che da sotto esegue in maniera trasparente il lavoro sporco) non implementano una funzione di cancel? Sarà mica perché fa solo danni?”. E con questo spunto di riflessione vi saluto e vi aspetto per la seconda parte dell’articolo, dove vedremo la maniera migliore di fermare un thread (spoiler: è molto più semplice di quello che ci si può aspettare!).

Ciao, e al prossimo post!

A proposito di me

Aldo Abate

È un Cinefilo prestato alla Programmazione in C. Per mancanza di tempo ha rinunciato ad aprire un CineBlog (che richiede una attenzione quasi quotidiana), quindi ha deciso di dedicarsi a quello che gli riesce meglio con il poco tempo a disposizione: scrivere articoli sul suo amato C infarcendoli di citazioni Cinematografiche. Il risultato finale è ambiguo e spiazzante, ma lui conta sul fatto che il (si spera) buon contenuto tecnico induca gli appassionati di C a leggere ugualmente gli articoli ignorando la parte Cinefila.
Ma in realtà il suo obiettivo principale è che almeno un lettore su cento scopra il Cinema grazie al C...

Di Aldo Abate

Gli articoli più letti

Articoli recenti

Commenti recenti