Thread e concorrenza - modelli, Pthreads e goroutine
In questa pagina 7
Un processo (Processi - stati, PCB, creazione con fork ed execUn processo è un programma in esecuzione (entità attiva) con codice (text), dati globali, heap, stack e lo stato dell'esecuzione; il kernel lo descrive con il PCB (Process Control Block: stato, program counter, registri, informazioni di scheduling, memoria, accounting, file aperti, in Linux la struct task_struct) e passa da un processo all'altro con il context switch (puro overhead); gli stati sono new, ready, running, waiting, terminated, con una ready queue e varie wait queue; in UNIX fork() crea un figlio copia del padre (restituisce 0 al figlio e il pid del figlio al padre), exec() sostituisce il programma del figlio, wait()/waitpid() raccoglie lo stato di uscita: senza di essa un figlio terminato è uno zombie, un figlio il cui padre è morto è un orfano adottato da init/systemd; i processi cooperanti comunicano con memoria condivisa (POSIX shm) o scambio di messaggi (diretto o indiretto, sincrono o asincrono, buffer a capacità zero, limitata, illimitata) e con pipe ordinarie (padre-figlio, unidirezionali) e named pipe (FIFO).Processi - stati, PCB, creazione con fork ed exec →) ha un solo flusso di istruzioni. Un thread permette più flussi dentro lo stesso processo. Questa nota spiega perché servono, come il sistema li realizza, come si programmano in C con Pthreads e in Go con le goroutine, e quali problemi nascono; la condivisione dei dati tra thread apre il capitolo della sincronizzazione (Sincronizzazione - sezione critica, race condition e mutexQuando più processi o thread accedono agli stessi dati e il risultato dipende dall'ordine degli accessi si ha una race condition (esempio: counter++ e counter-- sono tre istruzioni macchina ciascuna, e interleaving diversi danno 4, 5 o 6 partendo da 5); il problema della sezione critica chiede un protocollo (entry section, sezione critica, exit section, remainder section) che garantisca mutua esclusione, progresso e attesa limitata; disabilitare gli interrupt non scala sui multiprocessori, l'alternanza stretta con turn viola il progresso, la soluzione di Peterson (flag[2] e turn) soddisfa i tre requisiti ma può fallire su hardware moderno per il riordino delle istruzioni, per cui servono barriere di memoria; l'hardware offre test_and_set e compare_and_swap (atomiche) e variabili atomiche; il mutex lock (acquire/release, in POSIX pthread_mutex_t) è lo strumento di alto livello, con attesa attiva se è uno spinlock oppure con sospensione del thread se è un mutex di sistema; bloccare due volte lo stesso mutex senza sbloccarlo causa deadlock.Sincronizzazione - sezione critica, race condition e mutex →).
Che cosa è un thread
Definizione (thread). Un thread è l'unità di utilizzo della CPU: ha un identificatore, un program counter, un insieme di registri e uno stack propri, e condivide con gli altri thread dello stesso processo il codice, i dati globali, l'heap e le risorse del sistema operativo (come i file aperti).
Un processo con un solo thread è il caso di prima; con più thread, il PCB contiene più program counter e più stato.
| parte | per thread | condivisa nel processo |
|---|---|---|
| program counter e registri | sì | no |
| stack (variabili locali, indirizzi di ritorno) | sì | no |
| codice | no | sì |
| dati globali e statici | no | sì |
| heap | no | sì |
| file aperti, segnali, ambiente | no | sì |
Perché i thread
La maggior parte delle applicazioni moderne è multithread (e anche i kernel): compiti diversi (leggere dati, aggiornare lo schermo, rispondere a una richiesta di rete) diventano thread separati. Un server multithread riceve una richiesta e fa creare un thread che la serve, mentre il thread principale torna ad ascoltare. I vantaggi:
- reattività (responsiveness): il programma continua se una sua parte è bloccata (decisivo per le interfacce grafiche);
- condivisione delle risorse: i thread condividono la memoria senza i meccanismi di memoria condivisa o messaggi;
- economia: creare un thread e commutare tra thread costa molto meno di creare un processo e di un cambio di contesto tra processi;
- scalabilità: anche un solo processo sfrutta più core.
Concorrenza e parallelismo
| definizione | serve | |
|---|---|---|
| concorrenza (concurrency) | più compiti avanzano nel tempo (a turno) | basta un core: lo scheduler li alterna |
| parallelismo (parallelism) | più compiti vengono eseguiti nello stesso istante | più core |
Un sistema può essere concorrente senza essere parallelo (un solo core), non il contrario. Il parallelismo si presenta in due forme:
- parallelismo sui dati (data parallelism): lo stesso insieme di dati è diviso in parti, e su ogni core gira la stessa operazione su una parte (per esempio thread riempiono ciascuno elementi di un array: è il compito di Esercizio - Thread, race condition e parallelismo sui dati (laboratorio 3));
- parallelismo sui compiti (task parallelism): thread diversi eseguono operazioni diverse, anche sugli stessi dati.
La legge di Amdahl
Teorema (legge di Amdahl). Se una frazione del programma è seriale (non parallelizzabile) e il sistema ha core, lo speedup massimo è
Esempio: applicazione al 75% parallela e 25% seriale (). Con core:
Con : . Per lo speedup tende a : la parte seriale pesa in modo sproporzionato, e aggiungere core non basta. La legge non considera del tutto i sistemi multicore moderni. Quando un ciclo è parallelizzabile? Se le iterazioni sono indipendenti (arr[i] = i * i), sì; se ognuna dipende dalla precedente (arr[i] = arr[i-1] + i), no.
Thread utente e thread kernel
- Thread utente (user threads): gestiti da una libreria in spazio utente, senza che il kernel lo sappia.
- Thread kernel (kernel threads): gestiti direttamente dal sistema operativo; supportati da tutti i sistemi generali.
La relazione tra i due tipi definisce il modello di multithreading:
| modello | schema | conseguenze | esempi |
|---|---|---|---|
| many-to-one | molti thread utente su un thread kernel | se un thread si blocca, si bloccano tutti; nessun parallelismo su multicore (il kernel ne vede uno solo) | Solaris Green Threads, GNU Portable Threads (quasi nessuno oggi) |
| one-to-one | ogni thread utente ha un thread kernel | più concorrenza; il numero di thread per processo è limitato dal costo | Linux, Windows |
| many-to-many | molti thread utente su un numero uguale o minore di thread kernel scelto dal sistema | flessibile, ma complesso e poco diffuso | (Go lo usa a livello di runtime, v. sotto) |
| a due livelli (two-level) | come many-to-many ma permette di legare un thread utente a un thread kernel |
Con many-to-many e many-to-one servono dei canali di comunicazione tra kernel e libreria per tenere il numero giusto di thread kernel. Si usa una struttura intermedia, il processo leggero (lightweight process, LWP): un "processore virtuale" su cui la libreria schedula i thread utente, ognuno legato a un thread kernel (scheduler activations).
Librerie principali: Pthreads (POSIX: thread utente o kernel; UNIX, Linux, macOS), Windows threads (kernel, modello one-to-one: ogni thread ha un id, un insieme di registri, due stack, uno utente e uno kernel, e un'area privata; registri, stack e area formano il contesto), e Java threads.
In Linux non si distingue davvero tra processi e thread: sono tutti task. Il thread si crea con la system call clone() e dei flag che dicono cosa condividere con il padre (spazio degli indirizzi CLONE_VM, file system, file aperti, gestori di segnali, CLONE_THREAD). Senza flag clone() equivale a fork().
Pthreads
Pthreads è lo standard POSIX (IEEE 1003.1c) per creare e sincronizzare thread: specifica il comportamento, non l'implementazione. Le funzioni principali:
| funzione | uso |
|---|---|
pthread_create(&tid, &attr, funzione, arg) |
crea un thread che esegue funzione(arg); attr = NULL per gli attributi di default |
pthread_join(tid, &ris) |
attende la fine del thread e ne prende il valore restituito |
pthread_exit(valore) |
termina il thread chiamante, restituendo valore |
pthread_self() |
identificatore del thread chiamante |
pthread_cancel(tid) |
chiede la cancellazione di un thread |
Si compila collegando la libreria: gcc programma.c -o programma -lpthread. Esempio completo: si divide la somma dei primi interi in parti, una per thread (parallelismo sui dati). Ogni thread scrive il proprio risultato parziale in un suo elemento di un array: nessuna variabile è modificata da due thread, quindi non ci sono race condition.
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#define N 1000000L /* sommare 1 + 2 + ... + N */
#define M 4 /* numero di thread */
struct lavoro {
long da, a; /* intervallo [da, a) assegnato al thread */
long parziale; /* risultato del thread */
};
static void *somma_parziale(void *arg)
{
struct lavoro *w = arg; /* ogni thread riceve il proprio lavoro */
long s = 0;
for (long i = w->da; i < w->a; i++)
s += i + 1;
w->parziale = s;
return NULL; /* equivale a pthread_exit(NULL) */
}
int main(void)
{
pthread_t tid[M];
struct lavoro w[M]; /* un elemento per thread: niente variabili condivise in scrittura */
for (int k = 0; k < M; k++) {
w[k].da = k * (N / M);
w[k].a = (k + 1) * (N / M);
w[k].parziale = 0;
if (pthread_create(&tid[k], NULL, somma_parziale, &w[k]) != 0) {
fprintf(stderr, "pthread_create fallita\n");
return 1;
}
}
long totale = 0;
for (int k = 0; k < M; k++) {
pthread_join(tid[k], NULL); /* aspetta il thread k, poi legge il suo risultato */
totale += w[k].parziale;
}
printf("somma = %ld (attesa %ld)\n", totale, N * (N + 1) / 2);
return 0;
}Per e ogni thread somma 250 000 numeri e il risultato è , uguale a quello seriale.
Trappola classica: passare l'indirizzo di una variabile che cambia. In un ciclo for (int i...) pthread_create(&t[i], NULL, f, &i); tutti i thread leggono la stessa i, che il ciclo continua a incrementare: i thread vedono valori sbagliati o ripetuti. Si risolve passando l'indirizzo di un elemento di un array (&w[k] come sopra), o il valore con un cast di intero a puntatore, oppure creando un oggetto per thread.
Quando due thread modificano la stessa variabile (sum++ e sum-- su una globale), nascono le race condition: è il laboratorio 3 (Esercizio - Thread, race condition e parallelismo sui dati (laboratorio 3)).
Threading implicito
Gestire a mano i thread diventa difficile quando sono molti. Con il threading implicito creazione e gestione passano a compilatori e librerie. Metodi:
- fork-join: si "biforcano" più compiti (fork) e si aspetta la loro fine (join); la libreria decide come dividere;
- thread pool: si creano in anticipo thread che attendono lavoro; servire una richiesta con un thread già pronto è più rapido che crearne uno, il numero di thread resta limitato dalla dimensione del pool, si separa il compito dal meccanismo e si possono pianificare esecuzioni periodiche;
- OpenMP: direttive del compilatore (
#pragma omp parallel for) e API per C, C++ e FORTRAN, per la memoria condivisa; il compilatore crea tanti thread quanti sono i core; - Grand Central Dispatch (Apple): blocchi di codice messi in una coda di dispatch in attesa di un thread libero in un pool;
- Threading Building Blocks (Intel): libreria C++ con costrutti come
parallel_for.
Questioni aperte con i thread
| questione | scelte |
|---|---|
fork ed exec |
fork duplica solo il thread chiamante o tutti? (alcuni UNIX hanno due versioni). exec sostituisce l'intero processo, compresi tutti i thread, quindi se si fa exec subito dopo fork conviene duplicare solo il chiamante |
| segnali | un segnale è una notifica software di un evento (default o gestore definito dall'utente; kill PID invia SIGTERM). A quale thread va consegnato? Al thread interessato, a tutti, ad alcuni, o a uno designato |
| cancellazione | un thread target viene terminato prima della fine: asincrona (subito) o differita (deferred, il thread controlla periodicamente se deve finire). In Pthreads pthread_cancel è solo una richiesta: il tipo predefinito è differita e la cancellazione avviene a un punto di cancellazione (per esempio una read), dove si eseguono i gestori di pulizia |
| thread-local storage (TLS) | ogni thread ha una propria copia di certi dati; utile quando non si controlla la creazione dei thread (threading implicito); diverso dalle variabili locali (visibili solo in una chiamata) e simile a una variabile static unica per thread |
| scheduler activations | comunicazione kernel-libreria sul numero di thread kernel (v. sopra) |
Contention scope
Quando la libreria schedula i thread utente su LWP (many-to-one, many-to-many) la competizione per la CPU è dentro il processo: process-contention scope (PCS), di solito con priorità decise dal programmatore. Quando invece i thread kernel sono schedulati direttamente sulla CPU, competono con tutti i thread del sistema: system-contention scope (SCS). L'API Pthreads permette di chiedere PTHREAD_SCOPE_PROCESS (PCS) o PTHREAD_SCOPE_SYSTEM (SCS) alla creazione, ma Linux e macOS accettano solo PTHREAD_SCOPE_SYSTEM: il programma posix-scope.c del laboratorio 3 mostra lo scope predefinito e poi lo imposta a SYSTEM.
Goroutine in Go
Go è un linguaggio con la concorrenza nel linguaggio. Una goroutine è una funzione eseguita in modo concorrente con le altre, avviata con la parola chiave go. È molto più leggera di un thread: parte con uno stack di pochi KB che cresce quando serve, e il runtime di Go le multiplexa su pochi thread del sistema (un modello many-to-many realizzato dal runtime, con un numero di thread kernel regolato da GOMAXPROCS). Si coordinano con sync.WaitGroup (si aspetta la fine di un gruppo) e con i canali (channel), il meccanismo di comunicazione tipizzato: lo stile Go è «non comunicare condividendo la memoria, ma condividere la memoria comunicando».
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1) // un compito in più da aspettare
go func(id int) { // goroutine: id è passato per valore, ogni goroutine ha il suo
defer wg.Done() // alla fine segnala di aver terminato
fmt.Println("goroutine", id)
}(i)
}
wg.Wait() // il main aspetta tutte, altrimenti uscirebbe subito
}Se main ritorna, il programma termina anche se ci sono goroutine in esecuzione: per questo serve wg.Wait(). L'ordine delle tre stampe non è determinato. Si noti che i è passato come argomento id: è lo stesso problema dell'indirizzo condiviso visto in C (nelle versioni di Go precedenti alla 1.22 una closure che usava direttamente la i del ciclo la condivideva).
Lo stesso esempio della somma parallela, con un canale per raccogliere i risultati parziali (parallelismo sui dati, senza variabili condivise):
package main
import "fmt"
func sommaParziale(v []int, ris chan<- int) { // chan<- : canale in cui si può solo inviare
s := 0
for _, x := range v {
s += x
}
ris <- s // invio sul canale (bloccante finché qualcuno riceve)
}
func main() {
a := make([]int, 1000)
for i := range a {
a[i] = i + 1 // 1, 2, ..., 1000
}
const m = 4
ris := make(chan int)
pezzo := len(a) / m
for k := 0; k < m; k++ {
go sommaParziale(a[k*pezzo:(k+1)*pezzo], ris) // una goroutine per quarto
}
totale := 0
for k := 0; k < m; k++ {
totale += <-ris // ricezione: aspetta il risultato di una goroutine
}
fmt.Println(totale) // 500500
}Il canale senza buffer fa da rendez-vous (Processi - stati, PCB, creazione con fork ed execUn processo è un programma in esecuzione (entità attiva) con codice (text), dati globali, heap, stack e lo stato dell'esecuzione; il kernel lo descrive con il PCB (Process Control Block: stato, program counter, registri, informazioni di scheduling, memoria, accounting, file aperti, in Linux la struct task_struct) e passa da un processo all'altro con il context switch (puro overhead); gli stati sono new, ready, running, waiting, terminated, con una ready queue e varie wait queue; in UNIX fork() crea un figlio copia del padre (restituisce 0 al figlio e il pid del figlio al padre), exec() sostituisce il programma del figlio, wait()/waitpid() raccoglie lo stato di uscita: senza di essa un figlio terminato è uno zombie, un figlio il cui padre è morto è un orfano adottato da init/systemd; i processi cooperanti comunicano con memoria condivisa (POSIX shm) o scambio di messaggi (diretto o indiretto, sincrono o asincrono, buffer a capacità zero, limitata, illimitata) e con pipe ordinarie (padre-figlio, unidirezionali) e named pipe (FIFO).Processi - stati, PCB, creazione con fork ed exec →): l'invio aspetta la ricezione. Per i problemi classici (produttore-consumatore, lettori-scrittori, filosofi) con goroutine e canali si veda Semafori, monitor e problemi classici di sincronizzazioneUn semaforo è un intero accessibile solo con due operazioni atomiche, wait (P: aspetta finché S > 0 e decrementa) e signal (V, in POSIX sem_post: incrementa); il semaforo contatore ha valori illimitati, quello binario vale 0 o 1; con valore iniziale 1 realizza la mutua esclusione, con valore iniziale 0 impone un ordine di esecuzione (S1 prima di S2); senza attesa attiva ogni semaforo ha una coda di processi sospesi (sleep e wakeup); i semafori si usano male con facilità (ordine sbagliato, omissioni); il monitor è un tipo di dato astratto in cui un solo processo alla volta è attivo e le variabili condizione x.wait()/x.signal() sospendono e risvegliano (in POSIX mutex più pthread_cond_t); le anomalie di liveness sono deadlock, starvation e inversione di priorità; i problemi classici sono produttore-consumatore a buffer limitato (mutex, vuote = n, piene = 0; l'ordine delle wait è importante), lettori-scrittori (rw_mutex, mutex, read_count; i lettori possono far morire di fame gli scrittori) e filosofi a cena (la soluzione ingenua con un semaforo per bacchetta va in deadlock; si evita limitando a n-1 filosofi, rompendo la simmetria o ordinando le risorse), con soluzioni in C (POSIX) e in Go (canali e sync).Semafori, monitor e problemi classici di sincronizzazione →. Le data race in Go si individuano con go run -race programma.go.
Esercizi collegati
Domande d'esame
1. Qual è la differenza tra concorrenza e parallelismo? Un sistema con un solo core può essere concorrente? E parallelo? Traccia. Concorrenza = più compiti che fanno progressi alternandosi (lo scheduler); parallelismo = più compiti eseguiti nello stesso istante, richiede più unità di elaborazione. Con un core si ha concorrenza ma non parallelismo.
2. Un'applicazione è parallelizzabile al 60%. Qual è lo speedup massimo con 4 core e con infiniti core? Traccia. . Con 4 core: . Con infiniti core: .
3. Confrontare i modelli many-to-one, one-to-one e many-to-many. Quale usa Linux? Traccia. Many-to-one: un thread kernel per tutti, se uno si blocca si bloccano tutti, niente parallelismo. One-to-one: un thread kernel per ogni thread utente, più concorrenza ma costo per thread (Linux e Windows). Many-to-many: multiplexing di molti thread utente su alcuni thread kernel, flessibile ma complesso.
Versione ripasso
- Thread = flusso con PC, registri, stack propri; condivide codice, dati globali, heap, file aperti. Più economico di un processo (creazione e commutazione).
- Vantaggi: reattività, condivisione delle risorse, economia, scalabilità. Svantaggio: dati condivisi -> race condition.
- Concorrenza = più compiti avanzano (anche con 1 core); parallelismo = più compiti nello stesso istante (più core). Dati (stessa operazione su parti diverse) o compiti (operazioni diverse).
- Amdahl: ; per vale . Esempio: , -> 1,6.
- Thread utente (libreria) e thread kernel (sistema). Modelli: many-to-one (un blocco blocca tutti, no parallelismo), one-to-one (Linux, Windows), many-to-many, a due livelli. Linux: tutto è un task, thread con
clone()e flag di condivisione. - Pthreads:
pthread_create(&tid,&attr,f,arg),pthread_join,pthread_exit; compilare con-lpthread. Passare a ogni thread un proprio argomento (elemento di array), mai&idi un ciclo. Rimandi: Sincronizzazione - sezione critica, race condition e mutexQuando più processi o thread accedono agli stessi dati e il risultato dipende dall'ordine degli accessi si ha una race condition (esempio: counter++ e counter-- sono tre istruzioni macchina ciascuna, e interleaving diversi danno 4, 5 o 6 partendo da 5); il problema della sezione critica chiede un protocollo (entry section, sezione critica, exit section, remainder section) che garantisca mutua esclusione, progresso e attesa limitata; disabilitare gli interrupt non scala sui multiprocessori, l'alternanza stretta con turn viola il progresso, la soluzione di Peterson (flag[2] e turn) soddisfa i tre requisiti ma può fallire su hardware moderno per il riordino delle istruzioni, per cui servono barriere di memoria; l'hardware offre test_and_set e compare_and_swap (atomiche) e variabili atomiche; il mutex lock (acquire/release, in POSIX pthread_mutex_t) è lo strumento di alto livello, con attesa attiva se è uno spinlock oppure con sospensione del thread se è un mutex di sistema; bloccare due volte lo stesso mutex senza sbloccarlo causa deadlock.Sincronizzazione - sezione critica, race condition e mutex →. - Threading implicito: fork-join, thread pool (numero di thread limitato, più rapido), OpenMP (
#pragma omp parallel), GCD, TBB. - Questioni:
fork(solo chiamante o tutti),exec(sostituisce tutti i thread), segnali (a chi?), cancellazione (asincrona / differita,pthread_cancelè una richiesta), TLS, PCS/SCS (Linux e macOS: soloPTHREAD_SCOPE_SYSTEM), LWP e scheduler activations. - Go:
go f()avvia una goroutine (leggera, multiplexata dal runtime su thread del sistema);sync.WaitGroup(Add,Done,Wait); canali (ch <- v,v := <-ch, senza buffer = rendez-vous); semainritorna il programma finisce;go run -racecerca data race. - Cosa è per thread / condiviso: per thread PC, registri, stack; condivisi codice, dati globali, heap, file aperti, segnali.
forkcopia la memoria, un thread vede la stessa memoria. - Amdahl, esempio: : ; ; . Ciclo parallelizzabile se le iterazioni sono indipendenti (
arr[i]=i*i), seriale se dipendono (arr[i]=arr[i-1]+i). - Modelli: many-to-one (Solaris Green Threads, GNU Portable Threads: un blocco ferma tutti), one-to-one (Linux, Windows: ogni thread utente ha un thread kernel, costo per thread), many-to-many, a due livelli; LWP = processore virtuale tra libreria e kernel (scheduler activations). Linux: tutto è un task, thread creati con
clone()e flag di condivisione (senza flagfork). Windows: thread kernel con id, registri, due stack (utente e kernel), area privata. - Pthreads, codice minimo:
void *f(void *arg) { struct lavoro *w = arg; /* ... */ return NULL; }
pthread_create(&tid[k], NULL, f, &w[k]); /* un argomento per thread, mai &i del ciclo */
pthread_join(tid[k], NULL); /* compilare con -lpthread */- Somma parallela ( thread, numeri): ogni thread somma elementi in un suo elemento di array; il
mainsomma i parziali dopo ijoin: nessuna race condition, risultato . - Threading implicito: fork-join; thread pool (thread pronti, numero limitato, più rapido che crearne uno); OpenMP (
#pragma omp parallel); GCD (Apple); TBB (Intel). - Questioni:
forkcopia solo il thread chiamante o tutti?execsostituisce tutti; segnali (al thread interessato, a tutti, ad alcuni, a uno designato); cancellazione asincrona o differita (pthread_cancelè una richiesta, punti di cancellazione comeread); TLS (copia propria per thread, diverso dalle variabili locali); PCS (competizione nel processo) e SCS (nel sistema): Linux e macOS soloPTHREAD_SCOPE_SYSTEM. - Go:
go f();sync.WaitGroup(Add,Done,Wait); canalichan T(senza buffer = rendez-vous, con buffer = capacità),ch <- vev := <-ch;close+for range; il programma finisce quandomainritorna;go run -raceper le data race; le goroutine sono leggere (stack di pochi KB) e multiplexate dal runtime sui thread del sistema (many-to-many). - Errori tipici: credere che i thread abbiano memoria separata; passare
&i; dimenticarejoin(il processo termina prima dei thread); confondere concorrenza e parallelismo; applicare Amdahl senza la parte seriale; dimenticarewg.Wait()in Go.