Sleep? No, grazie!
considerazioni sul perché non usare la sleep in C e C++ – pt.2
soldato Hudson: Ehi Vasquez, ti hanno mai scambiato per un uomo? soldato Vasquez: No, e a te?
Ok, ci siamo. Dopo l’introduzione dell’ultimo articolo (lo avete appena riletto, vero?) che illustrava i (pochi) casi in cui è giustificato usare la sleep è giunto il momento di mostrare come sincronizzare una applicazione multithread in maniera efficace e intelligente. Ma mi raccomando: quando parlate con i vostri colleghi programmatori di un argomento spinoso come la sleep, cercate di essere meno sarcastici di mio cuggino, che di solito usa espressioni cattive e sferzanti come quella detta dal soldato Vasquez al soldato Hudson (che se l’è cercata, bisogna ammetterlo…).
Allora, vediamo un semplice esempio di programma multithread elementare, con due thread che fanno cose su dei dati condivisi. Questa versione base usa dei mutex per gli accessi condivisi (fin qui tutto ok) e, ahimè, usa una sleep in ogni thread per tentare di sincronizzare il lavoro senza massacrare la CPU (perché, senza le sleep, questa applicazione si mangerebbe il 100% della CPU). Vai col codice!
#include <stdio.h> #include <string.h> #include <unistd.h> #include <stdbool.h> #include <pthread.h> // creo un nuovo tipo per passare dei dati ai threads typedef struct _tdata { pthread_mutex_t mutex; // mutex comune ai threads bool stop; // flag per stop thread unsigned long comdata; // dato comune ai threads } tdata; // prototipi locali void* faiCose1(void *arg); void* faiCose2(void *arg); // funzione main() int main(int argc, char* argv[]) { pthread_t tid[2]; tdata data; // init mutex int error; if ((error = pthread_mutex_init(&data.mutex, NULL)) != 0) { printf("%s: non posso creare il mutex (%s)\n", argv[0], strerror(error)); return 1; } // init altri dati comuni ai threads data.stop = false; data.comdata = 0; // crea il thread 1 if ((error = pthread_create(&tid[0], NULL, &faiCose1, (void *)&data)) != 0) printf("%s: non posso creare il thread 0 (%s)\n", argv[0], strerror(error)); // crea il thread 2 if ((error = pthread_create(&tid[1], NULL, &faiCose2, (void *)&data)) != 0) printf("%s: non posso creare il thread 1 (%s)\n", argv[0], strerror(error)); // dopo 10 secondi ferma i thread sleep(10); data.stop = true; // join threads e cancella mutex pthread_join(tid[0], NULL); pthread_join(tid[1], NULL); pthread_mutex_destroy(&data.mutex); // exit printf("%s: thread terminati: comdata=%ld\n", argv[0], data.comdata); return 0; } // faiCose1() - thread routine void *faiCose1(void *arg) { // ottengo i dati del thread con un cast (tdata *) di (void *) arg tdata *data = (tdata *)arg; // thread loop printf("thread %ld partito\n", pthread_self()); unsigned long i = 0; for (;;) { // lock mutex pthread_mutex_lock(&data->mutex); // fa cose... data->comdata++; i++; // unlock mutex pthread_mutex_unlock(&data->mutex); // test stop flag if (data->stop) { // il thread esce printf("thread %ld terminato dal main (i=%ld comdata=%ld))\n", pthread_self(), i, data->comdata); pthread_exit(NULL); } // thread sleep (uso usleep solo per comodità) usleep(1000); } // il thread esce per altro motivo che lo stop flag printf("thread %ld terminato localmente (i=%ld comdata = %ld)\n", pthread_self(), i, data->comdata); pthread_exit(NULL); } // faiCose2() - thread routine void *faiCose2(void *arg) { // ottengo i dati del thread con un cast (tdata *) di (void *) arg tdata *data = (tdata *)arg; // thread loop printf("thread %ld partito\n", pthread_self()); unsigned long i = 1; for (;;) { // lock mutex pthread_mutex_lock(&data->mutex); // fa cose... data->comdata++; i++; // unlock mutex pthread_mutex_unlock(&data->mutex); // test stop flag if (data->stop) { // il thread esce printf("thread %ld terminato dal main (i=%ld comdata=%ld))\n", pthread_self(), i, data->comdata); pthread_exit(NULL); } // thread sleep (uso usleep solo per comodità) usleep(1000); } // il thread esce per altro motivo che lo stop flag printf("thread %ld terminato localmente (i=%ld comdata = %ld)\n", pthread_self(), i, data->comdata); pthread_exit(NULL); }
Tutto chiaro, no? I due thread condividono i dati attraverso la struttura tdata passata come argomento alla loro creazione, e la stessa struttura si usa anche per condividere i mutex di sincronizzazione. Faccio notare (per i più pignoli) che la terza sleep, quella del main() è del tutto giustificata per semplici programmi di esempio come questo, e serve solo a introdurre un ritardo per eseguire lo stop dei thread. Del resto, come già spiegato nella prima parte dell’articolo, questo uso è Ok per per gestire ritardi in single-thread, e il nostro main() può essere ricondotto a questo caso.
E come possiamo modificare questa applicazione per eseguire la sincronizzazione in modo efficace e intelligente come anticipato all’inizio? Ma usando, ad esempio, una condition variable (“Elementare, mio caro Watson!“). E allora riscriviamo il codice nella seguente maniera:
#include <stdio.h> #include <string.h> #include <unistd.h> #include <stdbool.h> #include <pthread.h> // creo un nuovo tipo per passare dei dati ai threads typedef struct _tdata { pthread_mutex_t mutex; // mutex comune ai threads pthread_cond_t cond; // condition variable comune ai thread bool stop; // flag per stop thread bool ready; // flag per dati disponibili unsigned long comdata; // dato comune ai threads } tdata; // prototipi locali void* faiCose1(void *arg); void* faiCose2(void *arg); // funzione main() int main(int argc, char* argv[]) { pthread_t tid[2]; tdata data; // init mutex int error; if ((error = pthread_mutex_init(&data.mutex, NULL)) != 0) { printf("%s: non posso creare il mutex (%s)\n", argv[0], strerror(error)); return 1; } // init condition variable if ((error = pthread_cond_init(&data.cond, NULL)) != 0) { printf("%s: non posso creare il cond (%s)\n", argv[0], strerror(error)); return 1; } // init altri dati comuni ai threads data.stop = false; data.ready = false; data.comdata = 0; // crea il thread 1 if ((error = pthread_create(&tid[0], NULL, &faiCose1, (void *)&data)) != 0) printf("%s: non posso creare il thread 0 (%s)\n", argv[0], strerror(error)); // crea il thread 2 if ((error = pthread_create(&tid[1], NULL, &faiCose2, (void *)&data)) != 0) printf("%s: non posso creare il thread 1 (%s)\n", argv[0], strerror(error)); // dopo 10 secondi ferma i thread sleep(10); data.stop = true; // join threads e cancella mutex pthread_join(tid[0], NULL); pthread_join(tid[1], NULL); pthread_mutex_destroy(&data.mutex); pthread_cond_destroy(&data.cond); // exit printf("%s: thread terminati: comdata=%ld\n", argv[0], data.comdata); return 0; } // faiCose1() - thread routine void *faiCose1(void *arg) { // ottengo i dati del thread con un cast (tdata *) di (void *) arg tdata *data = (tdata *)arg; // thread loop printf("thread %ld partito\n", pthread_self()); unsigned long i = 0; for (;;) { // lock mutex pthread_mutex_lock(&data->mutex); // aspetta condizione while (!data->ready) pthread_cond_wait(&data->cond, &data->mutex); // fa cose... data->comdata++; i++; // segnala condizione data->ready = false; pthread_cond_signal(&data->cond); // unlock mutex pthread_mutex_unlock(&data->mutex); // test stop flag if (data->stop) { // il thread esce printf("thread %ld terminato dal main (i=%ld comdata=%ld))\n", pthread_self(), i, data->comdata); pthread_exit(NULL); } } // il thread esce per altro motivo che lo stop flag printf("thread %ld terminato localmente (i=%ld comdata = %ld)\n", pthread_self(), i, data->comdata); pthread_exit(NULL); } // faiCose2() - thread routine void *faiCose2(void *arg) { // ottengo i dati del thread con un cast (tdata *) di (void *) arg tdata *data = (tdata *)arg; // thread loop printf("thread %ld partito\n", pthread_self()); unsigned long i = 1; for (;;) { // lock mutex pthread_mutex_lock(&data->mutex); // aspetta condizione while (data->ready) pthread_cond_wait(&data->cond, &data->mutex); // fa cose... data->comdata++; i++; // segnala condizione data->ready = true; pthread_cond_signal(&data->cond); // unlock mutex pthread_mutex_unlock(&data->mutex); // test stop flag if (data->stop) { // il thread esce printf("thread %ld terminato dal main (i=%ld comdata=%ld))\n", pthread_self(), i, data->comdata); pthread_exit(NULL); } } // il thread esce per altro motivo che lo stop flag printf("thread %ld terminato localmente (i=%ld comdata = %ld)\n", pthread_self(), i, data->comdata); pthread_exit(NULL); }
Che vi sembra? Con pochi cambi mirati, che consistono solo nell’aggiungere una condition variable e un flag di dati disponibili nei dati comuni (nella struttura tdata), e sorvegliando variabile e flag nei thread, si può evitare di usare le sleep (al prezzo di un leggero aumento dell’uso di CPU, molto lontano dal 100% di occupazione) e ottenendo una sincronizzazione precisa e deterministica (i thread lavorano solo quando serve): infatti usando le sleep la sincronizzazione è abbastanza alla spera in Dio, e aggiunge all’esecuzione dell’attività che ci interessa un ritardo innecessario e arbitrario (solitamente calcolato in maniera empirica o, peggio, messo un po’ a caso) e sempre sperando che il thread-scheduler faccia un buon lavoro.
Risulta anche evidente che l’uso di una condition vartiable è abbastanza semplice e immediato. Tutti i segreti sono contenuti nelle ottime pagine del POSIX Programmer’s Manual ma, comunque, vorrei evidenziare alcuni punti:
- Una condition variable è sempre associata a un mutex (si nota anche dal prototipo della pthread_cond_wait()) con cui lavora in completa sinergia.
- Il test di attesa sulla variazione deve essere messo in un while loop, cioè deve essere un test continuo e ripetitivo.
- Bisogna sempre aggiungere al meccanismo una variabile normale di sincronizzazione (è la variabile ready dell’esempio), per gestire il loop di attesa.
- Non aspettatevi miracoli: anche le condition variable hanno i loro punti critici e non sono la soluzione definitiva, sono solo una soluzione. Ma offrono già un bel miglioramento rispetto al semplice uso della sleep.
Come si intuisce dal punto 4 qua sopra, ci sono anche altri metodi-di-sincronizzazione-senza-sleep: io ho scelto quello con la condition variable perchè mi sembra che renda bene l’idea di come ragionare scrivendo applicazioni multithread (e ribadisco che è anche semplice da usare). Comunque, in generale, i metodi di sincronizzazione prevedono l’uso di spin lock, semafori, mutex ed eventi vari, spesso usati in combinazione. Ma ora non mi sembra il caso di aggiungere altra carne al fuoco: l’esempio con la variable condition è più che sufficiente per farsi una idea su come operare.
Ok, direi che il compito che mi ero prefisso, la demitizzazione della sleep, è completato. Con il prossimo articolo cambieremo argomento (o forse no? Magari sarebbe il momento di battere il ferro finché è caldo e proporre subito qualche altro esempio: vedremo). Comunque, non dormite troppo nel frattempo, la sleep non funziona, e chi dorme non piglia pesci!
Ciao, e al prossimo post!