Nei titoli e nei testi troverete qualche rimando cinematografico (ebbene si, sono un cinefilo). Se non vi interessano fate finta di non vederli, già che non sono fondamentali per la comprensione dei post...

Di questo blog ho mandato avanti, fino a Settembre 2018, anche una versione in Spagnolo. Potete trovarla su El arte de la programación en C. Buona lettura.

lunedì 29 agosto 2022

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

...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
pthread_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
order. (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("%s: esco\n", argv[0]);
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 % 1000) * 1000000;
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("%s: esco\n", argv[0]);
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 % 1000) * 1000000;
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!