Gentoo Logo

Disclaimer : La versione originale di questo articolo è stata pubblicata da IBM developerWorks ed è di proprietà di Westtech Information Services. Questo documento è una versione aggiornata dell'articolo originale, e contiene numerosi miglioramenti apportati dal Gentoo Linux Documentation team.
Questo documento non è mantenuto attivamente.


Spiegazioni sui thread POSIX, parte 1

Indice:

1.  Uno strumento semplice e veloce per la condivisione della memoria

I Thread sono divertenti

Sapere come usare correttamente i thread dovrebbe far parte del repertorio di ogni buon programmatore. I thread assomigliano ai processi. L'esecuzione dei thread, similmente a quella dei processi, è ripartita dal kernel in intervalli di tempo. Il kernel, nei sistemi uniprocessore, sfrutta il time slice per simulare l'esecuzione simultanea di piùthread nella stessa maniera in cui sfrutta il time slice per i processi. E attualmente, sui sistemi multiprocessore, i thread possono essere eseguiti contemporaneamente, esattamente come due o piùprocessi.

E perchè il multithreading è preferibile a più processi indipendenti per la maggior parte dei compiti di cooperazione? Bene, i thread condividono lo stesso spazio in memoria. Thread indipendenti possono accedere alle stesse variabili in memoria. Infatti tutti i thread contenuti nel programma possono leggere e scrivere le stesse variabili dichiarate globalmente. Se avete mai scritto del codice "non grezzo" che utilizza la funzione fork(), riconoscerete l'importanza di questo strumento. Perchè? Mentre fork() permette di dare vita a più processi, crea anche il seguente problema di comunicazione: come far dialogare fra loro più processi, ognuno dei quali ha un proprio spazio in memoria. Non esiste una soluzione semplice a questo problema. Nonostante ci siano molti tipi differenti di comunicazione locale fra processi (IPC), tutti presentano gli stessi due inconvenienti:

  • Impongono al kernel una specie di overhead in più, abbassando le prestazioni.
  • In quasi tutte le situazioni l'IPC non è un ampliamento "naturale" del codice. Spesso accresce drasticamente la complessità del programma.

Ulteriore scocciatura: overhead e complicazioni non sono delle belle cose. Se vi è mai capitato di dover apportare grosse modifiche ad uno dei vostri programmi per fare in modo che supporti IPC, apprezzerete senz'altro il semplice approccio di condivisione della memoria che i thread mettono a disposizione. I thread POSIX non necessitano di costose e complicate chiamate a lunga distanza perchè è come se tutti i nostri thread vivessero nella stessa casa. Con un po' di sincronizzazione, tutti i vostri thread possono leggere e modificare le strutture dati presenti nel programma. Non è necessario far transitare i dati attraverso un file descriptor o comprimerli in una piccola area di memoria condivisa. Per questo motivo solemente dovreste prendere in considerazione il modello con un processo multithread piuttosto che il modello multiprocesso con singoli thread.

I thread sono leggeri

Ma c'è dell'altro. I thread risultano inoltre essere estremamente leggeri. Confrontati alla fork() standard, producono molto meno overhead. Inoltre il kernel non necessita di creare un'ulteriore copia autonoma dello spazio di memoria relativo al processo, dei descrittori dei file, ecc. Ciò comporta un notevole risparmio del tempo di CPU, rendendo la creazione dei thread da decine a centinaia di volte più veloce che la creazione di nuovi processi. Proprio grazie a ciò, è possibile utilizzare un intero gruppo di thread senza doversi preoccupare di un eventuale overhead della CPU o della memoria. Non si ha un grosso utilizzo della CPU come si ha con una fork(). Questo significa che potete dare vita ai thread in qualsiasi momento abbia senso all'interno del programma.

Ovviamente, proprio come i processi, i thread trarranno vantaggio dalla presenza di più CPU. Questa è veramente un'ottima caratteristica se il software è progettato per essere utilizzato su macchine multiprocessore (se il software è open source, probabilmente finirà col girare soltanto su alcune di quelle). Le prestazioni di alcune tipologie di programmi che utilizzano i thread (in modo particolare quelli che fanno un uso intenso della CPU) salirà in modo abbastanza lineare relativamente al numero dei processori presenti nel sistema. Se state scrivendo un programma che fa un uso intenso della CPU, certamente cercherete un modo per poter utilizzare i thread nel vostro codice. Una volta esperti nello scrivere codice contenente thread, sarete capaci, scrivendo codice, di affrontare le sfide in modi nuovi e creativi, senza una moltitudine di IPC che fanno perdere tempo e diverse altre cose senza senso. Tutti questi benefici lavorano in sinergia per rendere la programmazione multithread divertente, veloce e flessibile.

Ora penso di essere un clone

Se avete mai fatto parte del mondo della programmazione Linux almeno per un momento, potreste conoscere la chiamata di sistema __clone(). __clone() e' simile a fork(), ma consente di svolgere molti dei compiti che anche i thread possono svolgere. Per esempio, con __clone() potete selettivamente condividere parte del contesto di esecuzione del processo padre (spazio in memoria, descrittori dei file, ecc.) con un nuovo processo figlio. E ciò è un bene. Ma ci sono anche cose negative a proposito di __clone(). Come riporta la pagina del manuale di __clone:

Codice 1.1: Citazione dalla pagina del manuale di __clone()

    "La chiamata __clone è propria di Linux e non dovrebbe essere utilizzata in
     programmi il cui obiettivo è la portabilità. Programmando applicazioni che
     sfruttano i thread (thread di controllo multipli nello stesso spazio di
     memoria), è preferibile utilizzare una libreria che implementi il thread
     API POSIX 1003.1c, come la libreria thread di Linux.
     Vedi pthread_create(3)."

Così, anche se __clone() offre gran parte dei vantaggi che offrono i thread, non è portabile. Ciò non significa che non dovreste usarla nel vostro codice. Ma dovreste valutare ciò quando pensate di utilizzare __clone() nel vostro software. Fortunatamente, come riporta la pagina del manuale di __clone(), c'è un'alternativa migliore: i thread POSIX. Quando avete intenzione di scrivere codice portabile e multithread, codice che funzioni sotto Solaris, FreeBSD, Linux, e altri, i thread POSIX sono la giusta strada da seguire.

Iniziando con i thread

Ecco un semplice programma che sfrutta i thread POSIX:

Codice 1.2: Programma di esempio che sfrutta i thread POSIX

#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>

void *thread_function(void *arg) {
  int i;
  for ( i=0; i<20; i++ ) {
    printf("Thread says hi!\n");
    sleep(1);
  }
  return NULL;
}

int main(void) {

  pthread_t mythread;

  if ( pthread_create( &mythread, NULL, thread_function, NULL) ) {
    printf("error creating thread.");
    abort();
  }

  if ( pthread_join ( mythread, NULL ) ) {
    printf("error joining thread.");
    abort();
  }

  exit(0);

}

Per compilare questo programma, semplicemente salvare il file come "thread1.c" e digitare:

Codice 1.3: Compilare il programma

$ gcc thread1.c -o thread1 -lpthread

Eseguirlo digitando:

Codice 1.4: Eseguire il programma

$ ./thread1

Capire thread1.c

thread1.c è un semplicissimo programma che utilizza i thread. Anche se non fa nulla di utile, vi aiuterà a capire come funzionano i thread. Ora diamo un'occhiata passo passo a ciò che fa questo programma. In main() per prima cosa dichiariamo una variabile chiamata mythread, che ha un tipo pthread_t. Il tipo pthread_t, definito in pthread.h, èspesso chiamato "thread id" (comunemente abbreviato a "tid"). Pensate a ciò come una specie di gestore di thread.

Dopo che mythread è stato dichiarato (ricordare che mythread è soltanto un "tid", o un controllo al thread che stiamo per creare), chiamiamo la funzione pthread_create per creare un vero e proprio thread. Non fatevi trarre in inganno dal fatto che pthread_create() è all'interno di un costrutto "if". Poichè pthread_create() ritorna zero in caso di successo e un valore diverso da zero in caso di insuccesso, collocare la chiamata alla funzione all'interno di un if() è soltanto una maniera elegante di scoprire un insuccesso della chiamata di pthread_create(). E ora diamo un'occhiata agli argomenti passati a pthread_create. Il primo è un puntatore a mythread, &mythread. Il secondo argomento, al momento impostato a NULL, può essere utilizzato per definire alcuni attributi del nostro thread. Poichè gli attributi predefiniti del thread per noi funzionano bene, lo impostiamo semplicemente a NULL.

Il nostro terzo argomento è il nome della funzione che il nuovo thread eseguirà una volta avviato. In questo caso il nome della funzione è thread_function(). Quando thread_function() ritorna, il nostro nuovo thread terminerà. In questo esempio la nostra funzione thread non esegue nulla di particolare. Stampa solamente a video "Thread says hi!" per 20 volte e poi esce. Notate che thread_function() accetta void * come argomento e ritorna void * come valore di ritorno. Questo dimostra che è possibile utilizzare un void * per passare una quantità arbitraria di dati al nostro nuovo thread, e che il nostro nuovo thread può ritornare una quantità arbitraria di dati quando termina. E ora come possiamo passare al nostro thread un argomento arbitrario? Semplice. Si utilizza il quarto argomento per chiamata a pthread_create(). In questo esempio è impostato a NULL perchè non serve passare nessun dato alla nostra grezza thread_function().

Come potete aver intuito, il programma sarà composto da due thread dopo che pthread_create() ha ritornato con successo. Aspetta un minuto, due thread? Non abbiamo creato solamente un singolo thread? Sì, certo. Ma anche il nostro programma principale è considerato un thread. Pensatela in questo modo: se viene scritto un programma senza assolutamente utilizzare i thread POSIX, il programma sarà composto da un singolo thread (questo singolo thread è chiamato thread "principale"). Creando un nuovo thread ora abbiamo un totale di due thread nel nostro programma.

Immagino che abbiate almeno due importanti domande da fare a questo punto. La prima è cosa il thread principale faccia dopo la creazione del nuovo thread. Continua ed esegue in sequenza la riga successiva del nostro programma (in questo caso la linea è "if ( pthread_join(...))"). La seconda domanda a cui potreste pensare è cosa succceda al nuovo thread una volta che esiste. Si ferma e aspetta di essere unificato o "riunito" con un altro thread come parte del proprio processo di pulizia.

OK, ora di nuovo a pthread_join(). Esattamente come pthread_create() divide il singolo thread in due thread, pthread_join() unifica due thread in un singolo thread. Il primo argomento passato a pthread_join() è il nostro "tid" mythread. Il secondo argomento è un puntatore ad un puntatore vuoto. Se il puntatore vuoto non è NULL, pthread_join() metterà il valore di ritorno di void * del thread nella locazione specificata. Poichè non ci interessa il valore di ritorno di pthread_function(), lo impostiamo a NULL.

Noterete che thread_function() impiega 20 secondi per arrivare al termine. Molto prima che thread_function() completi l'esecuzione, il thread principale ha già chiamato pthread_join(). Quando questo succede il thread principale si blocca (va in sleep) e aspetta il completamento di thread_function(). Una volta che thread_function() ha terminato il suo compito, pthread_join() ritornerà. Ora il programma ha di nuovo un thread principale. Quando il nostro programma esce, tutti i nuovi thread sono passati per la funzione pthread_join(). Questo è esattamente il modo in cui vi dovreste comportare con ogni nuovo thread creato all'interno dei vostri programmi. Se un nuovo thread non è ricongiunto continuerà a diminuire il numero massimo di thread eseguibili dal vostro sistema. Questo significa che la dovuta pulizia, se non effettuata a dovere, probabilmente causerà un fallimento delle nuove chiamate a pthread_create.

Niente padri, niente figli

Se avete utilizzato la chiamata a sistema fork() probabilmente avete familiarità col concetto di processi padre e figlio. Quando un processo genera un nuovo processo utilizzando fork(), il nuovo processo viene considerato figlio e il processo originale viene considerato padre. Questo crea una relazione gerarchica che può essere conveniente, in modo particolare durante l'attesa del termine di un processo figlio. La funzione waitpid(), per esempio, comunicherà al processo corrente di aspettare il termine di uno dei processi figlio. waitpid() è utilizzata per implementare una semplice routine di pulizia nei processi padre.

Le cose si fanno un po' più interessanti con i thread POSIX. Potreste aver notato che finora ho volutamente evitato di utilizzare i termini "thread padre" e "thread figlio". Tutto ciò perchè con i thread POSIX queste relazioni gerarchiche non esistono. Mentre un thread principale può creare un nuovo thread, e questo nuovo thread può creare un altro nuovo thread, lo standard dei thread POSIX vede tutti i vostri thread come un singolo insieme di risorse, uguali fra loro. Per questo motivo il concetto di attesa dell'uscita di un thread figlio non ha senso. Lo standard dei thread POSIX non registra nessuna informazione della "famiglia". Questa mancanza di una genealogia ha un'implicazione più grande: se si vuole attendere il termine di un thread, è necessario specificare quale si sta aspettando passando l'identificatore appropriato a pthread_join(). La libreria dei thread non può immaginarselo al posto vostro.

Per molte persone questa non è una gran bella notizia perchè può rendere più complicati programmi composti da più di due thread. Non fatevi intimorire. Lo standard dei thread POSIX fornisce tutti gli strumenti utili per gestire in maniera elegante thread multipli. Allo stato attuale, il fatto che non ci siano relazioni padre/figlio apre strade nuove e creative di utilizzare i thread nei vostri programmi. Per esempio, se abbiamo un thread chiamato thread 1, e thread 1 crea a sua volta un thread chiamato thread 2, non è necessario che sia lo stesso thread 1 chiamare pthread_join() per il thread 2. Qualsiasi altro thread nel programma può farlo. Ciò permette interessanti possibilità di scrivere codice che sfrutta pesantemente il multithread. Potete, per esempio, creare una "dead list" globale che contenga tutti i thread fermi e poi avere uno speciale thread di pulizia che semplicemente aspetta che un elemento sia aggiunto alla lista. Il thread di pulizia chiama pthread_join() per unificarsi con se stesso. Ora l'intera pulizia sarà gestita in modo efficente e armonioso in un unico thread.

Nuoto sincronizzato

È giunto finalmente il momento di dare un'occhiata a codice che compia qualcosa di un po' inaspettato. Ecco thread2.c:

Codice 1.5: thread2.c

#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>

int myglobal;

 void *thread_function(void *arg) {
  int i,j;
  for ( i=0; i<20; i++ ) {
    j=myglobal;
    j=j+1;
    printf(".");
    fflush(stdout);
   sleep(1);
    myglobal=j;
  }
  return NULL;
}

int main(void) {

  pthread_t mythread;
  int i;

  if ( pthread_create( &mythread, NULL, thread_function, NULL) ) {
    printf("error creating thread.");
    abort();
  }

  for ( i=0; i<20; i++) {
    myglobal=myglobal+1;
    printf("o");
    fflush(stdout);
    sleep(1);
  }

  if ( pthread_join ( mythread, NULL ) ) {
    printf("error joining thread.");
    abort();
  }

  printf("\nmyglobal equals %d\n",myglobal);

  exit(0);

}

Capire thread2.c

Questo programma, proprio come il primo, crea un nuovo thread. Sia il thread principale che il nuovo thread vanno ad incrementare per 20 volte una variabile globale chiamata myglobal. Ma il programma stesso produce qualche risultato inaspettato. Compilarlo digitando:

Codice 1.6: Compilare il programma

$ gcc thread2.c -o thread2 -lpthread

ed eseguirlo:

Codice 1.7: Esecuzione

$ ./thread2
..o.o.o.o.oo.o.o.o.o.o.o.o.o.o..o.o.o.o.o
myglobal equals 21

Abbastanza inaspettato! Poichè myglobal parte da zero, e sia il thread principale che il nuovo thread la incrementano di 20, dovremmo vedere myglobal uguale a 40 alla fine del programma. Dato che myglobal è uguale a 21 sappiamo che sta succedendo qualcosa di strano. Ma cosa?

Rinunciato? OK, vi mostrerò cosa succede. Date un'occhiata a thread_function(). Notate come copiamo myglobal in una variabile locale chiamata "j"? E notate in che modo incrementiamo j, poi pausa per un secondo e soltanto dopo copiamo il nuovo valore di j in myglobal? Questa è la chiave. Immaginate cosa succederebbe se il thread principale incrementasse myglobal soltanto dopo che il nuovo thread ha copiato il valore di myglobal in j. Quando thread_function() scrive di nuovo il valore di j in myglobal, va a sovrascrivere le modifiche fatte dal thread principale.

Scrivendo programmi che sfruttano i thread, vorrete evitare inutili effetti collaterali simili a quello appena visto, perchè sono una perdita di tempo (eccezion fatta se state scrivendo un articolo sui thread POSIX, ovviamente :). E ora cosa si può fare per eliminare questo problema?

Dal momento che il problema si verifica perchè myglobal è stata copiata in j e fermata per un secondo prima di riscriverla, possiamo provare ad evitare l'uso di una variabile locale incrementando myglobal in modo diretto. Ma mentre questa soluzione probabilmente funzionerà in questo particolare esempio, non è corretta. E se stiamo svolgendo un'operazione matematica relativamente complessa su myglobal invece che semplicemente incrementarla, certamente fallirà. Ma perchè?

Per capire il problema, è necessario ricordare che i thread vanno in esecuzione in maniera simultanea. Anche su una macchina uniprocessore (dove il kernel utilizza il time slice per simulare un multitasking reale) possiamo, dalla prospettiva di un programmatore, immaginare entrambi i thread in esecuzione contemporaneamente. thread2.c presenta problemi perchè il codice all'interno di thread_function() si basa sul fatto che myglobal non viene modificata per la durata di 1 secondo prima di essere incrementata. Ci servono alcuni procedimenti per far sì che un thread comunichi all'altro di "bloccarsi" fintanto che non vengono effettuate delle modifiche a myglobal. Vi mostrerò in modo esatto come fare nel mio prossimo articolo. Alla prossima.

2.  Risorse



Stampa

Aggiornato il 9 ottobre 2005

Oggetto: I thread POSIX (Portable Operating System Interface - Interfaccia Portabile al Sistema Operativo ndT) sono un ottima maniera per incrementare la reattività e le prestazioni del proprio codice. In questa serie, Daniel Robbins mostra esattamente come utilizzare i thread nel vostro codice. Verranno svelati dettagli del retroscena, cosicchè alla fine della serie sarete veramente pronti per creare il vostro programma multithread.

Daniel Robbins
Autore

Andrea Veroni
Traduzione

Donate to support our development efforts.

Copyright 2001-2014 Gentoo Foundation, Inc. Questions, Comments? Contact us.