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.

giovedì 15 ottobre 2020

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...).
...è inutile che mi guardi con quella faccia da sleep, soldato Hudson...
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:

  1. Una condition variable è sempre associata a un mutex (si nota anche dal prototipo della pthread_cond_wait()) con cui lavora in completa sinergia.
  2. Il test di attesa sulla variazione deve essere messo in un while loop, cioè deve essere un test continuo e ripetitivo.
  3. Bisogna sempre aggiungere al meccanismo una variabile normale di sincronizzazione (è la variabile ready dell'esempio), per gestire il loop di attesa.
  4. 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!