Sei sulla pagina 1di 12

4.

1 Introduzione ai thread
venerdì 2 agosto 2019 12:26
Nel modello introdotto nel capitolo precedente abbiamo considerato che un processo è un programma in esecuzione con un unico percorso di
esecuzione.
Nei sistemi moderni si è esteso il concetto di processo, facendo sì che tale diventasse multithread, ovvero la CPU esegue sempre un solo
processo alla volta ma il processo invece può svolgere più compiti alla volta visto che può avere più percorsi di esecuzione, appunto più
thread.

Il modello a processi che abbiamo sostanzialmente discusso fin


ora si basa su due concetti indipendenti, ovvero il
raggruppamento delle risorse e l'esecuzione. Qualche volta è
utile separarli ed è qui che entrano in gioco i thread. Un processo
può essere visto come un modo per raggruppare risorse tra loro
correlate, ha uno spazio di indirizzamento che contiene al suo
interno il codice del programma, i dati, i file aperti, registri, stack
e, raggruppando tali informazioni sotto forma di processo, queste
risorse possono essere gestite più facilmente. L'altro concetto che
un processo racchiude è quello di thread di esecuzione, ovvero di
percorso di esecuzione, infatti un thread un proprio
identificatore, ha un program counter che tiene traccia della
prossima istruzione da eseguire, ha dei registri propri, che
mantengono le variabili di lavoro correnti ed ha un proprio stack che contiene la storia dell'esecuzione, con un elemento per ogni procedura
che è stata chiamata ma non è ancora terminata. Il thread e il suo processo quindi possiamo vederli come concetti diversi e possono essere
trattati separatamente, quindi possiamo dire che i processi vengono usati per raggruppare le risorse mentre i thread sono le entità schedulate
per l'esecuzione nella CPU.
Riassumendo quindi possiamo dire che un processo pesante è un processo classico, come quello studiato fin ora, dove vi è un solo percorso di
esecuzione per processo, ovvero un singolo thread; in un processo multithread invece i thread sono di più per un singolo processo, ed è per
questo che un processo può svolgere più compiti in modo concorrente, dove ogni thread ha le proprie risorse (che abbiamo già detto) ma
condivide con gli altri thread le risorse del processo a cui appartengono ovvero codice, dati e altre risorse come i file.

❖ Motivazioni
Cerchiamo di capire quali sono le motivazioni per cui è conveniente utilizzare un sistema multithread.
La maggior parte delle applicazioni per i moderni calcolatori è di questo tipo; un esempio lampante può essere il browser, che può avere un
thread per la rappresentazione sullo schermo di immagini e testo, un altro thread per il reperimento dei dati in rete ecc.
Le applicazioni inoltre possono essere anche progettate per sfruttare le capacità di elaborazione sui sistemi multicore, visto che possono
eseguire più attività che utilizzano intensamente la CPU in parallelo sfruttando i diversi core di elaborazione.
In alcune situazioni inoltre una singola applicazione deve poter gestire molti compiti simili tra loro, consideriamo come esempio un server. Un
server web accetta, dai client, richieste di pagine Web, immagini, suoni ecc. e dunque se il server è intensamente utilizzato potrebbero esserci
migliaia di client che vi accedono in modo concorrente, ed è chiaro che, se il server fosse eseguito come un processo tradizionale a singolo
thread, sarebbe in grado di soddisfare un solo client alla volta e gli altri client dovrebbero aspettare un tempo che può essere anche molto
lungo, prima che la sua richiesta venga servita.
Una soluzione, che veniva utilizzata in passato, prima dell'avvento del multithreading, era quella per cui si eseguiva il server come singolo
processo e al momento di una richiesta il server creava un processo separato, per la gestione della richiesta. Poiché creare un processo è un
compito oneroso, è chiaro che è bene rivolgersi ad un approccio multithread.
I thread sono fondamentali anche nei sistemi che impiegano le RPC, ovvero le chiamate a procedure remote, che sono sistemi che mettono a
disposizione un sistema di comunicazione tra i processi, fornendo un meccanismo simile a quello delle normali chiamate a funzione o a
procedura.
Molti kernel infine hanno adottato l'approccio multithread, con dei thread specifici dedicati a specifici servizi, come la gestione dei dispositivi
periferici, della memoria e delle interruzioni.

❖ Vantaggi
Possiamo categorizzare i vantaggi della programmazione multithread in quattro grandi categorie, ovvero:
• Tempo di risposta. Se si rende multithread un'applicazione interattiva, possiamo permettere ad un programma di continuare la sua
esecuzione, anche se una parte di esso è bloccata o sta eseguendo un'operazione lunga. In questo modo riduciamo sicuramente il
tempo di risposta all'utente; ciò viene sfruttato in modo particolare nelle interfacce utente.
• Condivisione delle risorse. I processi possono condividere le risorse soltanto attraverso le tecniche di memoria condivisa e scambio di
messaggio, ovvero tramite le tecniche di comunicazione. Tali devono essere esplicitamente messe in atto da chi programma. I thread
invece condividono, come abbiamo visto, per default la memoria e le risorse del processo al quale appartengono.
• Economia. Mentre assegnare risorse e memoria a un nuovo processo è molto oneroso, i thread non hanno bisogno di assegnazioni
perché già condividono memoria e risorse del processo a cui appartengono, quindi è sicuramente molto più conveniente creare
thread. Anche se non è possibile quantificare empiricamente l'overhead prodotto dalla creazione di un processo rispetto ad un thread,
resta comunque il fatto che la creazione e la generazione di processi richiede molto più tempo.
• Scalabilità. I vantaggi della programmazione multithread sono ancora più evidenti nelle architetture multiprocessore, dove i thread si
possono eseguire in parallelo su più core distinti; un processo a singolo thread invece non può che funzionare su un singolo
processore, a prescindere di quanti ne siano a disposizione.
4.2 Programmazione multicore
venerdì 2 agosto 2019 18:46
Abbiamo visto che nell'evoluzione dei calcolatori si è passati innanzitutto da processori singoli alla possibilità di avere più processori su una
singola macchina, passando ai sistemi multiprocessore; abbiamo visto inoltre che la tecnologia ha permesso un'ulteriore evoluzione, passando
da i sistemi multiprocessore, dove su una scheda madre vi sono più processori, ai sistemi multiprocessore multicore, dove invece i processori
risiedono sullo stesso chip e al sistema operativo, nonostante vi sia appunto questo singolo chip, compaiono comunque più processori.
Abbiamo detto inoltre che la programmazione multithread offre un meccanismo che rende l'utilizzo dei multiprocessori molto più efficiente e
soprattutto aiuta a sfruttare al meglio la concorrenza.
Cerchiamo di capire questo vantaggio con un esempio: immaginiamo di avere un'applicazione con 4 thread.

In un sistema monoprocessore l'esecuzione dei thread si definisce concorrente nel senso che , poiché la CPU è in grado di eseguire sempre
una sola cosa alla volta, riesce a svolgere più compiti tramite l'"incastro" di più thread tra loro; in figura infatti vediamo che il singolo chip (in
questo ambito intendiamo un singolo processore), nel tempo, lavora un po' sul thread 1, poi passa al thread 2, poi lavora un po' sul thread 3,
poi sul 4, e ricomincia con il thread 1, e così via.

In un sistema multicore/multiprocessore l'esecuzione dei thread si definisce concorrente nel senso che i thread possono funzionare in
parallelo, concorrentemente su più core.
Si noti quindi la differenza tra parallelismo e concorrenza: un sistema è parallelo se può eseguire simultaneamente più di un task, mentre un
sistema concorrente supporta più di un task consentendo a tutti di progredire nell'esecuzione. La concorrenza può esserci senza
parallelismo. Prima dell'avvento infatti dei sistemi a multielaborazione simmetrica (SMP) e dei multicore, i sistemi erano monoprocessore e
"simulavano" un parallelismo con una rapida commutazione tra i processi del sistema, consentendo ad ogni processo di fare progressi, ma i
processi appunto "simulavano" ma erano in esecuzione concorrente e non parallela.

❖ Le sfide della programmazione


La tendenza, verso i sistemi multicore, tiene sempre di più sotto pressione i progettisti di sistemi operativi e i programmatori di applicazioni,
affinché le loro creazioni utilizzino e sfruttino al meglio il multicore.
Gli algoritmi di scheduling, infatti, che devono scrivere, devono essere algoritmi che permettono l'esecuzione parallela, ma la sfida maggiore
è quella che consiste nel modificare i programmi già esistenti e renderli multithread. Vediamo in ogni caso le principali sfide della
programmazione dei sistemi multicore:
• Identificazione dei task. È necessario infatti esaminare le applicazioni in modo da capire quali sono i pezzi che possono essere suddivisi
in task distinti e concorrenti, da far eseguire poi in parallelo su più core.
• Bilanciamento. L'identificazione dei task, da parte dei programmatori, deve anche far sì che i vari task eseguano compiti di mole e
valore confrontabili, perché, se un certo task non contribuisce al processo tanto quanto gli altri, allora può darsi che non ne vale la
pena di eseguirlo utilizzando core separati.
• Dati separati. Poiché i task utilizzano separatamente i dati, è chiaro che, come bisogna capire come dividere i task, bisogna capire
anche come dividere i dati tra i vari task.
• Dipendenze dei dati. I dati a cui i task accedono devono essere esaminati per verificare le dipendenze tra due o più task.
• Test e debugging. Poiché vi sono più flussi di esecuzione, effettuare dei test e il debugging è sicuramente più complicato rispetto alle
applicazioni a singolo thread.
Viste tali sfide, i programmatori e i progettisti sostengono, in gran parte, che saranno necessari approcci diversi in futuro, per il progetto di
sistemi software.

❖ Tipi di parallelismo
Esistono in generale due tipologie di parallelismo, ovvero il parallelismo dei dati e il parallelismo delle attività.
Il parallelismo dei dati consiste nella distribuzione di sottosistemi di dati su più core e l'esecuzione della stessa operazione su ogni core.
Consideriamo ad esempio la somma dei valori, contenuti in un vettore con dimensione N. Se avessimo un sistema a singolo core avremmo che
il thread andrebbe a sommare i valori dal valore con indice 0 al valore con indice N-1; in un sistema invece dual core, il thread A sul core 0
potrebbe sommare gli elementi da 0 a N/2 -1, il thread B invece sommerebbe, sul core 1, gli elementi da N/2-1 a N-1, e i due thread sarebbero
in esecuzione in parallelo su core distinti.
Il parallelismo delle attività, invece, consiste nella distribuzione delle attività, quindi la distribuzione dei thread stessi, e non dei dati su più
core. Ogni thread, infatti, realizza un'operazione diversa e thread differenti possono operare sugli stessi dati o su dati diversi. Considerando
l'esempio precedente, a differenza del parallelismo dei dati, avremo una situazione in cui potremmo coinvolgere ad esempio due thread,
ciascuno dei quali esegue un'unica operazione statistica sull'array di elementi; i thread lavorano ancora in parallelo su core separati, ma
ciascuno di loro sta eseguendo un'operazione differente.
Il parallelismo dei dati quindi comporta la distribuzione dei dati su più core, mentre quello delle attività comporta la distribuzione dei task su
più core. Poche sono le applicazioni che applicano rigorosamente l'uno o l'altro, nella maggior parte dei casi infatti si applica un ibrido tra le
due strategie.
4.3 Modelli di supporto al multithreading
sabato 3 agosto 2019 12:14
Anche i thread, come i processi, possono essere distinti in thread a livello utente e in thread a livello kernel. I thread a livello utente sono
gestiti sopra il livello del kernel e senza il suo supporto, mentre i thread a livello kernel sono gestiti direttamente dal sistema operativo. Tutti i
sistemi operativi esistenti, in pratica, supportano i thread del kernel. Dobbiamo capire ora la relazione che hanno queste due tipologie di
thread, e per questo analizziamo tre opzioni comuni.

❖ Modello da molti a uno


Il modello da molti a uno fa corrispondere a molti thread a livello utente un singolo thread a
livello kernel. Poiché si svolge nello spazio utente, tramite l'utilizzo di una libreria di thread nello
spazio utente, la gestione dei thread risulta efficiente, ma l’intero processo rimane bloccato se un
thread invoca una chiamata di sistema di tipo bloccante. Inoltre, poiché un solo thread alla volta
può accedere al kernel, è impossibile eseguire thread multipli in parallelo in sistemi
multiprocessore. Pochissimi sono i sistemi che utilizzano ancora tale modello, perché è appunto
inutile in presenza di più core.

❖ Modello da uno a uno


Il modello uno a uno mette in corrispondenza ciascun thread a
livello utente con un thread a livello kernel. Offre, tale modello,
sicuramente un grado di concorrenza maggiore rispetto al modello
precedente dal momento che il problema legato alla chiamata
bloccante non persiste più; se un thread invoca, infatti, una
chiamata di sistema bloccante, è possibile eseguire un altro thread.
Fornisce inoltre la possibilità di eseguire thread multipli in parallelo
sui sistemi multicore. C'è un unico svantaggio, legato al fatto che
ogni thread utente crea un thread kernel, e il carico dovuto alla
creazione di un thread a livello kernel può compromettere le prestazioni di un’applicazione, la maggior parte delle realizzazioni di questo
modello limita il numero di thread gestibili dal sistema. Tale modello, come approfondiremo, è utilizzato dai sistemi Windows e Linux.

• Modello da molti a molti


Il modello da molti a molti mette in corrispondenza più thread a livello utente con un
numero minore o uguale di thread a livello kernel. Il numero può essere specifico per
una certa applicazione o un particolare calcolatore. Per quanto riguarda la concorrenza,
anche se tale modello permette di creare tanti thread a livello utente quanti se ne
desiderino, ciò non garantisce una concorrenza "reale" in quanto comunque lo
scheduling del kernel sceglie un solo thread alla volta.
C'è però una differenza sostanziale di tale modello migliore visto finora, ovvero il
modello da uno a uno, infatti se tale modello permette maggiore concorrenza e i
programmatori devono però fare attenzione a non creare troppi thread utente, in tale
modello questo problema non persiste, infatti i programmatori possono creare
liberamente i thread che ritengono necessari, e i corrispondenti thread a livello kernel si
possono eseguire in parallelo nelle architetture multiprocessore. Inoltre, se un thread
impiega una chiamata di sistema bloccante, il kernel può fare in modo che si esegua un altro thread.

• Modello a due livelli


Una variante del modello da molti a molti è il cosiddetto modello a due livelli
che possiamo vedere come l'unione del modello da uno a uno e del modello da
molti a molti. Questo modello infatti mantiene sia la corrispondenza fra più
thread utente con un numero minore o uguale di thread del kernel, ma
permette anche di vincolare un thread utente a un solo thread del kernel.
4.4 Librerie dei thread
sabato 3 agosto 2019 12:44
La libreria dei thread fornisce al programmatore una API per la creazione e la gestione dei thread. I metodi con cui implementare una libreria
dei thread sono essenzialmente due ovvero:
1. La libreria è interamente collocata a livello utente, dunque il suo codice e le sue strutture dati risiedono tutte a livello utente. Invocare
quindi una funzione della libreria vuol dire fare una chiamata locale ad una funzione e non una chiamata di sistema.
2. La libreria è implementata a livello kernel, dunque c'è l'ausilio diretto del sistema operativo, e il suo codice e le sue strutture dati si
trovano quindi nello spazio del kernel. Invocare una funzione della libreria vuol dire fare una chiamata di sistema.
Le librerie maggiormente in uso sono attualmente 3, ovvero Pthreads di POSIX, Windows e Java; Pthreads può essere realizzata sia come
libreria a livello utente che a livello kernel, Windows invece è unicamente a livello kernel, strettamente per Windows, infine Java è gestibile
direttamente dai programmi Java, ma visto la peculiarità di funzionamento della Java Virtual Machine (che è quasi sempre eseguita su un
sistema operativo ospitante), l'API di Java per i thread è implementata tramite una libreria dei thread del sistema ospitante; per questo motivo
i thread di Java sui sistemi Windows sono in effetti implementati tramite l'API Windows, che è il sistema ospitante, su Unix e Linux invece si
adopera Pthreads.
Ricordiamo un attimo la differenza tra dati locali e dati globali. I dati locali sono dati che sono definiti (ed il loro uso dichiarato) nella funzione
(o nel blocco) che li usa; nascono quando la funzione entra in esecuzione e muoiono al termine dell'esecuzione della funzione. I dati globali
invece sono definiti fuori da qualunque funzione, il loro tempo di vita è il tempo di vita dell'intero programma, mentre la sua visibilità è estesa
su ogni file in cui sono dichiarati (dalla dichiarazione in avanti).
Detto ciò vediamo come i dati sono gestiti nelle librerie di thread. Per quanto riguarda i dati globali, in Windows e POSIX sono condivisi tra
tutti i thread appartenenti allo stesso processo, mentre in Java, poiché in Java non c'è nozione dei dati globali, l'accesso ai dati condivisi deve
essere assegnato in modo esplicito tra i thread. Per i dati locali invece, questi sono generalmente memorizzati nello stack, e poiché ogni
thread ha il proprio stack allora ogni thread ha la propria copia dei dati locali.
In vista dei paragrafi successivi, dove progetteremo un piccolo programma multithread, analizziamo due strategie di creazione dei thread,
ovvero:
1. Threading asincrono. Una volta che il genitore crea un thread figlio, riprende la sua esecuzione e il genitore e il figlio restano in
esecuzione in modo concorrente. I due thread risultano indipendenti e il thread genitore non attende e non ha bisogno di conoscere
quando suo figlio termina. Poiché non c'è dipendenza allora vi è poca condivisione dei dati tra thread.
2. Threading sincrono. Una volta che il genitore crea un thread figlio, attende che il figlio, o i figli se sono più di uno, terminino la loro
esecuzione per riprendere la propria; tale strategia è chiamata fork-join, nella quale i thread creati dal genitore svolgono il lavoro in
maniera concorrente ma il genitore non può continuare la sua elaborazione finché tali non terminano la loro. Una volta poi che il
thread figlio termina il suo lavoro, dunque termina, questo si "unisce" (appunto c'è il join) con il genitore, e solo dopo che tutti i figli
hanno fatto ciò allora il genitore riprende a lavorare. È chiaro che tale strategia da condivisione dei dati, basti considerare che per
esempio il thread genitore può combinare i risultati calcolati dai suoi figli.

Analizziamo i fondamenti della generazione dei thread in queste tre librerie, e, per farlo, progettiamo un programma multithread che calcola
la somma dei primi N numeri interni non negativi in un thread separato, ovvero che effettui in altre parole tale operazione:

Ciascuno dei tre programmi funzionerà permettendo l'inserimento nella riga di comando dell'indice superiore N della sommatoria.

❖ Pthreads
Con il termine Pthreads ci si riferisce allo standard POSIX (IEEE 1003.1c) dove POSIX è l'acronimo di Portable Operating System Interface, dove
la X simboleggia l'eredità UNIX delle API. Il compito dello standard POSIX è quello di definire alcuni concetti base che vanno seguiti durante la
realizzazione del sistema operativo.
Non si tratta di un’implementazione bensì di una specifica del comportamento dei thread, dunque i progettisti di sistemi possono realizzare la
specifica come meglio credono. I sistemi che implementano le specifiche Pthreads sono di tipo Unix, come Linux, Mac OSX e Solaris. Windows,
anche se non supporta nativamente Pthreads, ci sono implementazioni di terze parti anche per tale sistema. Analizziamo il codice che risolve il
nostro problema della sommatoria, scritto in C (chiaramente è una versione esemplificata dell'API Pthreads.
Tutti i programmi che impiegano la libreria Pthreads devono includere il file d'intestazione "pthread.h". All'inizio, prima del main, abbiamo la
dichiarazione di sum, che sarà il dato condiviso dai thread, e poi c'è la dichiarazione della funzione runner che verrà esplicitata dopo, come di
norma in C, e che è la funzione che sarà colei che farà partire i thread, un po' come la funzione run di Java, e che come possiamo vedere ha al
suo interno la somma effettiva. Inizialmente vi è un solo thread di controllo principale, che parte con il main; vengono dichiarati poi nel mai tid
che è l'identificatore del secondo thread che verrà creato più tardi, e attr che invece è l'identificatore dell'insieme degli attributi del thread.
Dopo i vari controlli, con la chiamata a funzione pthread_attr_init(&attr) si passa attr a questa funzione che assegna appunto gli attributi a
tale variabile, in tal caso si assegnano valori predefiniti visto che non sono stati dichiarati. La chiamata a funzione poi pthread_create crea un
nuovo thread, il secondo dopo quello principale, passandogli l'identificatore del thread, i suoi attributi, il nome della funzione da cui il nuovo
thread inizierà l'esecuzione e il numero intero fornito come parametro alla riga di comando ed individuato da argv[1]. Il programma ha quindi
due thread di cui diciamo il "main" è il genitore mentre quello che si viene a creare nel main è il figlio. Seguendo la strategia fork-join, dopo la
creazione del figlio il padre ne attende il completamento e sancisce tale strategia con la chiamata pthread_join di cui attende la fine, per poi
stampare la somma. Il figlio invece termina con la chiamata a funzione, stesso chiamata nella runner, pthread_exit(0).
Questo programma di esempio va a creare un solo thread. Nel caso di più thread, l'utilizzo della pthread_join può essere racchiusa in un ciclo
for.
❖ Thread in Windows
La tecnica che utilizza la libreria di Windows richiama per certi versi quella di Pthreads. Analizziamo ora il codice in C, necessario per la
risoluzione del nostro problema di sommatoria.
Come è ovvio che sia, anche nel caso di Windows dobbiamo prima di tutto includere la libreria "Windows.h". Come nel caso della libreria
Pthreads, la variabile sum è un dato globale, condiviso tra i thread. Tale dato è di tipo DWORD, ovvero sostanzialmente un tipo intero di 32 bit,
unsigned. La funzione che eseguirà il nuovo thread, quello che verrà creato dal flusso, dal thread principale, è la funzione summation, che
stavolta è stata direttamente dichiarata prima del main (si può fare), alla quale si passa un puntatore a void, che in Windows si indica con
LPVOID e fa il lavoro di somma (come running in Pthreads).
Con il main abbiamo l'avvio del thread principale, che dichiara, come avveniva in Pthreads, dichiara l'identificatore del nuovo thread che si
andrà a creare, e il gestore dell'thread (ovvero l'handle). Dopo i vari controlli, si crea il secondo thread tramite la funzione CreateThread che
restituisce un ThreadHandle e prende in ingresso tutti gli attributi necessari, che in Windows sono attributi di sicurezza, dimensione dello
stack, la funzione che deve svolgere il thread (che abbiamo detto che è summation), i parametri alla funzione del thread, un flag che è un
indicatore per segnalare se il thread debba avere inizio nello stato di attesa o meno, e infine l'identificatore del flag. In questa situazione
stiamo considerando che il flag sia impostato in modo tale che il thread non parta nello stato d'attesa, ma che appunto nasca come "pronto" e
in esecuzione. La funzione in Windows per impostare il fork-join, e dunque far attendere il thread genitore fino alla fine dell'esecuzione del
thread figlio, è WaitForSingleObject per quanto riguarda un singolo thread figlio, mentre nel caso dell'attesa della terminazione di più thread
allora si utilizza la funzione WaitForMultipleObject, alla quale vengono passati il numero di thread figli da attendere, un puntatore al vettore
dei thread figli, un flag e la durata del time-out, che può essere anche INFINITE.
• Thread in Java
Il linguaggio Java mette a disposizione una ricchissima libreria per la creazione e la gestione dei thread. Anche nel caso di Java esiste sempre
un thread principale di controllo, quello che nasce con il metodo (ricorda che in Java le funzioni si chiamano metodi) main(), e anche un main
vuoto viene eseguito dalla Java Virtual Machine come un singolo thread. I suoi thread sono disponibili su tutti i sistemi operativi che
dispongono di una JVM.
In java un thread è un oggetto da istanziare quando appunto vogliamo istanziare il thread.
Per attivare un thread occorre creare quindi un oggetto della classe thread a cui devono essere associate le istruzioni da eseguire nel nuovo
thread. Il nuovo thread non comincia la sua esecuzione all'atto della creazione infatti per avviare l'esecuzione occorre richiamare il metodo
start () dell'oggetto Thread.
Possiamo istanziare il thread in due modi:
1. Creando una classe derivata da Thread (che è una classe astratta)
2. Creando una classe che implementa Runnable (che è un'interfaccia) e passando un oggetto di questa classe al costruttore di Thread.
Analizziamo il secondo caso, ovvero il caso in cui implementiamo l'interfaccia Runnable. L'interfaccia ha al suo interno un unico metodo da
implementare ovvero il metodo astratto run. Una classe che decide di implementare l'interfaccia Runnable è costretta ad implementare anche
il metodo astratto run.
Abbiamo innanzitutto la classe Sum che è una classe che ha un attributo sum e due metodi, ovvero i metodi getter e setter. Abbiamo poi la
classe di nostro interesse, la classe Summation, che è la classe che implementa Runnable; ha due attributi che sono upper e sumValue che è
un oggetto della classe Sum, poi il metodo run, che tramite un ciclo for setta l'oggetto sum tramite il suo metodo, passandogli il valore
calcolato. Abbiamo infine la classe principale, la classe pilota Driver, dove è implementato il main, dove abbiamo il thread principale che parte
appunto con il main, dove si istanzia l'oggetto di tipo sum, si definisce il valore di upper, si crea poi un thread, istanziandolo come un oggetto
tramite il costruttore della classe Summation (che implementando runnable può costruire un thread). Il metodo start fa partire tutto;
ricordiamo la differenza tra run e start: run serve a definire le operazioni che deve fare il thread creato, start da inizio alla sua esecuzione.
Resta da notare che, nel blocco try-catch, il thread figlio ha chiamato il metodo join che è l'equivalente del metodo WaitforSingleObject e
pthread_join, per far sì che vada in atto il fork-join.
4.5 Threading implicito
domenica 4 agosto 2019 21:50
Nei paragrafi precedenti abbiamo visto le varie sfide della programmazione, legate al mondo dei thread, che è in continuo e rapido sviluppo e
che nonostante i grandi vantaggi che apporta è una tipologia di programmazione complessa. I thread arriveranno, con la continua evoluzione
dell'elaborazione multicore, ad essere addirittura centinaia, se non migliaia, e le difficoltà di programmazione aumenteranno oltre le sfide già
citate. Un modo per affrontare tali ostacoli e gestire meglio la progettazione è il cosiddetto threading implicito che consiste nel trasferimento
della creazione e della gestione dei thread dagli sviluppatori ai compilatori e librerie di runtime. Questa strategia è molto comune e di tale
ne analizzeremo 3 approcci.

❖ Gruppi di thread
Abbiamo visto nel paragrafo 4.1 l'esempio di un server web multithread dove, per ogni richiesta ricevuta, il server anziché creare un processo
separato (come faceva in passato) per la gestione della richiesta crea un thread separato; questo perché creare un thread è più semplice e
meno oneroso per il sistema rispetto alla creazione di un processo.
Nonostante tutto ciò però un server multithread presenta delle problematiche. In primis c'è il problema del tempo richiesto per la creazione
del thread prima di poter soddisfare la richiesta, poi vi è il problema più grande riguardante invece il fatto che non si è posto un limite al
numero di thread concorrentemente attivi nel sistema, e un numero illimitato potrebbe chiaramente compromettere le prestazioni ed
esaurire le risorse disponibili. Una possibile soluzione a quest'ultimo problema è l'utilizzo di gruppi di thread (thread pool), che consiste nel
creare un certo numero di thread alla creazione del processo stesso ed organizzarli in gruppo (pool) in cui attendono di eseguire il lavoro che
gli sarà richiesto.
Tornando al nostro esempio, utilizzando tale metodo, quando un server riceve una richiesta non fa altro che attivare un thread del gruppo, se
disponibile, e gli passa la richiesta, e, dopo che tale thread ha eseguito la richiesta del server, torna nel gruppo di attesa. Nel caso in cui non c'è
nessun thread disponibile nel gruppo per eseguire la richiesta, allora il server attende finché non se ne libera uno. I vantaggi di utilizzo di tale
strategia sono:
• Il servizio di richiesta di un thread già esistente è più veloce rispetto alla creazione di un thread.
• Un gruppo di thread va a limitare il numero di thread esistenti in un certo istante.
• Separare il task da svolgere dalla meccanica della sua creazione ci permette di utilizzare diverse strategie per l'esecuzione di tale task.
Il numero dei thread che deve avere un gruppo si può determinare tramite euristiche in base a fattori come tempo di CPU, la quantità di
memoria fisica ecc., e in architetture più moderne è possibile addirittura cambiare dinamicamente il numero all'occorrenza, secondo quindi
l'utilizzo del sistema. Queste ultime infatti, generalmente, utilizzano gruppi di piccolo numero nel caso di un carico di sistema basso, facendo sì
che le prestazioni aumentino.
L'API di Windows mette a disposizione varie funzioni per i gruppi di thread, con uso simile alla funzione Thread_Create(). Si definisce infatti
prima la funzione che il gruppo di thread verrà chiamato ad eseguire, si passa poi un apposito puntatore a tale funzione alle apposite funzioni
dell'API per i gruppi di thread, che avvieranno uno dei thread del gruppo; un esempio di tale funzione è QueueUserWorkItem che prende in
ingresso tre parametri che sono il puntatore alla funzione da eseguire in un nuovo thread, un parametro che tale funzione deve utilizzare e
infine un parametro che indica come il gruppo di thread deve creare e gestire l'esecuzione del nuovo thread. A seguito di ciò ci sarà un thread
che richiamerà la funzione da svolgere assegnata al gruppo.

❖ Open MP
Open MP è un insieme di direttive del compilatore e una API per programmi scritti C, C++ o Fortran, che fornisce il supporto per la
programmazione parallela in ambienti a memoria condivisa. Tale meccanismo definisce delle regioni parallele come blocchi di codice
eseguibili in parallelo, e gli sviluppatori di applicazioni inseriscono direttive del compilatore nei punti del codice dove vi sono regioni parallele,
istruendo una libreria di runtime Open MP per l'esecuzione della regione in parallelo. Vediamo un piccolo esempio di codice in C.

Quando Open MP incontra la direttiva #pragma omp parallel for crea tanti thread quanti sono i core di elaborazione del sistema e tutti i
thread creati eseguono contemporaneamente la regione parallela. Quando poi un thread esce dalla regione parallela viene terminato. Open
MP fornisce anche direttive aggiuntive per l'esecuzione di porzioni di codice in parallelo, tra cui cicli parallelizzati, dando invece come direttiva
#pragma omp parallel for.
Oltre tutte le direttive, con tale metodo lo sviluppatore può anche scegliere il livello di parallelismo. È disponibile su diversi compilatori open-
source e commerciali per tutti i sistemi operativi più frequentemente usati, ovvero Linux, Windows e Mac OS X.
❖ Grand Central Dispatch
GCD, ovvero Grand Central Dispatch, è una tecnologia sviluppata per sistemi operativi Mac OS X e iOS di Apple ed è una combinazione di
estensioni del linguaggio C, una API e una libreria di runtime che permette agli sviluppatori di individuare sezioni di codice da eseguire in
parallelo.
Definisce estensioni ai linguaggi C e C++ chiamate blocchi, che non sono altro che unità di lavoro auto contenute, specificate dal simbolo "^"
ovvero dall'accento circonflesso inserito prima di una coppia di parentesi graffe. Un esempio di blocco è il seguente:

GCD pianifica l'esecuzione a runtime dei blocchi, inserendoli in una dispatch queue ovvero una coda di dispacciamento, e quando GCD lo
rimuove, lo assegna ad un thread disponibile tra quelli che sono presenti nel gruppo di thread che gestisce. Le code di dispacciamento possibili
sono due, ovvero possiamo avere code seriali o code concorrenti.
• I blocchi posti in una coda seriale vengono prelevati secondo un ordine FIFO e quando tale blocco viene prelevato si attende il
completamento della sua esecuzione prima di prelevare un altro blocco. Ogni processo ha una sua propria coda seriale chiamata main
queue. Tali code sono utili per poter assicurare l'esecuzione sequenziale delle varie attività.
• I blocchi posti in una coda concorrente vengono anche loro prelevato secondo un ordine FIFO ma a differenza delle seriali, la coda
concorrente permette il prelievo di più blocchi per volta e permettendo l'esecuzione di tali blocchi in parallelo. Esistono tre code
concorrenti nei sistemi, in base alle priorità legate all'importanza dei blocchi, ovvero coda a priorità bassa, di default e alta.
4.6 Problematiche di programmazione multithread
lunedì 5 agosto 2019 13:58
❖ Chiamate di sistema fork() ed exec()
Nel Capitolo 3 è descritto l’uso della chiamata di sistema fork() per la creazione di un nuovo processo tramite la duplicazione di un processo
esistente e abbiamo parlato della exec( ). Tutto ciò era relativo ai processi, per quanto riguarda ora, invece, i thread la semantica cambia
infatti:
• Per quanto riguarda la fork, se un thread in un programma invoca la chiamata di sistema fork(), il nuovo processo potrebbe, in
generale, contenere un duplicato di tutti i thread oppure del solo thread invocante.
• Per quanto riguarda la exec, se un thread invoca la chiamata di sistema exec(), il programma specificato come parametro della exec( )
sostituisce l’intero processo, inclusi tutti i thread.
La exec quindi ha un comportamento diverso ma unico, mentre la fork ha un duplice possibile comportamento e l'uso di una versione o di
un'altra dipende dall'applicazione; se si va a invocare ad esempio la exec subito dopo la fork allora la duplicazione dei thread non è necessaria,
dal momento che il programma specificato nei parametri della exec sostituirà il processo; se la exec non si esegue immediatamente dopo la
fork allora potrebbe essere utile una duplicazione di tutti i thread del processo genitore.

❖ Gestione dei segnali


Nei sistemi Unix, per comunicare ai processi il verificarsi di qualche evento, si usano i segnali.
Questi possono essere ricevuti sia in modo sincrono che asincrono, in base alla sorgente e in base alla ragione per cui l'evento viene segnalato.
In ogni modo, a prescindere da come essi vengano ricevuti, tutti i segnali seguono uno stesso schema secondo cui all'occorrenza di un
particolare evento si genera un segnale, questo viene inviato al processo e una volta ricevuto il segnale deve venire gestito.
Nel caso di segnali sincroni, come il caso in cui vi è un accesso illegale in memoria o una divisione per zero, questi vengono inviati allo stesso
processo che ha eseguito l'operazione che ha causato il segnale; nel caso dei segnali asincroni, come il caso di una terminazione di un
processo richiesta con una specifica combinazione di tasti o la scadenza di un timer, il segnale è causato da un evento esterno al processo in
esecuzione e tale processo riceve comunque il segnale. In altre parole, sincrono è quando il segnale è causato da un evento interno al
processo, mentre asincrono è quando il segnale è causato da un evento esterno al processo, e in ogni caso il segnale viene ricevuto dal
processo in questione.
Ogni segnale si può gestire poi in due modi, ovvero tramite un gestore predefinito dei segnali, che esiste per ogni segnale e che il kernel
esegue quando deve gestire il segnale, oppure tramite un gestore di segnali definito dall'utente, che è una sostituzione del gestore
predefinito. In ciascun caso l'handler, ovvero il gestore, può scegliere di ignorare semplicemente il segnale oppure può gestirli terminando
l'esecuzione del programma.
Per i processi che hanno un unico thread la gestione dei segnali è semplice perché questi vengono sempre inviati al processo stesso; il
problema con i segnali sorge per i processi multithread, perché appunto sorge il problema di capire a quale thread inviare il segnale. Esistono
in generale le seguenti possibilità:
• Delivery mirato, che consiste nell' inviare il segnale al thread a cui il segnale si riferisce.
• Delivery generale, che consiste nell' inviare il segnale a tutti i thread del processo.
• Delivery di gruppo, che consiste nell'inviare il segnale a specifici thread del processo.
• Delivery, che consiste nel definire un thread specifico a cui verranno inviati tutti i segnali diretti al processo.
Il metodo per recapitare il segnale dipende dal tipo di segnale, infatti i segnali sincroni si devono inviare al thread che ha generato l'evento
causa del segnale e non agli altri thread, mentre se si tratta di segnali asincroni la situazione è sicuramente meno chiara: alcuni segnali
asincroni vengono inviati a tutti i thread.
La funzione standard in Unix per l'invio di un segnale è:

Tale specifica, nei parametri che prende in ingresso, il processo (pid di tipo pid_t) e il segnale che deve essere recapitato a tale processo
(l'intero signal). Nelle versioni multithread di Unix permettono, in gran parte, di indicare per ciascun thread i segnali da accettare o da
bloccare, quindi alcuni segnali asincroni si potrebbero recapitare soltanto ai thread che non li bloccano. Eppure, poiché i segnali vanno gestiti
una sola volta, un segnale in genere viene recapitato solo al primo thread che non lo blocca.
La stessa funzione, però dell'API Pthreads di POSIX

In tal caso è però specificato il thread a cui recapitare il segnale.


Windows in genere non prevede la gestione esplicita dei segnali, questi però si possono emulare con le cosiddette chiamate di procedure
asincrone APC. Le APC permettono a un thread a livello utente di specificare la funzione da richiamare quando il thread riceve la
comunicazione di un particolare evento; è grosso modo equivalente a un segnale asincrono di Unix. Mentre tuttavia in un ambiente
multithread UNIX necessita di un criterio di gestione dei segnali, il sistema delle APC è più semplice, poiché una APC è rivolta a un particolare
thread e non a un processo.

❖ Cancellazione dei thread


La cancellazione dei thread è l’operazione che permette di terminare un thread prima che completi il suo compito; se più thread, ad
esempio, eseguono una ricerca in modo concorrente in una base di dati e un thread riporta il risultato, gli altri thread possono essere
annullati.
Un thread da cancellare è spesso chiamato thread bersaglio (target thread) e la cancellazione di tale thread può avvenire in due modalità
ovvero:
• Cancellazione asincrona. Un thread fa immediatamente terminare il thread bersaglio;
• Cancellazione differita. Il thread bersaglio può periodicamente controllare se deve terminare, in modo da riuscirvi in modo opportuno.
I problemi con la cancellazione si presentano nel momento in cui ci sono risorse assegnate a un thread cancellato, o se si cancella un thread
mentre si stanno aggiornando dei dati che tale thread condivide con un altro thread; tale caso è particolarmente problematico, soprattutto se
la cancellazione è asincrona, perché non è detto che il sistema operativo riesca a liberare una risorsa, magari importante per tutto il sistema.
La cancellazione differita invece funziona tramite un thread che segnala la necessità di cancellare un certo thread bersaglio e la cancellazione
avviene solo se il thread bersaglio verifica se debba essere cancellato o meno; in tal modo si può programmare la verifica in un punto
dell'esecuzione del thread in cui non sta condividendo nulla, o che in generale sia un momento senza problemi.
In Pthreads sono supportate tre modalità di cancellazione, ognuna definita da uno stato (Abilitato o disabilitato) e un tipo (differita o
asincrona) e un thread può scegliere e impostare il suo stato di cancellazione e il suo tipo tramite un API. Quando la cancellazione di un thread
è disabilitata questo non può essere cancellato, e potrà esserlo solo quando il thread imposterà la sua cancellazione ad abilitato. Il tipo di
cancellazione predefinito è quello differito, e la cancellazione avviene solo quando un thread raggiunge un punto di cancellazione. In caso di
richiesta di cancellazione, che deve restare in attesa perché momentaneamente il thread ha disabilitato la cancellazione, viene richiamata una
funzione nota come gestore della pulizia, che permette di rilasciare tutte le risorse che un thread può aver acquisito, prima della sua
eliminazione. Nella documentazione di Pthreads si sconsiglia quindi la cancellazione asincrona. Su Linux, la cancellazione dei thread usa l'API
Pthreads ma è gestita attraverso i segnali.

❖ Dati specifici dei thread


Uno dei vantaggi principali della programmazione multithread è dato dal fatto che i thread appartenenti allo stesso processo ne condividono
i dati. Tuttavia, abbiamo anche visto che i thread spesso necessitano di una propria copia dei dati, una copia privata, e tali dati sono chiamati
dati specifici dei thread TLS. Il problema sorge perché spesso è facile confondere i dati specifici dei thread con le variabili locali e questo
perché le variabili locali sono visibili solo durante la chiamata di una singola funzione, i dati specifici sono visibili attraverso tutte le chiamate e
quindi possiamo confondere i dati specifici con dati statici, con la differenza che i dati specifici sono unici per ogni thread.

❖ Attivazione dello scheduler


Nel modello a due livelli o in quello da molti a molti spesso è necessaria una comunicazione tra la libreria del kernel e la libreria per i thread; è
grazie infatti a tale forma di coordinamento che il numero dei thread nel kernel è modificabile dinamicamente, per il miglioramento delle
prestazioni. Molti sistemi che implementano tali modelli hanno una struttura dati collocata proprio intermedia tra i thread del kernel e
dell'utente, e tale struttura è chiamata LWP processo leggero.

Da parte della libreria dei thread a livello utente, l'LWP è visto come una sorta di processore virtuale alla quale l'applicazione può richiedere lo
scheduling di un thread a livello utente. Per quanto riguarda invece i thread a livello kernel, ciascun LWP è associato a un thread del kernel, e
sono proprio i thread del kernel che il sistema operativo pone in esecuzione sui processori fisici. Se un thread del kernel si blocca (mentre
attende il completamento di un'operazione di I/O ad esempio) allora anche l'LWP si blocca e l’effetto a catena risale fino al thread a livello
utente associato a LWP, che si blocca anch’esso.
Uno dei modelli di comunicazione tra la libreria a livello utente e il kernel è conosciuto come attivazione dello scheduler; il kernel infatti
fornisce all'applicazione una serie di processori virtuali (LWP) mentre l'applicazione esegue lo scheduling dei thread dell'utente sui processori
virtuali disponibili; il kernel inoltre informa l'applicazione se si verificano determinati eventi, seguendo la procedura chiamata upcall. Le upcall
sono gestite dalla libreria dei thread mediante un apposito gestore, eseguito su un processore virtuale. Una situazione capace di innescare una
upcall si verifica quando il thread di un’applicazione è sul punto di bloccarsi. In questo caso il kernel, tramite una upcall, informa l’applicazione
che un thread è prossimo a bloccarsi, e identifica il thread in oggetto. Il kernel, quindi, assegna all’applicazione un nuovo processore virtuale.
L’applicazione esegue un gestore della upcall su questo nuovo processore: il gestore salva lo stato del thread bloccante e rilascia il processore
virtuale su cui era stato eseguito. Il gestore della upcall pianifica allora l’esecuzione di un altro thread sul processore virtuale che si è appena
liberato. Quando si verifica l’evento atteso dal thread bloccante, il kernel fa un’altra upcall alla libreria dei thread per comunicare che il thread
bloccato è nuovamente in condizione di essere eseguito.
Il gestore di questa upcall necessita anch’esso di un processore virtuale: il cernei può crearne uno ex novo, o sottrarlo a un thread utente per
prelazione. L’applicazione contrassegna il thread fino ad allora bloccato come pronto per l’esecuzione, ed esegue lo scheduling di un thread
pronto per l’esecuzione su un processore virtuale disponibile.
4.7 Esempi di sistemi operativi
martedì 6 agosto 2019 15:25
❖ Thread di Windows
Abbiamo detto che Windows offre la API Windows, che è l'API principale
della famiglia dei sistemi operativi Microsoft.
Un'applicazione per l'ambiente Windows si esegue come un processo
separato ed ogni processo può contenere uno o più thread; l'API
l'abbiamo analizzata nel paragrafo riguardante le librerie dei thread, ora
analizziamo il resto.
Il sistema Windows implementa il modello da uno a uno, secondo cui
quindi si associa ad ogni thread a livello utente un thread a livello kernel.
I componenti generali di un thread includono un ID del thread, dei
registri che rappresentano lo stato del processore, uno stack utente che
viene usato quando il thread è in modalità utente, uno stack kernel per la
modalità kernel e un’area di memoria privata, usata dalle diverse librerie
run-time e dalle librerie dinamiche. I registri, gli stack e la memoria
privata formano quello che si chiama, in Windows, contesto del thread.
Per quanto riguarda le strutture dati principali di un thread abbiamo:
• ETHREAD (executive thread block)
• KTHREAD (kernel thread block)
• TEB (thread environment block)
L'ETHREAD è una struttura che contiene all'interno un puntatore al
processo a cui il thread appartiene e l'indirizzo della funzione in cui il
thread inizia la sua esecuzione; ha inoltre anche un puntatore alla
corrispondente struttura KTHREAD che include invece informazioni per il
thread relative allo scheduling, alla sincronizzazione, lo stack kernel e a sua volta un puntatore alla struttura TEB.
ETHREAD e KTHREAD risiedono nello spazio del kernel e dunque sono accessibili solo al kernel, invece TEB appartiene allo spazio utente e vi
accede solo quando il thread è in modalità utente.
La struttura TEB contiene l'identificatore del thread, lo stack utente e un vettore di dati specifici del thread. In altre parole, un thread in
Windows ha le seguenti strutture dati:

❖ Thread di Linux
Abbiamo visto che Linux offre la chiamata di sistema fork() per duplicare un processo, e prevede inoltre la chiamata di sistema clone ( ) per
generare nuovo thread. Tuttavia, Linux non distingue tra processi e thread, impiegando generalmente al loro posto il termine task
(operazione) in riferimento al flusso del controllo di un programma. Quando invoca la clone, questa riceve come parametro un insieme di
indicatori, dei flag, al fine di stabilire quante e quali risorse del task genitore debbano essere condivise dal task figlio. Per esempio, qualora
clone () riceva i flag CLONE_FS, CLONE_VM, CLONE_ SIGHAND e CLONE_FILES, il task genitore e il task figlio condivideranno le medesime
informazioni sul file system (come la directory attiva), lo stesso spazio di memoria, gli stessi gestori dei segnali e lo stesso insieme di file aperti.
Adoperare clone () in questo modo è equivalente a creare thread, dal momento che il task genitore condivide la maggior parte delle proprie
risorse con il task figlio. Tuttavia, se nessuno dei flag è impostato al momento dell’invocazione di clone (), non si ha alcuna condivisione, e la
funzionalità ottenuta diventa simile a quella fornita dalla chiamata di sistema fork (). Questa condivisione a intensità variabile è resa possibile
dal modo in cui un task è rappresentato nel kernel di Linux. Per ogni task, nel kernel esiste un’unica struttura dati (e precisamente, struct
task_struct). Questa struttura, invece di memorizzare i dati del task relativo, utilizza dei puntatori ad altre strutture dove i dati sono
effettivamente contenuti: per esempio, strutture dati che rappresentano l’elenco dei file aperti, le informazioni per la gestione dei segnali e la
memoria virtuale. Quando si invoca fork(), si crea un nuovo task insieme con una copia di tutte le strutture dati del task genitore. Anche
quando s’invoca la chiamata clone () si crea un nuovo task, ma anziché ricevere una copia di tutte le strutture dati, il nuovo task punta a
queste o a quelle strutture dati del task genitore, a seconda dell’insieme di flag passati a clone ( ).

Potrebbero piacerti anche