2. Perch´ parallellizzare?
e
Migliora le prestazioni del programma (mentre un processo ` in
e
attesa per esempio in fase di I/O, un altro pu` lavorare).
o
Migliora la distribuibilit` del programma (rendendolo pronto per
a
la scalabilit` orizzontale).
a
` spesso vitale e necessario (come nel caso delle interfaccie
E
grafiche).
`
E utile a volte persino in termini di modularit` e leggibilit` del
a a
codice.
3. Multiprocessing
Con multiprocessing, in questo contesto, intendiamo
l’esecuzione di multipli processi software concorrenti che
abbiano spazi di memoria indipendenti.
I processi su Linux proliferano mediante meccanismo di
generazione. Il processo di sistema operativo che si occupa della
generazione di tutti i processi kernel necessari ` init. Ogni
e
processo ` identificato da un PID univoco.
e
Il meccanismo scelto su linux per la generazione di nuovi
processi ` la cosiddetta fork utilizzando la quale un processo
e
sdoppia il proprio spazio di indirizzi in memoria permetterndo
l’esecuzione di un nuovo processo che sfrutti questa copia.
4. Fork
La funzione fork (int fork()) trasforma un singolo processo in
esecuzione in due processi identici, noti come parent e child.
Quando l’esecuzione ha successo, la fork ritorna 0 al processo
child e il PID del child al processo parent.
#include <unistd.h>
#include <stdio.h>
int main() {
int cpid = fork();
if(cpid > 0) {
printf("I am the father and this is my child: %dn", cpid);
} else if (cpid == 0) {
printf("I am the childn");
} else {
printf("Error!");
}
}
5. Meccanismi di IPC
`
E spesso necessario rendere possibile ai processi creati il
comunicare fra loro.
I sistemi di IPC (Inter-process communication) che Linux
fornisce sono svariati:
Pipe (< stdio.h >)
Signal (< signal.h >)
Message Queues (< sys/msg .h >)
Semaphores (< sys/sem.h >)
Shared Memory (< sys/shm.h >)
Socket (< sys/socket.h >)
6. Pipe
Le Pipe sono oggetti grazie ai quali l’output di un processo pu`
o
essere iniettato in input ad un altro processo.
Linux fornisce due modi di definire pipe:
1. FILE* popen(char* command, char* type) - apre una pipe
ove command ` il processo con il quale ci vogliamo connettere.
e
Il tipo pu` essere “r” in lettura o “w” in scrittura. Le pipe cos`
o ı
aperte vanno chiuse tramite pclose(FILE* fs) e vengono
scritte/lette tramite l’utilizzo di fprintf e fscanf.
2. int pipe(int fd[2]) - crea una pipe restituendo due file
descriptor. fd[0] ` aperto in lettura ed fd[1] ` aperto in
e e
scrittura. Le pipe vanno sempre chiuse mediante metodo
close(int fd). Vengono sfruttati i metodi read e write.
8. Signal
I segnali sono interrupt generati via software che possono essere
inviati ad un processo per segnalargli che ` avvenuto un evento.
e
I segnali possono essere generati in maniera sincrona, ma sono
genealmente meccanismi asincroni. Essi sono identificati
univocamente da un numero intero compreso tra 0 e 31.
Due sono i modi pi` comuni per l’invio di un segnale:
u
int kill(int pid, int signal) - ` una chiamata di sistema che
e
permette di emettere un segnale. Se il pid ` maggiore di zero, il
e
processo da esso identificato sar` l’unico destinatario; nel caso
a
in cui il pid sia zero, il messaggio sar` inviato a tutti.
a
int raise(int signal) - invia un messaggio al processo running.
Utilizza kill in maniera trasparente ponendo come pid quello del
processo che la esegue.
9. Signal
Un’applicazione pu` definire una funzione cosiddetta che verr`
o a
invocata quando uno specifico segnale viene ricevuto.
L’handler per i segnali viene definito tramite la chiamata int
(*signal(int sig, void (*func)()))(); ricevuto il segnale
identificato da sig, la funzione func verr` invocata. func pu`
a o
assumere tre valori:
SIG DFL - funzione di default; il processo sar` terminato una
a
volta ricevuto il segnale
SIG IGN - funzione ignore; il segnale verr` catturato ed ignorato
a
un generico puntatore a funzione.
10. #include <stdio.h>
#include <signal.h>
void sigproc(void);
void quitproc(void);
main()
{ signal(SIGINT, sigproc);
signal(SIGQUIT, quitproc);
printf(‘‘ctrl-c disabled use ctrl-$backslash$$backslash$ to quit$backslash$n’’);
for(;;); /* infinite loop */}
void sigproc()
{ signal(SIGINT, sigproc); /* */
/* NOTE some versions of UNIX will reset signal to default
after each call. So for portability reset signal each time */
printf(‘‘you have pressed ctrl-c $backslash$n’’);
}
void quitproc()
{ printf(‘‘ctrl-$backslash$$backslash$ pressed to quit$backslash$n’’);
exit(0); /* normal exit status */
}
11. Message Queues
Le code di messaggi si basano su uno schema molto semplice.
Un processo piazza un messaggio all’interno di una struttura
(una coda) fornita dal sistema operativo. Un altro processo,
conoscendo l’identificativo della coda da cui leggere potr`a
successivamente recuperare il messaggio.
Una coda di messaggi pu` essere inizializzata mediante il
o
metodo int msgget(key t key, int msgflg); il metodo ritorna
l’identificativo univoco della coda - i parametri specificati
rappresentano il nome assengato alla coda e dei flag utili per il
management dei diritti.
Un messaggio viene inserito nella coda tramite il metodo
msgsnd e viene invece recuperato mediante msgrcv.
12. Semaphores
I semafori costituiscono un fondamentale sistema di coordinamento
tra processi.
Qualora due o pi` processi condividano la stessa risorsa mediante
u
meccanismi di IPC ` vitale che l’accesso a tale risorsa sia
e
correttamente gestito (osserveremo meglio questo problema nella
parte dedicata ai thread).
Un semaforo viene inizializzato (o se ne ottiene accesso) tramite: int
semget(key t key, int nsems, int semflg); ove, key rappresenta un
nome associato all’id univoco che la funzione ritorna; nsems indica il
numero di semafori che si vogliono ottenere e semflg sono i flag di
gestione assegnati al semaforo.
Operazioni sul semaforo vengono eseguite mediante la direttiva int
semop(int semid, struct sembuf *sops, size t nsops);
13. Shared Memory
La shared memory rappresenta il metodo, forse pi` veloce di scambiare dati fra
u
processi differenti. In sostanza, un processo crea un’area di memoria alla quale
altri processi possono accedere (se viene loro dato diritto). Questo metodo si
porta ovviamente dietro il problema della condivisione di una risorsa e pertanto
porta all’insorgenza della necessit` di sincronizzare l’accesso da parte dei processi
a
alla memoria condivisa.
Un segmento di memoria condivisa ` acceduto tramite int shmget(key t key, size t
e
size, int shmflg);
Per potere usare la memoria condivisa, il processo deve farne attach al suo spazio
di indirizzi; questo viene fatto tramite void *shmat(int shmid, const void
*shmaddr, int shmflg); che ritorner` in shmaddr l’indirizzo alla head della
a
memoria condivisa.
Il processo rilascia l’accesso tramite int shmdt(const void *shmaddr);
Trascuro per ora le problematiche di sincronizzazione, essendo queste simili per
molti versi a quelle successivamente trattate nella parte dedicata ai thread.
14. Socket
Le Socket costituiscono un sistema di IPC punto-punto e bidirezione. Pur
non essendo il sistema pi` veloce di comunicazione, la loro versatilit` e la
u a
possibilit` di usarle attraverso la rete le rendono molto usate.
a
Esistono 4 tipi di socket:
Stream Socket (SOCK STREAM) - bidirezionali, sequenziali, affidabili
e non duplicati senza limiti di trasmissione. Usa TCP.
Datagram Socket (SOCK DGRAM) - bidirezionali, non assicurano
l’ordine dei messaggi che sono costituiti da pacchetti di dimensione
finita. Non necessita di connessione. Usa UDP
Sequential Packet Socket (SOCK SEQPACKET) - essenzialmente un
Datagram ma sequenziale, affidabile e con connessione. Mai
implementato.
Raw - utilizza direttamente i protocolli di comunicazione.
15. Socket
Le Socket vengono create mediante la funzione int socket(int domain, int type, int
protocol) (se il protocollo non ` specificato esso sar` il default per il tipo). Le Socket si
e a
collegano le une alle altre tramite indirizzi. Per assegnare un indirizzo ad una socket si
usa il metodo int socket(int domain, int type, int protocol).
Le connessioni tramite Socket sono solitamente non bilanciate (client-server). Il server
(in una connessione STREAM) chiama int listen(int s, int backlog) ove backlog
rappresenta il numero massimo di connessioni da accodare.
Il client per collegarsi al Server chiamer` int connect(int s, struct sockaddr *name, int
a
namelen) e, infine, il server, per accettare la connessione eseguir` int connect(int s,
a
struct sockaddr *name, int namelen) che restituisce il file descriptor di una socket
dedicata esclusivamente alla connessione.
In una socket stream, la ricezione e l’invio dei messaggi sono eseguiti mediante i metodi
read() e write() o, alternativamente, recv(...) e send(...).
In una socket datagram, invece si usano i metodi sendto() e recvfrom() o, in alternativa,
recvmsg() e sendmsg.
16. Soluzioni differenti - OpenMP
OpenMP ` una API che supporta il multiprocessing con memoria condivisa.
e
La semantica della libreria ` cos` semplice da farla apparire quasi come una funzionalit` del linguaggio.
e ı a
La libreria si occupa in maniera del tutto trasparente per il programmatore del partizionamento dei dati e della
distribuzione tra i core del codice.
#include <stdio.h>
int main(void)
{
#pragma omp parallel
printf("Hello, world.n"); // Verr` stampato una volta per ogni processore
a
return 0;
}
int main(int argc, char *argv[]) {
const int N = 100000;
int i, a[N];
#pragma omp parallel for // k cicli saranno eseguiti in parallelo (con k numero di processori)
for (i = 0; i < N; i++)
a[i] = 2 * i;
return 0;
}
17. Multithreading
Possiamo considerare un Thread come un lightweight Process.
L’utilizzo di soluzioni multithread ha grandi benefici:
Aumenta la responsivit` - in presenza di GUI, ad esempio,
a
permette di dedicare un thread ad ogni attivit` grafica.
a
Usa in maniera pi` efficiente i sistemi multiprocessore - ` pi`
u e u
facile scalare rispetto al numero di processori applicazioni
multithread.
Migliora la struttura di programma - molti programmi risultano
meglio strutturati come sistemi a parti semi-indipendenti che
come monoliti.
18. Confronto tra Multiprocessing e
Multithreading
I benefici dei thread rispetto ai processi consistono essenzialmente in:
minor tempo per essere creati (dato che sfruttano l’address
space senza copiarlo)
minor tempo per essere terminati
minor tempo per switchare da un thread ad un altro (rispetto a
quello richiesto per switchare da un processo all’altro)
minore overhead dovuto alla comunicazione
Il vantaggio dei processi sui thread `, invece rappresentato da un
e
maggior livello di isolamento.
19. Pthreads
La libreria pi` importante per la realizzazione di codice
u
multithread ` libpthread (pthread.h).
e
Per creare un thread si usa int pthread create(pthread t *tid,
const pthread attr t *tattr, void*(*start routine)(void *),
void *arg); - se non si passano attributi, il thread sar` non
a
detachato e erediter` la priorit` del padre.
a a
La libpthread ` parecchio complessa e contiene molti metodi
e
per la gestione e l’inizializzazione dei thread dei quali non
tratteremo qui (creare variabili thread local, gestione priorit`,
a
ecc...).
20. Sincronizzazione
La libpthread offre anche vari meccanismi di sincronizzazione fra i
thread. La sincronizzazione in questo caso ` vitale poich´ i thread
e e
condividono lo stesso spazio di indirizzi.
Mutual exclusion locks (Mutex)
Condition variable attributes
Semaphores (non li tratteremo in quanto molto simili a quelli
visti per i processi)
21. Sincronizzazione
Mutex
I mutex rappresentano un sistema molto diffuso di serializzazione delle
operazioni; essi sincronizzano i thread serializzando l’accesso a
porzioni del codice definite sezioni critiche.
Un mutex viene creato tramite le funzione int
pthread mutex init(pthread mutex t *mp, const
pthread mutexattr t *mattr); - ove il passaggio degli attributi ` del
e
tutto arbitrario.
Esistono mutex di varia natura (unici, condivisi, scoped, ecc...) e, le
varie tipologie, se non fornite in libreria, sono facilmente realizzabili.
I mutex vengono lockati e slockati rispettivamente tramite le funzioni
pthread mutex lock e pthread mutex unlock.
22. Sincronizzazione
Condition Variables
Le Condition Variables sono usate per bloccare atomicamente un thread finch´ un e
determinato evento non si verifica; esse sono sempre utilizzate in congiunzione con
Mutex e lock. Si controlla la condizione dopo avere acquisito il lock e, nel caso
questa non sia verificata ci si mette in wait sulla condizione mentre atomicamente
si rilascia il lock. Quando un altro thread cambia la condizione e notifica il cambio
si riacquisisce il lock e si rivaluta la condizione.
Le Condition Variables sono inizializzate cos` int
ı
pthread cond init(pthread cond t *cv, const pthread condattr t *cattr);
Varie versioni del metodo wait (semplice, temporizzata, ecc...) possono essere
usate per attendere che la condizione si verifichi
La condizione pu` essere segnalata mediante: int
o
pthread cond signal(pthread cond t *cv);.
Il modo corretto di mettersi in attesa su una condition variable ` il seguente:
e
pthread_mutex_lock();
while(condition_is_false) // previene Spurious WakeUp
pthread_cond_wait();
pthread_mutex_unlock();
23. Moderne Amenit`
a
Negli anni, molte altre librerie che abilitano il multithreading sono
nate. Fra queste ritroviamo:
boost::thread - contiene tutti i maggiori paradigmi di gestione
dei thread e della loro sincronizzazione
Qt - dispone di versioni ad alto livello di trasparenza delle
funzionalit` fin qui esposte
a
24. Asincronia
Risulta spesso utile non assegnare specificatamente un job ad un
thread ma mantenere un pool di thread detti workers ed assegnare ad
uno di essi un job solo quando si verifichi uno specifico evento che lo
richieda. Quest’idea ` alla base dei pattern reactor e proactor.
e
Un sistema di questo tipo ` detto asincrono in quanto si oppone al
e
classico meccanismo sincrono basato su loop e polling o wait su
condition variable in cui un job ` assegnato sempre e solo ad un
e
thread (nel quale quindi, per ogni job viene creato un nuovo thread).
Questo sistema ` vincente in quanto ottimizza le prestazioni del
e
programma sia in termini di cicli eseguiti che in termini di memoria
occupata; tra l’altro esso rende la quantit` di risorse necessarie al
a
programma prevedibile in ogni momento e non strettamente
dipendente dagli input.
25. Asincronia
Un problema molto sentito ` quello della gestione multithread di un
e
sistema Thread che riceve richieste da vari client. Se le richieste
fossero servite sequenzialmente, non sarebbe possibile gestire richieste
concorrenti.
La prima soluzione che ci viene in mente ` quella di creare un thread
e
per ciascuna richiesta ricevuta. In questo modo sicuramente riceviamo
il problema di gestione ma si presenta un nuovo problema. Se, infatti,
ricevessimo nello stesso istante una gran quantit` di richieste, eg.
a
10000, avremmo uno spawn istantaneo di 10000 thread che potrebbe
portare al kill del nostro processo da parte del sistema operativo.
La soluzione a questo problema (peraltro noto come c10k problem) si
basa proprio su meccanismi asincroni come quelli descritti
precedentemente; molti di questi si basano sull’utilizzo di select e
poll.
26. select e poll
select e poll sono potenti strumenti per il multiplexing delle socket. Un
programmatore pu` usare questi strumenti per scoprire quando un evento di
o
scrittura sia avvenuto su una socket cos` da comprendere che ` il momento
ı e
di leggere.
La select funziona come segue: int select(int nfds, fd set *readfds, fd set
*writefds, fd set *errorfds, struct timeval *timeout); ove nfds ` il fd di
e
valore pi` alto, readfds, writefds e errorfds sono dei set di file descriptor da
u
controllare e timeout ` un eventuale timeout che scatta qualora non
e
avvengano eventi su nessun file descriptor. La funzione torna il numero di
fd su cui ` avvenuto un evento e riempie i set con solo i file descriptor su
e
cui sono avvenuti eventi.
La poll ` un’evoluzione della select che ne migliora essenzialmente la
e
semantica. Essa permette di specificare il tipo di eventi su cui porsi in
ascolto (oltre a supportare un numero maggiore di eventi). Ad oggi poll() ` e
deprecata e sarebbe bene usare la nuova versione detta epoll().