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
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
-
Leggi la spiegazione ai thread POSIX di Daniel nella Parte 2 e nella Parte 3.
-
Leggi la documentazione su Linux
threads, by Sean Walton, KB7rfa
-
Un tutorial
sui thread POSIX scritto da Mark Hays presso la University of Arizona
-
In An Introduction to
Pthreads-Tcl, controllate i cambiamenti a Tcl che la rendono idonea
all'utilizzo con i thread POSIX
-
Un altro tutorial, Getting
Started with POSIX Threads, scritto da Tom Wagner and Don Towsley
presso il Computer Science Department della University of Massachusetts,
Amherst
-
Date sempre uno sguardo all'amichevole pagina del manuale di Linux di
pthread (man -k pthread)
-
FSU PThreads
è una libreria C che implementa i thread POSIX per SunOS 4.1.x, Solaris
2.x, SCO UNIX, FreeBSD, Linux, e DOS
-
Riferimento alla home page di thread POSIX e DCE per
Linux
-
Guardate La
libreria LinuxThreads
-
Proolix è
un semplice sistema operativo per i8086 POSIX-compliant in permanente
sviluppo
-
Date un'occhiata al libro di David R. Butenhof
Programming with POSIX Threads, in cui egli ricopre, fra le altre
cose, i possibili cambiamenti nel non utilizzare i mutex
-
Cercate il libro di W. Richard Stevens UNIX
Network Programming: Network APIs: Sockets and XTI, Volume 1
|