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 2

Indice:

1.  Le piccole cose chiamate mutex

Mutex me!

Nel mio precedente articolo, ho parlato del codice con thread che faceva cose inusuali ed inaspettate. Due thread che incrementano una variabile globale venti volte. La variabile avrebbe dovuto avere un valore finale di 40, ma invece terminava con un valore di 21. Che cos'era successo? Il problema si è verificato perché un thread "cancellava" ripetutamente l'incremento fatto dall'altro. Diamo un'occhiata a del codice corretto che usa un mutex per risolvere il problema:

Codice 1.1: thread3.c

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

int myglobal;
pthread_mutex_t mymutex=PTHREAD_MUTEX_INITIALIZER;

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

int main(void) {

  pthread_t mythread;
  int i;

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

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

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

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

  exit(0);

}

Tempo di capire

Se paragonate questo codice con la versione del mio precedente articolo, noterete l'aggiunta delle chiamate a pthread_mutex_lock() e pthread_mutex_unlock(). Queste chiamate compiono una funzione fondamentale nei programmi con thread. Forniscono un metodo di mutua esclusione (da cui il nome). Due thread non possono avere lo stesso mutex bloccato nello stesso momento.

I mutex lavorano così. Se il thread "a" prova a bloccare un mutex mentre il thread "b" ha lo stesso mutex bloccato, il thread "a" va in sleep. Appena il thread "b" rilascia il mutex (grazie ad una chiamata a pthread_mutex_unlock()), il thread "a" riuscirà a bloccare il mutex (in altre parole ritornerà dalla chiamata a pthread_mutex_lock() con il mutex bloccato). Allo stesso modo se il thread "c" prova a bloccare il mutex mentre il thread "a" lo sta tenendo, anche il thread "c" andrà temporaneamente in sleep. Tutti i thread che vanno in sleep, avendo chiamato pthread_mutex_lock() su di un mutex già bloccato si mettono in coda per l'accesso a quel mutex.

pthread_mutex_lock() e pthread_mutex_unlock() sono usati di norma per proteggere strutture di dati. Cioè, siete sicuri che solo un thread alla volta possa accedere ad una certa struttura dati bloccandola e sbloccandola. Come potete aver già indovinato, la libreria POSIX dei thread garantisce un blocco senza dover mettere il thread in sleep se un thread prova a bloccare un mutex sbloccato.


Figura 1.1: Per il vostro piacere, 4 znurts rimettono in atto una scena da recenti chiamate pthread_mutex_lock()

Fig. 1

Il thread in questa immagine che ha il mutex bloccato può accedere alla complessa struttura dati senza doversi preoccupare di avere altri thread che ci provino allo stesso tempo. La struttura dati è effettivamente "congelata" fino a che il mutex non viene sbloccato. È come se le chiamate a pthread_mutex_lock() e pthread_mutex_unlock() fossero cartelli di "lavori in corso" che delimitano una particolare parte dei dati condivisi che è stata modificata o letta. Le chiamate si comportano come avvisi per gli altri thread per mandarli in sleep e aspettare il loro turno per bloccare il mutex. Ovviamente questo è vero se si delimita ciascuna "read" e "write" ad una particolare struttura dati con chiamate a pthread_mutex_lock() e pthread_mutex_unlock().

Ma perché i mutex?

Sembra interessante, ma perché vogliamo mettere i nostri thread in sleep? Dopo tutto, il principale pregio dei thread non è proprio la loro abilità nel lavorare indipendentemente ed in molti casi contemporaneamente? Certo, questo è assolutamente vero. Tuttavia, ciascun programma con thread non banali richiede almeno un certo uso dei mutex.un po' di uso dei mutex. Rivediamo il nostro programma dell'esempio per capire perché. Se date un'occhiata a thread_function(), noterete che il mutex è bloccato all'inizio del ciclo e rilasciato alla fine. In questo esempio, mymutex è usato per proteggere il valore di myglobal.

Se guardate attentamente thread_function(), noterete che il codice relativo all'incremento copia myglobal su di una variabile locale, incrementa la variabile locale, va in sleep per un secondo, e solo allora copia il valore della variabile locale su myglobal. Senza il mutex thread_function()sovrascriverebbe il valore incrementato quando si sveglia, se il nostro thread principale incrementa myglobal durante il breve sonno di un secondo di thread_function(). Usare un mutex assicura che questo non succeda (Nel caso voi vi stiate chiedendo, ho aggiunto il ritardo di un secondo per mostrare un risultato difettoso. Non c'è nessuna motivo perché thread_function() vada in sleep per un secondo prima di riscrivere il valore della variabile locale su myglobal). Il nostro nuovo programma usando il mutex produce l'effetto desiderato.

Codice 1.2: Output del programma che usa il mutex

$ ./thread3
o..o..o.o..o..o.o.o.o.o..o..o..o.ooooooo
myglobal vale 40

Per esplorare ulteriormente questo concetto estremamente importante, diamo un'occhiata alla parte di codice del nostro programma che esegue l'incremento:

Codice 1.3: Codice per l'incremento

thread_function() increment code:
   j=myglobal;
    j=j+1;
    printf(".");
    fflush(stdout);
    sleep(1);
    myglobal=j;

main thread increment code:
    myglobal=myglobal+1;

Se questo codice fosse in un programma a singolo thread ci si aspetterebbe che il codice thread_function() fosse completamente eseguita. L'esecuzione verrebbe allora seguita dal codice principale del thread (o nella maniera inversa). In un programma con thread ma senza mutex, il codice può (e spesso lo farà, grazie alla chiamata a sleep()) finire l'esecuzione in quest'ordine:

Codice 1.4: Ordine di esecuzione

  thread_function() thread        main thread

  j=myglobal;
  j=j+1;
  printf(".");
  fflush(stdout);
  sleep(1);                        myglobal=myglobal+1;
  myglobal=j;

Quando il codice viene eseguito in questo particolare ordine la modifica fatta dal thread principale alla variabile myglobal viene sovrascritta. In questo modo finiamo con un valore non corretto alla fine del nostro programma. Se stessimo manipolando dei puntatori probabilmente finiremmo con un segfault. Notate che il nostro thread di thread_function() esegue tutte le sue istruzioni in ordine. Non è come se thread_function() facesse qualche cosa non in ordine. Il problema è che abbiamo un altro thread che sta facendo altre modifiche alla stessa struttura dati nello stesso tempo.

Dentro i thread 1

Prima che vi spieghi come trovare il modo di usare i mutex, vi offro un piccolo sguardo del lavoro interno dei thread. Ecco il nostro primo esempio:

Diciamo che voi abbiate un thread principale che crea tre nuovi thread: i thread "a", "b" e "c". Decidiamo che il thread "a" sia stato creato per primo, il thread "b" per secondo e il thread "c" per ultimo.

Codice 1.5: Ordine di creazione dei thread

  pthread_create( &thread_a, NULL, thread_function, NULL);
  pthread_create( &thread_b, NULL, thread_function, NULL);
  pthread_create( &thread_c, NULL, thread_function, NULL);

Dopo che la prima chiamata a pthread_create() è completata, voi potete immaginare che, o il thread "a" esiste o, che ha finito e che è ora fermo. Dopo la seconda chiamata a pthread_create(), sia il thread principale che il thread "b" possono pensare che il thread "a" esista ( o sia fermo).

Tuttavia, immediatamente dopo che la seconda chiamata a create() ritorna, il thread principale non può immaginare quale thread (a o b) stia per partire per primo. Benché ambedue i thread esistano è a discrezione del kernel e della libreria dei thread dargli un intervallo di tempo di CPU. Non c'è una regola fissa su chi parta per primo. Ora è molto probabile che il thread "a" incominci l'esecuzione prima del thread "b", ma non è garantito. Questo è specialmente vero su macchine multi-processore. Se voi scrivete del codice che assuma che il thread "a" effettivamente incominci la sua esecuzione prima del thread "b", vi ritroverete con un programma che funzionerà il 99% delle volte. O peggio, un programma che funziona il 100% delle volte sulla vostra macchina ma mai ( 0%) sul server a quattro processori del vostro cliente.

Un'altra cosa che possiamo imparare da questo esempio è che la libreria dei thread conserva l'ordine di esecuzione del codice per ciascun thread individuale. In altre parole, queste tre chiamate a pthread_create() verranno eseguite nell'ordine in cui compaiono. Dal punto di vista del thread principale, tutto il codice sta lavorando secondo ordine. Qualche volta possiamo sfruttare questo per ottimizzare parte dei nostri programmi con thread. Come nell'esempio sopra il thread "c" può pensare che i thread "a" e "b" stiano girando o abbiano già terminato. Non ci si deve preoccupare della possibilità che i thread "a" o "b" non siano ancora stati creati. Potete usare questa logica per ottimizare i vostri programmi con i thread.

Dentro i thread 2

OK, adesso come altro ipotetico esempio, supponiamo di avere un po' di thread che stanno eseguendo il codice qui sotto:

Codice 1.6: Codice in esecuzione

  myglobal=myglobal+1;

Abbiamo bisogno di bloccare/sbloccare il mutex rispettivamente prima e dopo l'incremento? Alcuni di voi potrebbe dire di no. Il compilatore, dopo tutto, molto probabilmente compilerà l'assegnazione sopra in un unica istruzione macchina. Come sapete una singola istruzione macchina non può essere interrotta "mid-stream". Anche gli interrupt hardware rispettano l'atomicità delle istruzioni macchina. A causa di questa tendenza si può essere tentati di lasciare fuori le chiamate pthread_mutex_lock() e pthread_mutex_unlock(). Non fatelo.

Sono stato un debole? Non proprio. Primo non dovreste presumere che l'assegnamento sopra sarà compilato come una singola istruzione macchina, a meno che non lo abbiate verificato di persona. Anche se inserite alcune parti scritte in assembler per essere sicuri che l'incremento avvenga atomicamente o anche se scrivete il compilatore da voi, potete ancora avere dei problemi.

Ecco perché. Usare una singola inline assembler opcode probabilmente funzionerà magnificamente su di una macchina con un unico processore. Ciascun incremento avviene atomicamente ed è probabile che il risultato sarà quello desiderato. Ma su di una macchina multiprocessore è un'altra storia. Con le macchine con più CPU potete avere due processori distinti che eseguono la assegnazione sopra quasi (o esattamente) allo stesso tempo. E ricordatevi anche che questa modifica della memoria ha bisogno di passare dalla cache L1 alla L2 e poi alla memoria principale. (Una macchina SMP non è solo un processore addizionale; ha anche bisogno di hardware speciale per arbitrare l'accesso alla RAM). Alla fine, voi non avete idea di quale CPU vinca la "gara" per scrivere nella memoria principale. Per produrre codice prevedibile, voi volete usare i mutex. I mutex inseriscono una "memory barrier", che assicura che le scritture nella memoria principale avvengano nell'ordine in cui i thread bloccano i mutex.

Considerate una architettura SMP che aggiorni la memoria principale in blocchi da 32-bit. Se state incrementando un intero a 64-bit senza i mutex, i quattro byte più significativi possono provenire da una CPU e gli altri quattro da un'altra. Male! Peggio ancora, usando questa tecnica scadente, il vostro programma fallirà una volta ad ogni morte di papa o anche alle tre di mattina sul sistema di un vostro importante cliente. David R. Butenhof, nel suo libro, Programming with POSIX Threads (vedi Risorse alla fine dell'articolo), contempla tutte le permutazioni possibili quando non si usano i mutex.

Troppi mutex

Se usate troppi mutex, il vostro codice non avrà nessun tipo di concorrenza e girerà più lentamente di uno con una soluzione single-threaded. Se ne mettete troppo pochi, il vostro codice avrà bug strani ed imbarazzanti. Fortunatamente c'è una via di mezzo. Prima di tutto i mutex sono usati per rendere seriale l'accesso a dati condivisi. Non usateli per dati non condivisi e neanche se la logica del vostro programma assicura che solo un thread abbia accesso ad una certa struttura dati in un determinato tempo.

In secondo luogo se state usando dati condivisi, usate i mutex sia in lettura che in scrittura. Circondate le vostre sezioni di lettura e scrittura con pthread_mutex_lock() e pthread_mutex_unlock(), o usatele ogni volta che un invariante del programma è momentaneamente rotto. Imparate a vedere il vostro codice secondo la prospettiva di un singolo thread e assicuratevi che ciascun thread nel vostro programma abbia una buona visione della memoria. Probabilmente impiegherete molto tempo prima di abituarvi a scrivere del codice con i mutex, ma presto diventeranno una seconda natura per voi e sarete capaci di usarli in modo corretto senza doverci pensare troppo.

Usare le chiamate: inizializzazione

OK, adesso è giunto il momento di vedere tutti i possibili utilizzi dei mutex. Primo: incominciamo con l'inizializzazione. Nel nostro esempio thread3.c,, usiamo un metodo di inizializzazione statico. Questo richiede la dichiarazione di una variabile pthread_mutex_t e assegnarle il valore della costante PTHREAD_MUTEX_INITIALIZER:

Codice 1.7: Esempio di inizializzazione

pthread_mutex_t mymutex=PTHREAD_MUTEX_INITIALIZER;

Questo è abbastanza semplice. Ma si possono creare mutex anche dinamicamente. Usate questo metodo dinamico ogni volta che il vostro codice alloca un nuovo mutex usando malloc(). In questo caso il metodo di inizializzazione statico non funzionerebbe e dovrebbe venir usata la routine pthread_mutex_init():

Codice 1.8: Creare un mutex in modo dinamico

int pthread_mutex_init( pthread_mutex_t *mymutex, const
pthread_mutexattr_t*attr)

Come potete notare, pthread_mutex_init accetta un puntatore a una regione di memoria preventivamente allocata per inizializzarlo come mutex. Come secondo argomento, può anche accettare un puntatore opzionale pthread_mutexattr_t. Questa struttura può essere usata per impostare diversi attributi del mutex. Ma di norma questi attributi non sono necessari, così generalmente si specifica NULL.

Ogni volta che inizializzate un mutex usando pthread_mutex_init(), dovrebbe essere distrutto usando pthread_mutex_destroy(). pthread_mutex_destroy() accetta un unico puntatore a pthread_mutex_t e libera tutte le risorse impegnate dal mutex al momento della sua creazione. State attenti che pthread_mutex_destroy() non liberi la memoria usata per salvare pthread_mutex_t. Dipende da voi svuotare la memoria. Ricordatevi anche che sia pthread_mutex_init() che pthread_mutex_destroy() ritornano zero se tutto va bene.

Usare le chiamate: locking

Codice 1.9: Esempio di locking

pthread_mutex_lock(pthread_mutex_t *mutex)

pthread_mutex_lock() accetta un unico puntatore ad un mutex per bloccarlo. Se il mutex fosse già bloccato, il chiamante andrà in sleep. Quando la funzione ritorna il chiamante sarà (ovviamente) svegliato e a questo punto terrà anche il lock. Questa chiamata può tornare uno zero, in caso di successo, o un codice di errore diverso da zero in caso di fallimento.

Codice 1.10: Esempio di unlocking

pthread_mutex_unlock(pthread_mutex_t *mutex)

pthread_mutex_unlock() complementa pthread_mutex_lock() e sblocca un mutex ch e il thread aveva già bloccato. Dovreste sempre sbloccare un mutex che avete bloccato quando è sufficientemente sicuro (per aumentarne le prestazione). E non dovreste mai sbloccare un mutex di cui non abbiate il blocco (altrimenti la chiamata a pthread_mutex_unlock() fallirà con un valore di ritorno EPERM diverso da zero).

Codice 1.11: Provando l'esempio di lock

pthread_mutex_trylock(pthread_mutex_t *mutex)

Questa chiamata è comoda quando volete bloccare un mutex mentre il vostro thread sta facendo qualcos'altro perché il mutex è al momento bloccato. Quando chiamate pthread_mutex_trylock() cercherete di bloccare il mutex. Se il mutex è al momento sbloccato voi lo bloccate e questa funzione ritornerà zero. Tuttavia, se il mutex è bloccato, questa chiamata non lo bloccherà. Ritornerà invece un valore di errore EBUSY, diverso da zero. A questo punto proseguite con le vostre attività e provate a bloccarlo più tardi.

Aspettando le condizioni

I mutex sono strumenti necessari per i programmi con thread, ma non possono fare tutto. Cosa succede, ad esempio, se il vostro thread sta aspettando che si verifichi una certa condizione su dei dati condivisi? Il vostro codice sblocca e blocca ripetutamente il mutex, controllando ogni cambiamento del valore. Allo stesso tempo sbloccherà velocemente il mutex così che altri possano fare i cambiamenti necessari. Ma questo è un approccio orribile, perché questo thread dovrà fare un busy loop per determinare un cambiamento in un ragionevole lasso di tempo.

Potete mettere il thread chiamante in sleep per un po', diciamo tre secondi tra ogni controllo, ma allora il vostro codice con thread non sarà reattivo in maniera ottimale. Quello di cui avete bisogno è un modo per mettere in sleep un thread mentre aspetta che alcune condizioni vengano a verificarsi. Una volta che si verificano le condizioni avete bisogno di un metodo per svegliare i o il thread che sta aspettando che quella condizione si verifichi. Se riuscite a fare ciò il vostro codice con thread sarà veramente efficiente e non vincolerà importanti bloccaggi di mutex. Questo è ciò che le variabili della condizione POSIX possono fare per voi.

E le variabili della condizione POSIX sono l'argomento del mio prossimo articolo, dove vi mostrerà esattamente come usarle. A quel punto avrete tutte le risorse per creare sofisticati programmi con thread che modellano gruppi di lavoro, linee di assembler e altro ancora. Nel nuovo articolo accelererò il passo ora che avete più familiarità con i thread. Spero di riuscire ad inserire un programma con thread ragionevolmente sofisticato alla fine del prossimo articolo e parlando di cura delle condizioni, arrivederci!

2.  Risorse

  • Leggi la spiegazione ai thread POSIX di Daniel Parte 1 e Parte 3.
  • Leggete la documentazione su Linux threads, di Sean Walton, KB7rfa.
  • Date sempre un'occhiata all'amichevole pagina del manuale LINUX di pthread (man -k pthread).
  • Guardate The LinuxThreads Library.
  • Proolix è un semplice sistema operativo POSIX-compliant per i8086+ in continuo sviluppo.
  • Date un'occhiata al libro di David R. Butenhof Programming with POSIX Threads, in cui lui affronta, tra le altre cose, le possibili permutazioni del non usare i mutex.
  • Prendete il libro di W. Richard Stevens "UNIX Network Programming".
  • Trovate ulteriori risorse per sviluppatore Linux in developerWorks Linux zone.
  • Fatevi coinvolgere dalla comunità di developerWorks partecipando ai blog di developerWorks.


Stampa

Aggiornato il 9 ottobre 2005

Oggetto: I thread POSIX sono un ottima modo per incrementare la reattività e le prestazioni del vostro codice. In questo articolo, il secondo di una serie di tre, Daniel Robbins vi mostra come proteggere l'integrità delle strutture di dati condivise nel vostro codice con thread usando delle eccellenti piccole cose chiamate mutex.

Daniel Robbins
Autore

Massimo Zanetti
Traduzione

Donate to support our development efforts.

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