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!

Nessun commento:

Posta un commento