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ì 19 novembre 2020

Processi o Thread?
considerazioni sulla scelta tra Processi e Thread - pt.1

Gwenovier: Frank, cosa stai facendo?
Frank T.J.Mackey: Ti sto giudicando in silenzio.

Nel capolavoro Magnolia, Frank T.J.Mackey (un grande Tom Cruise) è un tipo con delle precise, radicate e radicali convinzioni, lui sa sempre come procedere in qualsiasi situazione. Lui non avrebbe mai dubbi su quale architettura scegliere scrivendo una applicazione: Processi o Thread? O un mix dei due? Ecco, visto che la maggior parte dei programmatori non ha le stesse sicurezze di Frank, cercheremo con questo articolo di fare un po' di chiarezza: speriamo bene...

Processi vs Thread
...processi o thread? Essere o non essere?...

La scelta di suddividere un programma in single/multi process o single/multi thread non è triviale. Non ci sono regole fisse e, spesso, si seguono metodi empirici o, più semplicemente, metodi intuitivi basati (si spera) sul buon senso.

Sfortunatamente l’avvento del multithreading è stato mal recepito da una grande parte dei programmatori che tendono a usarlo sempre (ma proprio sempre!) pur conoscendo in maniera approssimativa le (grandi) problematiche implicite nello scrivere un buon programma multithread. Per prendere un esempio dal mondo reale posso dirvi che l’idea di scrivere questo articolo mi è venuta mentre mio cuggino (ebbene si, sempre lui) mi raccontava di una sua riunione di lavoro, dove si dava per scontato che una nuova funzionalità da aggiungere a una applicazione già esistente (che era già multithread, e come no?) dovesse essere, sicuramente, un nuovo thread da aggiungere a quelli già esistenti...

Ma, sfortunatamente, la scelta non è così semplice.

Cerchiamo di chiarire cominciando col riassumere, brevemente, le caratteristiche e differenze tra Processi e Thread, ma senza entrare troppo nel dettaglio (mica che qualche lettore si addormenta) e dando anche per scontato che tutto questo sia già ben noto:

 

Processi

Thread

Definizione            

Un processo è l'istanza di un programma in esecuzione. A volte viene definito come "heavy weight process".

Un thread è un flusso di esecuzione all’interno di un processo. A volte viene definito come "light weight process".

Context-Switch         

Il cambio di contesto tra processi è relativamente lungo.                                               

Il cambio di contesto tra thread è più veloce di quello tra processi.

Memory Sharing         

I processi sono isolati tra loro. Questo è un vantaggio perché il "crash" di un processo non impedisce agli altri di continuare l'attività normale.

I thread non sono isolati tra loro e condividono la memoria: questo non è vantaggio: il malfunzionamento di un thread può, infatti, bloccare tutto il meccanismo di funzionamento del processo che lo contiene.

Comunicazione          

I processi anche se isolati tra loro possono condividere dati attraverso i meccanismi di IPC              

I thread grazie alla condivisione di memoria possono comunicare più facilmente e velocemente tra loro rispetto ai processi.

Creazione e Terminazione

la creazione e terminazione di un processo sono attività relativamente lente.                               

la creazione e terminazione di un thread sono attività più veloci delle equivalenti attività dei processi.

 

Così a prima vista sembrerebbe che, almeno in teoria, la scelta penda sempre dalla parte dei thread, ma in pratica... in pratica bisogna soffermarsi su due fattori:

  1. La differenza di prestazioni (velocità di context-switch e comunicazione) non è così abissale da far pendere clamorosamente la bilancia dal lato thread: nella decisione possono entrare anche altri fattori dipendenti dall'Hardware: ad esempio è noto che, in un sistema single-core e single-processor (è il caso di molti sistemi embedded) il multithreading spinto (di tipo preemptive, il tipo cooperative è tutta un'altra storia) non da poi delle grandi prestazioni...
  2. La frase "...il malfunzionamento di un thread può, infatti, bloccare tutto il meccanismo di funzionamento del processo..." mostrata qui sopra, insegna che un cattivo disegno di una applicazione multithread "si mangia" tutti i vantaggi descritti nella tabella precedente. 

Entrando un po' nel dettaglio del punto 1 appena descritto possiamo soffermarci sulla implementazione Linux dei thread (e il discorso è abbastanza valido anche per la implementazione Windows): in Linux un thread è una COE (context of execution) esattamente come un processo (e ce lo spiega lo stesso Linus qui: [Re: proc fs and shared pids]), quindi, visto che per il sistema operativo thread e processi sono (praticamente) la stessa cosa possiamo dedurre che, se il meccanismo di context-switch è ben fatto, la differenza di prestazioni non sarà enorme. Sarà magari più complicato scrivere l'applicazione, visto che far comunicare due processi con IPC non è semplice come far comunicare due thread che condividono la memoria, ma, e lo ripeterò per l'ennesima volta: scrivere una buona applicazione multithread è complicato, perché la sincronizzazione non può essere presa sotto gamba: una cattiva sincronizzazione produce problemi enormi (race-condition, starvation, ecc.), blah, blah, blah...

Quindi, senza dilungarci troppo, possiamo riassumere le (poche) regole necessarie a definire la scelta:

  1. Partendo da zero, ha senso realizzare una applicazione multithread quando la attività si può dividere in varie piccole sotto-attività omogenee, che necessitano di una notevole condivisione di dati e context-switch frequente (e, quindi, necessariamente rapido). Occhio all'Hardware però: se il target è una applicazione embedded su una CPU single-core avere molti thread può essere un problema.
  2. Partendo da una applicazione multithread già esistente, magari buona e ben testata, non ha molto senso aggiungere un nuovo thread che esegue, rispetto all'attività originale dell'applicazione,  una grande attività non omogenea, con una limitata condivisione di dati e context-switch scarso. E perché no ? Perché potrebbe creare problemi di concorrenza con la applicazione base e quindi, sicuramente, aggiungerebbe problemi di implementazione della sincronizzazione.
  3. Quindi nel caso 2 (specialmente se il target è una CPU single-core) potrebbe essere una buona scelta scrivere la parte nuova come processo indipendente (magari a sua volta di tipo multithread) e, probabilmente, anche l'implementazione risulterebbe più semplice e veloce, soprattutto a livello di debug, messa a punto e manutenzioni future (tutti argomenti fondamentali nel Software Engineering).

Per concludere: io sono un grande stimatore/utilizzatore del multithreading (e continuerò a esserlo), ma so che quando si fanno delle scelte tecniche bisogna essere il più possibile obbiettivi e non badare ai propri gusti personali... e per chi pensasse che tutto quanto sopra è solo un mio delirio informatico, posso proporre un interessante esempio reale fornito dagli amici del team di sviluppo Chrome di casa Google (che non sono esattamente gli ultimi arrivati): indovinate un po' che cosa fa Google Chrome quando aprite un nuovo tab di navigazione? Crea un nuovo thread? No: crea un nuovo processo!

Non posso finire un articolo senza mostrare un po' di codice, quindi ho pensato di mostrare un esempio di applicazione multithread (simile, ma ancora più semplificata di quella vista qui), che crea due thread che condividono un contatore (...si, lo so, usa le sleep al contrario di quello raccomandato nell'ultimo articolo... ma è proprio un esempio semplificato, non fate i pignoli...). Questo esempio si può compilare ed eseguire per prendere nota dei risultati.

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdbool.h>
#include <pthread.h>

// struttura per i dati condivisi
typedef struct {
    pthread_mutex_t mutex;      // mutex comune ai threads
    bool            stop;       // flag per stop thread
    unsigned long   counter;    // dato comune ai threads
} shdata;

// prototipi locali
void* threadfunc(void *arg);

// funzione main()
int main(int argc, char* argv[])
{
    pthread_t tid[2];
    shdata 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.counter = 0;

    for (int i = 0; i < 2; i++) {
        // crea un thread
        if ((error = pthread_create(&tid[i], NULL, &threadfunc, (void *)&data)) != 0)
            printf("%s: non posso creare il thread %d (%s)\n", argv[0], i, 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: counter=%ld\n", argv[0], data.counter);
    return 0;
}

// threadfunc() - thread routine
void *threadfunc(void *arg)
{
    // ottengo i dati del thread con un cast (shdata *) di (void *) arg
    shdata *data = (shdata *)arg;

    // thread loop
    printf("thread %ld partito\n", pthread_self());
    unsigned long i = 0;
    for (;;) {
        // lock mutex
        pthread_mutex_lock(&data->mutex);

        // incrementa i counter
        data->counter++;
        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 counter=%ld))\n",
                   pthread_self(), i, data->counter);
            pthread_exit(NULL);
        }

        // sleep thread (uso usleep solo per comodità)
        usleep(1000);
    }

    // il thread esce per altro motivo che lo stop flag
    printf("thread %ld terminato localmente (i=%ld counter = %ld)\n",
           pthread_self(), i, data->counter);
    pthread_exit(NULL);
}

Chiaro, no? È un esempio veramente semplice che mi permetterà di mostrarvi, nella seconda parte dell'articolo, una applicazione multiprocess che fa esattamente la stessa cosa, oltretutto con un codice veramente molto simile e con risultati di esecuzione abbastanza interessanti... Non trattenete il respiro nell'attesa, mi raccomando!

Ciao, e al prossimo post!

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!