I Thread POSIX

Un thread è un’unità di esecuzione all’interno di un processo. Un processo può avere più thread in esecuzione, che tipicamente condividono le risorse del processo e, in particolare, la memoria. Lo standard POSIX definisce un insieme di funzioni per la creazione e la sincronizzazione di thread. Vediamo le principali:

  • pthread_create(pthread_t *thread, pthread_attr_t *attr, void *(*start_routine)(void *), void *arg) prende 4 argomenti:
    1. thread: un puntatore a pthread_t, l’analogo di pid_t. Attenzione che non necessariamente è implementato come un intero (nelle ultime versioni di Linux è un unsigned long int, vedi pthreadtypes.h in /usr/include/...);
    2. attr: attributi del nuovo thread. Se non si vogliono modificare gli attributi è sufficiente passare NULL (vedere pthread_attr_init per maggiori dettagli);
    3. start_routine il codice da eseguire. È un puntatore a funzione che prende un puntatore a void e restituisce un puntatore a void. Ricordarsi che in C il nome di una funzione è un puntatore alla funzione;
    4. arg eventuali argomenti da passare, NULL se non si intende passare parametri.
  • pthread_exit(void *retval) termina l’esecuzione di un thread restituendo retval. Si noti che quando il processo termina (exit) tutti i suoi thread vengono terminati. Per far terminare un singolo thread si deve usare pthread_exit;
  • pthread_join(pthread_t th, void **thread_return) attende la terminazione del thread th. Se ha successo, ritorna 0 e un puntatore al valore ritornato dal thread. Se non si vuole ricevere il valore di ritorno è sufficiente passare NULL come secondo parametro (vedi esempio sotto).
  • pthread_detach(pthread_t th) se non si vuole attendere la terminazione di un thread allora si deve eseguire questa funzione che pone th in stato detached: nessun altro thread potrà attendere la sua terminazione con pthread_join e quando terminerà le sue risorse verranno automaticamente rilasciate (evita che diventino thread “zombie”). Si noti che pthread_detach non fa sì che il thread rimanga attivo quando il processo termina con exit.
  • pthread_t pthread_self() ritorna il proprio thread id.

    ATTENZIONE: questo ID dipende dall’implementazione ed è l’ID della libreria phread e non l’ID di sistema. Per visualizzare l’ID di sistema (quello che si osserva con il comando ps -L, dove L sta per Lightweight process, ovvero thread) si può usare una syscall specifica di Linux syscall(SYS_gettid).

Esempio: creazione e attesa

Vediamo un semplice esempio in cui vengono creati 2 thread che stampano il proprio id (sia quello di libreria che quello di sistema) e vanno in sleep(1) prima di terminare. Il thread principale attende la terminazione dei due thread e poi stampa un messaggio e termina.

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/syscall.h>   /* For SYS_xxx definitions */

// codice dei thread. Notare che e' una funzione che prende 
// un puntatore e ritorna un puntatore (a void)
void * codice_thread(void * a) {
    pthread_t tid;
    int ptid;
    
    tid  = pthread_self();      // library tid
    ptid = syscall(SYS_gettid); // tid assegnato dal SO (funziona solo in Linux)

    printf("Sono il thread %lu (%i) del processo %i\n",tid,ptid,getpid());
    sleep(1);
    pthread_exit(NULL);
}

int main() {
    pthread_t tid[2];
    int i,err;

    // crea i thread (ritorna 0 quando ha successo, vedere il man!)
    // - gli attributi sono quelli di default (il secondo parametro e' NULL)
    // - codice_thread e' il nome della funzione da eseguire
    // - non vegnono passati parametri (quarto parametro e' NULL)
    for (i=0;i<2;i++) {
        if (err=pthread_create(&tid[i],NULL,codice_thread,NULL)) {
            printf("errore create [%i]\n",err);
            exit(EXIT_FAILURE); }
    }
    // attende i thread. Non si legge il valore di ritorno (secondo parametro NULL)
    for (i=0;i<2;i++) {
        if (err=pthread_join(tid[i],NULL)) {
            printf("errore join [%i]\n",err);
            exit(EXIT_FAILURE); }
    }
    printf("I thread hanno terminato l'esecuzione correttamente\n");
}

Il programma va compilato con l’opzione -pthread per linkare la libreria POSIX threads e includere macro utili alla gestione dei thread.

> gcc test1.c -pthread -o test1       <=== opzione -pthread !!
> ./test1
Sono il thread 140072330872576 (14335) del processo 13793
Sono il thread 140072322479872 (14336) del processo 13793
I thread hanno terminato l'esecuzione correttamente

Si possono notare gli ID di libreria (unsigned long) e tra parentesi quelli di sistema visualizzabili con ps -L. Provare da un altro terminale mentre i thread sono in esecuzione:

$ ps -AL | grep test1
14334 14334 pts/0    00:00:00 test1
14334 14335 pts/0    00:00:00 test1
14334 14336 pts/0    00:00:00 test1
$ 

Esercizio 1: detach

Provare a “distaccare” uno dei thread e osservare l’errore restituito dalla join.

ATTENZIONE: essendo una libreria esterna, gli errori non possono essere visualizzati con perror (che stampa gli errori di sistema). Consultare il manuale delle chiamate a libreria per vedere i possibili errori restituiti. In ubuntu 64 bit, i codici di errore si trovano in /usr/include/asm-generic/errno-base.h.

Soluzione Esercizio 1

È sufficiente aggiungere una phtread_detach tra i due cicli for

// distacca il primo thread
if (err=pthread_detach(tid[0])) {
     printf("errore detach [%i]\n",err);
} 

Che dà il seguente output

$ ./esercizio1
Sono il thread 140102296766208 (85) del processo 84
errore join [22] 

L’errore 22 indica appunto che il thread non è ‘joinable’. Guardando il manuale di pthread_join scopriamo che l’errore si chiama EINVAL, che genericamente indica un ‘invalid argument’.In ubuntu 64 bit, i codici di errore si trovano in /usr/include/asm-generic/errno-base.h.

$ cat /usr/include/asm-generic/errno-base.h | grep 22
#define EINVAL 22 /* Invalid argument */ 

Esercizio 2: somma multithreaded

Passate ai 2 thread 2 interi letti dalla linea di comando (argv[1] e argv[2]). I due thread calcolano il quadrato del numero intero e il thread principale, infine, stampa la somma dei due valori ottenuti.

Fare attenzione: la memoria è condivisa quindi si deve passare ai 2 thread l’indirizzo di una zona di memoria “riservata” in modo da evitare interferenze.

  • Create un array di interi num[2] nel main
  • Copiate atoi(argv[1]) e atoi(argv[2]) in num[0] e num[1]
  • Passate l’indirizzo di num[0] e num[1] ai due thread (è necessario un cast a void *)
  • Nei thread, calcolate il quadrato e risalvatelo in num[0] e num[1] (è necessario un cast a int *)
  • Dopo le join il main può stampare num[0]+num[1], le join infatti assicurano che i thread abbiamo già computato la somma (lasciate la sleep per verificare che il main attende i risultati corretti)

Variante: se volete tenere input e output dei thread distinti potete usare una struct con due campi.

// struct usata per passare i valori e ricevere i risultati
 struct data {
     int v; // input data
     int r; // results
 };

Soluzione esercizio 2

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/syscall.h>   /* For SYS_xxx definitions */

void * codice_thread(void * a) {
    pthread_t tid;
    int ptid;
    // ottiene un puntatore a int passato dal main
    // *num punta a num[i] dove i è il numero del thread
    int *num = (int *)a; 
    
    tid  = pthread_self();      // library tid
    ptid = syscall(SYS_gettid); // tid assegnato dal SO (funziona solo in Linux)

    printf("Sono il thread %lu (%i) del processo %i e ho letto %i\n",
        tid,ptid,getpid(),*num);
    sleep(1);
    // calcola il quadrato di num[i] puntato da num e mette il risultato
    // direttamente in num[i]. In questo modo dopo le join il main può
    // trovare il quadrato direttamente in num[i]
    *num = *num * *num;
    pthread_exit(NULL);
}

int main(int argc, char *argv[]) {
    pthread_t tid[2];
    int i,err;
    int num[2]; // array per passaggio parametri
    if (argc < 3) {
        printf("Usage: %s num1 num2\n",argv[0]);
        exit(EXIT_FAILURE);
    }

    // crea i thread: passa i parametri ai thread copiando argv[i+i]
    // in num[i] e facendo un cast a (void *) di &num[i]. 
    // In questo modo ogni thread lavora su una variabile distinta
    // 
    for (i=0;i<2;i++) {
        num[i] = atoi(argv[i+1]); // copia argv[i+i] in num[i]
        // crea i thread passando come parametro (void *)&num[i]
        if (err=pthread_create(&tid[i],NULL,codice_thread,(void *)&num[i])) {
            printf("errore create [%i]\n",err);
            exit(EXIT_FAILURE); }
    }
    // attende i thread. 
    for (i=0;i<2;i++) {
        if (err=pthread_join(tid[i],NULL)) {
            printf("errore join [%i]\n",err);
            exit(EXIT_FAILURE); }
    }
    // il risultato è direttamente in num[i]!
    printf("I thread hanno terminato l'esecuzione correttamente: %d + %d = %d\n",
        num[0],num[1],num[0]+num[1]);
}

Che dà il seguente output:

$ ./esercizio2  4 5
Sono il thread 140064670689024 (139) del processo 138 e ho letto 4
Sono il thread 140064662296320 (140) del processo 138 e ho letto 5
I thread hanno terminato l'esecuzione correttamente: 16 + 25 = 41 

Esercizio 3: interferenze

Creare 2 thread che aggiornano ripetutamente (in un ciclo for) una variabile condivisa count per un numero elevato di volte (ad esempio 1000000). Stampare il valore finale per osservare eventuali incrementi perduti.

NOTA: il compilatore potrebbe ottimizzare il codice e fare, ad esempio, un unico incremento di 1000000 sulla variablie count. In questo caso non si osserveranno interferenze. Per evitare ottimizzazioni dare a gcc l’opzione -O0 (che dovrebbe essere di default). Per sperimentare ottimizzazioni provare con -O oppure -O3. Per visualizzare l’assembly risultante si può usare il comando seguente:

 objdump -M intel -d esercizio3 | grep -A 50 codice_thread  

che fa il disassemlaggio (-d) in stile mnemonico intel (-M intel) dell’eseguibile a.out. Il filtro grep cerca nel codice assembly la stringa codicethread (nome della funzione che ci interessa) e stampa 10 righe (opzione -A 10, after).
Dal codice assembly è abbastanza evidente come viene compilato il ciclo for.

Soluzione esercizio 3

#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include <sys/syscall.h>   /* For SYS_xxx definitions */

#define MAX 1000000  // numero di incrementi

int count=0; // contatore globale inizializzato a 0

void * codice_thread(void * a) {
    pthread_t tid;
    int ptid;
    int i;

    tid  = pthread_self();      // library tid
    ptid = syscall(SYS_gettid); // tid assegnato dal SO (funziona solo in Linux)

    printf("Sono il thread %lu (%i) del processo %i\n",tid,ptid,getpid());
    
    // il thread incrementa il contatore globale MAX volte
    // questo può INTERFERIRE e alcuni incrementi vengono persi!
    for (i=0;i<MAX;i++) {

        count++;
    
    }

    pthread_exit(NULL);
}

int main() {
    pthread_t tid[2];
    int i,err;

    // crea i thread (ritorna 0 quando ha successo, vedere il man!)
    for (i=0;i<2;i++) {
        if (err=pthread_create(&tid[i],NULL,codice_thread,NULL)) {
            printf("errore create [%i]\n",err);
            exit(EXIT_FAILURE); }
    }
    // attende i thread. Non si legge il valore di ritorno (secondo parametro NULL)
    for (i=0;i<2;i++) {
        if (err=pthread_join(tid[i],NULL)) {
            printf("errore join [%i]\n",err);
            exit(EXIT_FAILURE); }
    }
    printf("I thread hanno terminato l'esecuzione correttamente %d\n",count);
}

Output con interferenze (notare che la somma è sembre diversa da quella attesa di MAX*2 = 2000000):

$ ./esercizio3 
Sono il thread 140074353243904 (159) del processo 158
Sono il thread 140074344851200 (160) del processo 158
I thread hanno terminato l'esecuzione correttamente 1163146

Se osserviamo l’assembly notiamo come viene compilato il ciclo for:

 $ objdump -M intel -d esercizio3 | grep -A 50 codice_thread
...
94c:    mov    eax,DWORD PTR [rip+0x2006c2]        # 201014 <count>
952:    add    eax,0x1
955:    mov    DWORD PTR [rip+0x2006b9],eax        # 201014 <count>
...

Come vediamo l’incremento di default viene proprio fatto caricando il valore nel registro eax, incrementando il registro di 1 e salvando il valore di nuovo in memoria. Il programma quindi è suscettibile a interferenze sul valore di count (come abbiamo visto dall’output).

Proviamo a compilare il programma con l’opzione -O3. In questo caso l’esecuzione non dà interferenze:

$ ./esercizio3 
Sono il thread 140345806817024 (219) del processo 218
Sono il thread 140345798424320 (220) del processo 218
I thread hanno terminato l'esecuzione correttamente 2000000

In effetti l’ottimizzazione fa una somma di 1000000 (0xf4240) direttamente sulla variabile in memoria, senza utilizzare i registri:

$ objdump -M intel -d esercizio3 | grep -A 50 codice_thread
a2f:	add    DWORD PTR [rip+0x2005db],0xf4240        # 201014 <count>

Osserviamo quindi che, non solo l’output dipende dalla temporizzazione dei thread (race condition), ma la presenza o meno di interferenze può dipendere da come il codice è stato compilato: uno stesso programma che sembra privo di race condition potrebbe esibirne una volta che viene ricompilato con una versione nuova del compilatore. Vedremo, quindi, come risolvere le interferenze in modo sistematico (e indipendente dal compilatore) nelle prossime lezioni.