Gestione dei thread in C++11, C# e Java

annuncio pubblicitario
Scuola Politecnica e delle Scienze di Base
Corso di Laurea in Ingegneria Informatica
Elaborato finale in Programmazione I
Gestione dei thread in C++11, C# e Java
Anno Accademico 2015/2016
Candidato:
Giuseppe Percuoco
matr. N46002004
1
[Dedica]
2
Indice
Indice .................................................................................................................................................III
Introduzione..........................................................................................................................................4
Capitolo 1: Cosa è un Thread?.............................................................................................................6
1.1 Gestione dei thread.....................................................................................................................7
1.2 Concorrenza ...............................................................................................................................8
Capitolo 2: I tre linguaggi ..................................................................................................................10
Capitolo 3: Thread in C++11..............................................................................................................13
3.1 Creazione e terminazione dei thread .......................................................................................13
3.2 Concorrenza..............................................................................................................................15
3.3 Concorrenza sui task.................................................................................................................17
Capitolo 4: Thread in C#....................................................................................................................19
4.1 Creazione e terminazione dei thread........................................................................................19
4.2 Concorrenza..............................................................................................................................21
4.3 Concorrenza sui task.................................................................................................................23
Capitolo 5: Thread in Java..................................................................................................................24
5.1 Creazione e terminazione dei thread .......................................................................................24
5.2 Concorrenza .............................................................................................................................26
Capitolo 6: Caso d'uso comune..........................................................................................................28
6.1 Implementazione nei tre linguaggi...........................................................................................28
Conclusioni.........................................................................................................................................32
Bibliografia.........................................................................................................................................36
Introduzione
L'Informatica nasce per risolvere problemi. La sua etimologia deriva principalmente dalla
unione di ''info'' e ''matica'', ovvero ''informazione automatica '', da cui con il passare del
tempo ne è derivata una scienza che si occupa del trattamento e gestione dell'informazione
mediante procedure automatiche, intese come eseguibili da un calcolatore. Non molto
lontano, rispetto al concetto di informatica, è il concetto di ''algoritmo'' che formalizzando
una sua definizione può essere visto come un insieme di procedure le quali eseguite in un
certo ordine permettono la risoluzione di un determinato problema, basandosi sulle
informazioni necessarie caratterizzanti quest'ultimo. Dall'unione dell'informatica e del
concetto di algoritmo nasce il concetto di ''programma'' inteso come la codifica di un
qualsiasi algoritmo in un qualsiasi linguaggio di programmazione e che ne rende possibile
la sua esecuzione da parte di un calcolatore; viene da se che tale definizione è valida sotto
l'ipotesi che affinché un programmatore possa codificare un algoritmo in un qualsiasi
linguaggio di programmazione esso deve avere la peculiarità di essere risolvibile o in
gergo tecnico ''computazionalmente trattabile'' . Più in generale secondo una visione
statica, un programma non è altro che una descrizione delle elaborazioni da seguire.
Si suol dire « un programma è algoritmo più strutture dati », ma nel momento in cui tale
concetto viene dato in pasto ad un calcolatore che ne effettua una elaborazione, il concetto
statico di programma tende a svanire lasciando posto ad una entità dinamica chiamata
''processo''. Un processo è l'unità base di esecuzione che rappresenta l'attività dinamica di
un elaboratore in relazione ad una esecuzione di un programma, e come tale entità
dinamica esso varia nel tempo e per tale ragione viene identificato da: il programma
stesso, inteso come linee di codice, e da uno ''stato di esecuzione''. Oltre al concetto di
''esecuzione'', al processo viene associato anche il concetto di ''possesso e protezione delle
risorse'', definendo per ognuno di essi un proprio spazio di indirizzamento che rappresenta
l'insieme di indirizzi ai quali un processo può accedere.
4
Nella maggior parte dei casi dato un problema esso può essere risolto scomponendolo in
sottoproblemi di complessità minore ovvero, come nella realtà anche nell'informatica vale
il concetto di ''divide et impera''. Un problema risolto con tale metodologia prevederà alla
fine delle risoluzioni dei sottoproblemi una fase di combinazione degli stessi in un ordine
coerente. Dall'esigenza di svolgere tramite un calcolatore problemi sempre più complicati,
nasce il concetto di ''programmazione concorrente''. Essa viene intesa come un insieme di
specifiche tecniche, metodologie e strumenti necessari per agevolare l'esecuzione di
applicativi intesi come insieme di attività che vengono svolte simultaneamente. In
generale un processo non è una unità atomica ma può essere scomposto in una serie tali
attività , che nella maggior parte dei casi si identificano nel concetto di ''thread''.
5
Capitolo 1: Cosa è un Thread?
Un processo generalmente è costituito da un insieme di locazioni per memorizzare i dati,
un insieme di variabili globali e locali,da un suo relativo descrittore e da uno stack.
L'unione di queste informazioni sono definite come ''immagine del processo''. Ad ogni
processo viene definito un proprio spazio di indirizzamento, costituito principalmente
dalle risorse da esso possedute e dalla sua immagine.
Le operazioni di passaggio da un processo ad un altro (''context switch'') sono molto
onerose gravando principalmente sul tempo di esecuzione, con un conseguente aumento
dell' overhead dovuto al continuo succedersi di fasi di salvataggio e ripristino dello "spazio
di esecuzione ''. L'utilizzo di spazi di indirizzamento differenti per ogni processo, diviene
utile nel momento in cui ci interessiamo circa la protezione dei dati locali e ne favorisce la
sicurezza ad accessi non controllati in ambienti di scambio di messaggi tra processi, ma
tuttavia diventa complessa nel caso di interazioni mediante risorse comuni. Molti sono i
casi di applicazioni con un elevato grado di parallelismo e che condividono dati comuni (si
pensi ad applicativi in ambito di controlli reali di strutture fisiche , controllo di dispositivi
I/O, etc.).
La soluzione a tali problematiche è stata quella di introdurre nei moderni sistemi operativi
il concetto di ''processo leggero'',
comunemente noto come ''thread''. Un thread è ''un
flusso di controllo sequenziale'' in un processo (''pesante''). Il perno su cui il thread poggia
le sue fondamenta concettuali è quello della separazione tra ''esecuzione'' e ''possesso di
risorse''. I due aspetti possono essere completamente separati dato che possono essere
gestiti dal SO in maniera indipendente. L'unità che viene eseguita è il thread, mentre il
possessore delle risorse è il processo o detto anche ''task''.
I threads appartenenti a uno specifico processo, condividono alle risorse, lo spazio di
indirizzamento e gli stessi dati di quest'ultimo. Ogni thread è associato uno stato di
6
esecuzione, locazioni di memoria per le variabili ad esso locali, uno stack, un contesto
(insieme di valori presenti nei registi del processore in un dato istante) e un descrittore.
Da queste informazioni si traduce che a cause delle minime informazioni associate a un
thread, ne consegue che i cambi di contesto, creazione e terminazione
risultano più
semplificate e veloci rispetto a quelle di un processo.
Definiamo ''multithreading'' la capacità di un sistema operativo di consentire l'esecuzione
di più thread in un processo.
1.1 Gestione dei thread
I thread possono essere gestiti sia a ''livello utente'' (user-level) che a ''livello
nucleo''(kernel-level).
Figura 1.1
Figura 1.2
In caso di prima gestione (Figura 1.1) si utilizzano librerie di funzioni (“thread package”)
implementata a livello utente, fornendo meccanismi di creazione, terminazione e
sincronizzazione dei thread. L'importanza di tale scelta è il sistema operativo ignora la
presenza dei thread, tendendo a gestire solo i processi. Nei casi generali un processo viene
inizializzato ad avere solo un thread che però ha la capacità di crearne altri tramite una
apposita funzione di libreria, così da creare una gerarchia di thread (padre-figlio) o livelli
di thread. Nel momento in cui un thread invoca una “system call”, il sistema operativo
blocca il processo a cui appartiene il thread e di conseguenza blocca anche i restanti
7
thread.
Nel secondo caso (Figura 1.2) la gestione a livello del nucleo di sistema (kernel-level)
impone che tutte le funzioni di realizzazione, terminazione, sincronizzazione etc., siano a
carico del sistema operativo e pertanto tali sono associate a chiamate di sistema.
1.2 Concorrenza
La ''Programmazione Concorrente'' è definita come l'insieme delle tecniche, metodologie
e strumenti per fornire supporto all'esecuzione di applicazioni, intese come insieme di
attività svolte simultaneamente. La ''Multiprogrammazione'' è la forma più elementare di
programmazione concorrente, e fornisce supporto alla esecuzione combinata di processi o
thread differenti. Essa permette di creare una macchina astratta che dispone di più
processori virtuali, uno per ogni processo, in modo da estendere le potenzialità fisiche
della macchina.
La ''Concorrenza'' è quel concetto per cui l'esecuzione di un insieme di processi si
sovrappone nel tempo. Gli strumenti che ci fornisce la programmazione concorrente sono
un insieme di primitive utili a definire : attività indipendenti (processi,thread),
comunicazione e sincronizzazione tra attività concorrenti (''concorrente'' non significa ''
parallelo'').
Le problematiche ricorrenti sono quelle relative a: comunicazione, condivisione di risorse,
assegnazione di risorse e sincronizzazione.
La ''Race Condition '' è quella situazione in cui più processi leggono e scrivono dati
condivisi, e il risultato finale è influenzato dall'ordine di esecuzione delle operazioni
all'interno dei rispettivi processi.
I processi concorrenti si dicono:
•
Processi Indipendenti: P1 e P2 sono indipendenti se l’esecuzione di P1 non è
influenzata da P2, e viceversa.
•
Processi Interagenti: P1 e P2 sono interagenti se l’esecuzione di P1 è influenzata da
8
P2, e viceversa (dovuto alle differenti velocità di esecuzione dei processi).
I tipi di interazione sono:
•
Competizione: risorse comuni che non possono essere utilizzate
contemporaneamente (“mutua esclusione”).
•
Cooperazione: eseguire attività mediante scambio di messaggi (“comunicazione”).
•
Interferenza: causata da competizione tra processi per l’uso non autorizzato di
risorse o ad una errata coordinazione di competizione e cooperazione.
In generale per un corretto funzionamento e avanzamento di processi concorrenti è
necessario imporre vincoli nella esecuzione delle operazioni, detti anche vincoli di
“Sincronizzazione”:
•
Competizione: un solo processo alla volta deve avere accesso alla risorsa comune
(“sincronizzazione indiretta o implicita”).
•
Cooperazione: le operazioni eseguite dai processi devono seguire una sequenza
prefissata (“sincronizzazione diretta o esplicita”).
La “risorsa” è un qualunque oggetto fisico/logico di cui un processo necessita per portare a
termine (o parzialmente a termine) le sue operazioni.
9
Capitolo 2: I tre linguaggi
C++11, analogamente noto come C++0x, è il nuovo standard per il linguaggio di
programmazione ISO C++ che di fatto sostituisce la passata versione risalente all'anno
2003. Il ''C++ Standard Committe '' ha completato il nuovo standard nel 2008, per poi
presentare una bozza nel 2010 e nel successivo 1° Settembre 2011 ufficializzare la
versione finale . Le esigenze di introdurre un nuovo standard erano quelle di migliorare la
velocità di programmazione, l'eleganza e la possibilità di agevolarene la manutenzione.
Si tratta del terzo standard ufficale per tale linguaggio : C++98 e C++03 non presentavano
modifiche rilevanti, mentre tale nuova versione aggiorna totalmente il linguaggio tanto che
lo stesso creatore Bjarne Stroustrup tende a definirlo come ''un nuovo linguaggio''. Le
principali novità introdotte riguardano:
•
presenza di nuovi algoritmi
•
nuove classi contenitore
•
operazioni atomiche
•
smart pointers
•
funzione async()
•
una librearia multithread
Il C# è un linguaggio di programmazione sviluppato da Microsoft durante l'iniziativa
.NET. Esso è orientato agli oggetti derivante principalmente da Delphi,C++, Java , Visual
Basic e pertanto offre all'utenza un linguaggio semplice e maneggevole rispetto agli stessi
C++ e Java. Le sue fondamenta si basano sulla piattaforma .NET Framework, che
oggigiorno è l'indiscussa tecnologia, appartenente allo Standard ISO, su cui Microsoft
poggia le sue fondamenta. Caratteristiche e componenti principali messi a disposizione dal
.Net Framework, sono:
•
Common Languege Runtime (CLR): parte della tecnologia che si prende atto della
gestione dell'esecuzione delle applicazioni;
10
•
Common Language Specification (CLS): sezione che facilita l'interoperabilità tra
differenti linguaggi;
•
Common TypeSystem (CTS): specifiche comuni per linguaggi differenti.
Immagine 3.1
Il CLR ha il ruolo di eseguire le applicazioni .NET scritte in uno dei tipi di linguaggi che
la piattaforma supporta, il quale in una prima fase trasforma il codice in un una forma
ibrida comunemente chiamata IL (Intermediate Lenguage) . All'atto dell'esecuzione tale
codice viene assegnato ad un compilatore JIT-ter che lo converte in codice macchina e al
contempo attuando una ottimizzazione per il tipo di hardware su cui verrà eseguito.
Dall'altra parte il CLS e il CTS indicano una serie di regole che sia il compilatore che il
linguaggio devono sottostare affinché un componente possa interagire con altri
componenti scritti in linguaggi differenti e pertanto permettere la correttezza
dell'esecuzione dal CLR.
Java è stato creato da James Gosling presso la SunMicroSystem nel bel mezzo del
progetto “Green” nel 1991. Prima chiamato Oak (JDK 1.0), successivamente il nome Java
venne ufficializzato dalla Sun nel 1995. Esso è un linguaggio object oriented, indipendente
dall'architettura e multithread. Java dispone di un insieme di tool , racchiusi nel JDK, che
vengono a supporto duranti le fasi di sviluppo di un programma e nelle azioni necessarie
per la sua esecuzione. Tale linguaggio si basa sul principio << write once, run
anywhare>>, ovvero il codice una volta compilato non ha bisogno di una successiva
ricompilazione nel momento in cui viene lanciato su una differente piattaforma. Infatti una
volta scritto il codice in Java un apposito compilatore “javac” lo processa trasformandolo
11
non in un linguaggio macchina specifico della piattaforma su cui girerà, ma in un
linguaggio per un “processore virtuale” (JVM, Java Virtual Machine) detto “bytecode”.
In tale elaborato introdurremo prima il concetto di ''thread'', e in seguito tratteremo come
vengono implementati all'interno di tre linguaggi di programmazione: C++11,C# e Java.
12
Capitolo 3: Thread in C++11
L'ambiente di scrittura a cui facciamo riferimento per questo linguaggio e il classico “DevC++”. Per una corretta compilazione è necessario usare il flag “-std=c++0x”.
Le librerie a cui faremo riferimento sono principalmente le classi presenti in : <thread>,
<mutex>, <condition_variable> e <future>.
3.1 Creazione e terminazione dei thread
La libreria messa a disposizione dal linguaggio è la libreria <thread>.Il costruttore del
thread (esempio in Figura 3.1.1) accetta come parametri di ingresso il task/funzione da
eseguire e i suoi parametri, ovviamente deve esserci una corrispondenza biunivoca gli
argomenti che la funzione ha in ingresso e quelli effettivamente passati.
Figura 3.1.1
13
Effettuata la ''costruzione'', il thread dichiarato inizia a eseguire non appena il sistema gli
affida le risorse necessarie. Gli argomenti utili alla esecuzione del task possono essere
passati sia per valore che per riferimento, in questo ultimo caso si utilizza la funzione
“ref()” dato che i costrutti dei thread sono “template variadici” e per questo necessitano di
un “reference_wrapper” per passare un riferimento. Opposto al costruttore c'è il distruttore
dell'oggetto thread il quale, nel caso in cui il thread è ''joinable'', chiama la funzione
“terminate()”. La funzione “join()” indica al main thread di non procedere fintanto che i/il
thread/s istanziati/o non abbiano completato, quindi rappresenta una sorta di punto di
sicnronizzazione fra il thrad appena istanziato e il “main thread”.
Figura 3.1.2
È possibile lasciare eseguire un thread al di fuori del proprio ambito invocando su di essi
la
funzione “detach()”, così da lascarlo transitare in uno stato comunemente chiamato
“deamon”. Permettere a un thread di restare in esecuzione oltre il suo distruttore è
considerato dallo stesso ideatore del linguaggio un errore, dato che sarebbe più
professionale sempre accettarsi che un thread rilascia le risorse assegnatogli o che non
acceda a oggetti dell'ambito in cui è stato istanziato dopo che tale ambito sia stato
distrutto. Come mostrato in Figura 3.1.2 il thread “t1” una volta aver invocata la funzione
“detach”, lo stato di terminazione non viene rilevato dal main thread
quest'ultimo più veloce di t1 termina in modo del tutto indipendente.
14
ed essendo
3.2 Concorrenza
Il nuovo standard esprime molta importanza riguardo l'uso dei thread, incitando l'uso
cautelo degli stessi. Viene in primis sottolineato il concetto dell'evitare la “Data Race”.
Diremo che due thread hanno una data race se essi possono accedere a una posizione di
memoria in modo simultaneo e almeno uno dei due effettua una scrittura. Nel momento in
cui usiamo processi interattivi che agiscono su dati comuni, il linguaggio mette a
disposizione due forme di locking: “Mutex” e “Variabili Condition”.
Un mutex è un oggetto che viene realizzato per implementare l'accesso esclusivo a una
risorsa. Esso può essere posseduto da un unico thread per volta.
Immagine 3.2.1
L'accesso al mutex viene effettuato con la funzione “lock()”, mentre il rilascio con la
funzione “unlock()” (come in Figura 3.2.1).
Un lock essendo una risorsa è necessario che una volta acquisita e consumata per
effettuare le proprie operazioni, debba essere rilasciata. La libreria standard mette a
disposizione due classi “lock_guard” e “unique_lock” che effettuano un “unlock()”
implicito (nella figura 3.2.2 per utilizzare un unique_lock basta sostituirlo a i lock_guard
presenti nel codice). La differenza tra lock_guard e unique_lock sta nel numero di funzioni
messe a disposizione, la prima è più semplice e veloce mentre la seconda seppur più
onerosa implementa funzionalità aggiuntive.
15
Figura 3.2.2
In genere può capitare di dover acquisire più risorse contemporaneamente; l'acquisizione
di più lock può, con molta probabilità, portare al deadlock (si avrebbe un deadlock se il
taskA effettuasse una lock su “a “mentre il taskB effettua una locksu “b”, Figura 3.2.3).
Figura 3.2.3
Le “variabili condition” sono implementate per effettuare una corretta comunicazione tra
thread, effettuando una sincronizzazione in relazione al verificarsi o meno di un evento.
16
Figura 3.2.4
Figura 3.2.5
La figure 3.2.4 e 3.2.5 mostrano un piccolo esempio di utilizzo delle variabili condition e
delle funzioni “notify-wait”.
3.3 Concorrenza sui task
Per task si intende una attività che viene eseguita contemporaneamente da altre. Per thread
si intende la descrizione a livello di sistema delle risorse di un computer per l'esecuzione
di task. Un thread esegue un task. Lo standard offre supporto alla concorrenza per task che
17
effettuano operazioni sui dati e produzione dei stessi. La comunicazione tra task avviene
mediante le funzioni “future/promise”. I task pongono il risultato nella “promise” (handle
di uno stato condiviso) attraverso una “set_value()” ( o “set_exception()” in caso di
propagazione dell'eccezione),
e i task che necessitano del valore depositato potranno
ricavarlo dal relativo “future” attraverso una “get()”. Un “package_task” è un contenitore
di una coppia future/primise (Figura 3.2.6).
Figura 3.2.6
18
Capitolo 4: Thread in C#
L'ambiente usato per la stesura degli esempi in C# è “Visual Studio”. I namespace a cui
faremo riferimento per la trattazione dei thread
sono principalmente: “System.Threading” e
“System.Threading.Task”.
4.1 Creazione e terminazione dei thread
Nel linguaggio C# il codice viene gestito da un Manger thread, ovvero un thread che segue
politiche e comandi del CLR. Esso rappresenta una astrazione di più alto livello rispetto a
un qualsiasi thread di sistema.
All'interno del .NET Framework, un thread è rappresentato mediante la classe “Thread”
appartenente al namespace “System.Threading”. Il suo costruttore accetta un delegate, che
rappresenta la funzione da far eseguire al thread, di tipo “ThreadStart” o
“ParametrizedThreadStart” nel dobbiamo passargli anche alcuni parametri.
Figura 4.1.1
19
Distinguiamo il concetto di Thread di Foreground da quello di Background: il primo è in
grado di mantenere in vita l'applicazione fintantoché non è concluso, nel secondo caso
l'applicazione non deve aspettare il completamento del thread per poter terminare. Tale
proprietà
è configurabile
mediante la proprietà “IsBackground”. Il CLR mette a
disposizione un contenitore chiamato ThreadPool, in cui si mantiene una lista di thread
attivi. La classe s tatica ThreadPool mette a disposizione un metodo
“QueueUserWorkItem”, che accetta un delegate di tipo “WaitCallback”, mediante il quale
possiamo accodare un nuovo task da eseguire in parallelo; per determinare/settare il
numero massimo di thread inseribili in un PoolThread, basta effettuare una chiamata al
metodo “GetMaxThread”/”SetMaxThread”.
Utilizziamo un oggetto di tipo Thread all'interno delle nostre applicazioni per la facilità di
gestire il flusso di esecuzione. Mediante l'esecuzione del metodo “Join” instauriamo una
sorta di sincronizzazione con il thread chiamante (inteso quello che chiama il metodo), che
consiste nell'aspettare la terminazione delle operazioni di quest'ultimo (Figura 4.1.2).
Figura 4.1.2
Un'altra possibilità è quella di interrompere completamente un thread, si pensi per esempio
di voler annullare l'esecuzione di una lunga serie di operazioni, e in questi casi il metodo
da
utilizzare è “Abort” . Quando l'esecuzione del thread viene cancellata, il relativo
codice viene interrotto da una “ThreadAbortException” (Figura 4.1.3).
20
Figura 4.1.3
4.2 Concorrenza
La prima tecnica che ci permette di sincronizzare gli accessi alle risorse comuni da parte
dei thread è l'uso della parola chiave “lock” (Figura 4.1.1). Essa definisce un blocco di
istruzioni, detto sezione critica, che deve essere sincronizzato cosicché un thread che inizi
ad eseguire tale sezione non possa essere interrotto. Il lock prevedete di accettare un
oggetto di tipo riferimento, che viene utilizzato come token da acquisire e da bloccare per
poter entrare nella sezione critica. Per problemi come l'incremento di una variabile, invece
di utilizzare l'istruzione lock, con la conseguente creazione di un oggetto, è possibile usare
la classe “Interlocked”.
L'istruzione vista in precedenza mediante la lock, viene interpretata dal compilatore
trasformandola in una classe “Monitor”. La classe Monitor prevede due metodi principali
che sono la “ Enter” e la “Exit” entrambe effettuate su un oggetto di tipo riferimento. Tale
classe, rispetto alla lock, permette di definire tramite l'istruzione “TryEnter” un tempo
massimo entro il quale un thread dovrà aspettare prima di entrare nella sezione critica.
Un'altra struttura che ci viene in aiuto per risolvere problemi di accessi concorrenti a
risorse comuni, è la classe “Semaphore”. La classe “Semaphore” limita il numero di thread
21
che possono accedere ad una risorsa o ad un pool di risorse contemporaneamente. Il
costruttore della classe accetta come parametri di ingresso il numero massimo di accessi
iniziali e il numero massimo di possibili accessi contemporanei (Figura 4.2.4).
Figura 4.2.4
L'accesso alle risorse avviene invocando il metodo “WaitOne”, e il rilascio mediante il
metodo “Release”.
Un semaforo inizializzato per avere sono un permesso di accesso contemporaneo viene
detto Mutex. Per utilizzare il Mutex come controllore di accesso ad una sezione critica, si
utilizza il metodo “WaitOne”. Per rilasciarlo è necessario invocare “ReleaseMutex”
all'uscita del blocco condiviso (Figura 4.2.5).
Figura 4.2.5
22
4.3 Concorrenza sui task
All'interno del .NET Framework troviamo una libreria chiamata Parallel Extensions,
formata da due componenti principali: Task Parallel Library (TPL) e Prallel LINQ.
La TPL contiene la classe “Task”, inserita nel namespace System.Threading.Task, e mette
a servizio del programmatore una serie di funzionalità per la creazione, controllo ed
esecuzione del codice parallelo. Esso fonda la sua interfaccia ( intesa come insieme di
funzionalità) sull'utilizzo di due tipi di delegate, “Action” e “Func”, a seconda dei casi se
eseguiamo una procedura o una funzione. La creazione di un task avviene mediante
l'oggetto “Task Factory”, accessibili mediante la proprietà “Task.Factory” e sfruttando il
metodo “StartNew”. Un task creato in tale modo viene subito schedulato ed avviato non
appena possibile. Quando creiamo un task passandogli un delegate di tipo Func, ovvero
una funzione, implicitamente andiamo a costruire un oggetto di tipo Task<Result>, cioè
una classe che deriva da Task e che espone la proprietà Result tramite la quale viene
recuperato il risultato dell'invocazione. Facendo così imponiamo una netta
sincronizzazione tra il task e il thread chiamante, il quale rimano bloccato fintantoché il
risultato non è disponibile.
Vengono esposti i principali due metodi che permettono la programmazione asincrona:
“async” e “awayt”. Anche all'interno dell'ambiente Task è possibile trovare una sorta di
gerarchia Padre-figlio chiamata “nested task”, in cui un task padre contiene un task figlio.
Nel momento in cui viene eseguito il task padre implicitamente viene eseguito anche il
figlio, il quale però per default ha un ciclo di vita indipendente. È possibile specificare, in
fase di costruzione la volontà di sincronizzare padre e figlio settando la proprietà
“TaskCreationOption.AttachedToParent”.
23
Capitolo 5: Thread in Java
L'IDE utilizzato è stato “ Eclipse Mars”.
I principali package utilizzati per la gestione dei
thread sono: “java.util.concurrent”,
”java.util.concurrent.locks”
e “java.util.cuncurrent.Semaphore”.
5.1 Creazione e terminazione dei thread
Java supporta la programmazione multithread a livello di linguaggio, consentendo di
realizzare programmi multithread in maniera standardizzata e indipendente dalla
piattaforma su cui si esegue. Il linguaggio fornisce primitive per definire attività
indipendenti e primitive per la comunicazione e sincronizzazione di attività concorrenti.
La classe thread è presente nel package
“java.lang”. Java mette a disposizione due
possibili modalità di creazione di un thread:
•
Derivazione della classe Thread;
•
Implementazione dell'interfaccia
Runnable.
Perché java offre due distinti meccanismi
per la creazione di un Thread?
Immagine 5.1.1
Semplicemente perché Java non consente la
derivazione multipla. Pertanto se una classe non è già coinvolta in un legame di
derivazione allora possiamo usare il primo metodo, altrimenti si usa il secondo metodo
ridefinendo la funzione “run()”.
Il costruttore principale della classe Thread é:
24
in cui gli passiamo il gruppo di appartenenza del thread, un oggetto eseguibile e il nome
del thread. All'atto della creazione, i thread possono essere raggruppati in un ThreadGroup
così da poterli controllare come se fossero una singola entità; ogni thread appartiene
sempre ad un gruppo, ma se non viene specificato nel costruttore si sottintende che quel
thread appartiene al gruppo di default chiamato “main”.
Introdotti nel package “java.util.concurrent”, i ThreadPool permettono di ridurre
l'overhead causato dalla creazione dei thread e permette di avere sotto controllo un numero
specificato di thread. Il tipo più comune di ThreadPool è il “newFixedThreadPool”, che
mantiene un numero fissi di thread in esecuzione e, nel momento in cui uno di essi viene
interrotto quando è ancora in uso, viene rimpiazzato automaticamente con un nuovo
thread.
Il metodo “start()” ha il principale obiettivo di allocare il rispettivo thread all'interno della
JVM e di invocare la funzione run(), la quale definisce il comportamento del thread.
Per quanto riguarda la fase di terminazione essa può avvenire in vari modi: mediante una
interruzione in cui un thread invoca il metodo “interrupt()” sull'oggetto thread da
interrompere sollevando una interruzione del tipo “InterruptedException”, mediante una
“suspend()” che sospende il thread ,che successivamente può essere riattivato mediante
una “resume”, oppure invocando il metodo “stop()” che blocca del tutto il thread e lo
“uccide”. In generale questi metodi sono “deprecati” in quanto l'uso degli stessi può
comportare notevoli complicazioni: si pensi ad un thread che venga interrotto prima di
rilasciare una risorsa, in tale modo si blocca completamente l'accesso alla risorsa a favore
di altri thread generando di conseguenza un deadlock difficilmente rilevabile e risolvibile.
Le funzioni più sicure per la sospensione e terminazione di un thread sono: la “sleep()”
che pone in uno stato di attesa/dormiente un thread per un numero di millisecondi
specificati, e il metodo “join()” che diviene utile nel momento in cui un thread padre
genera più thread figli e potrebbe essere necessario attendere la loro conclusione prima di
procedere.
25
5.2 Concorrenza
Java fornisce un meccanismo di sincronizzazione basato su mutex per accedere alle
sezioni critiche. In generale ad ogni oggetto è associato un proprio mutex, il quale non
viene acceduto direttamente dall'applicazione ma solo attraverso l'uso di metodi/blocchi
sincronizzati. Nel momento in cui un thread esegue un blocco/metodo sincronizzato, se
esso è libero, entra in possesso del mutex associato all'istanza (“mutex lock”) garantendosi
un accesso esclusivo alla sezione, e eventuali thread che vogliano accedere alla risorsa
verrano posti in uno stato di attesa. Un metodo si definisce sincronizzato quando alla sua
firma viene anteposto la parola chiave
“synchronized”, e l'accesso al metodo è effettuato
solo nel momento in cui viene acquistato il lock
associato. Tale tecnica garantisce l'accesso in
Figura 5.2.2
mutua esclusione ai dati incapsulati in un oggetto solo se si accede per metodi definiti
sincronizzati. I metodi non sincronizzati possono essere eseguiti in ogni istante senza
proprietà di sicurezza relative alla mutua esclusione.
Java offre la possibilità di non dichiarare tutto il
metodo synchronized ma solo una parte di esso, in
Figura 5.2.3
questo caso la parola chiave accetta come parametro
un riferimento ad un oggetto del quale si vuole ottenere il lock.
Il vantaggio nell'usare metodi/blocchi synchronized sta nel fatto che permette al
programmatore di non avere la preoccupazione di rilasciare il mutex ogni volta che un
metodo termina normalmente o a causa di una eccezione , dato che viene eseguito
automaticamente.
Un'altra struttura che ci garantisce la concorrenza tra thread è il Monior.
In Java un
Monitor viene realizzato mediante una classe che ha metodi synchronized e una variabile
condition. Una “variabile condition” definisce un meccanismo per sospendere thread in
attesa del verificarsi di una condizione. Un monitor è formato da due sezioni: una “entry
set” in cui sono racchiusi i thread pronti per accedere alla risorsa e una “wait set” in cui
26
sono racchiusi i thread che sono stati sospesi o che sono in attesa del verificarsi di una
condizione. Le operazioni effettuabili sul monitor in Java sono : “wait()” in cui un thread
attivo nel monitor sospende la sua esecuzione con il conseguente passaggio nella wait set,
e rimarrà in quella regione fintantoché un altro thread attivo nel monitor non effettua la
“notify()”.
A partire da Java 1.5 sono stati introdotti nuovi costrutti per garantire e gestire una buona
sincronizzazione, tra cui spiccano classi come “Semaphore”, “Barriere” e “Lock”.
Il costruttore della classe Semaphore ha come parametri di ingresso il numero di permessi
da gestire per l'accesso alla risorsa comune ed un ulteriore parametro booleano che indica
la gestione ordinata (FIFO) o non dei thread che tentano l'accesso.
Figura 5.2.4
Le operazioni principali eseguibili su una oggetto Semaphore sono: “acquire()” che blocca
il thread corrente se non c'è almeno un permesso disponibile da acquisire, “release()” che
aggiunge un permesso al semaforo e potenzialmente permette di sbloccare un thread
bloccato in fase di accesso, e “tryAcquire()” che permette di acquisire un permesso sul
semaforo solo se è disponibile al momento della richiesta.
La classe “CyclicBarrier” permette ad un insieme di thread di aspettare ognuno il
raggiungimento da parte di tutti gli altri di un punto comune di sincronizzazione (barrieria)
oltre il quale riprendere l'esecuzione.
“Lock” è un'interfaccia del package “java.util.concurrent”, e le sue implementazioni
forniscono un uso più semplice rispetto a lock associati a blocchi/metodi synchronized.
Le funzionalità addizionali sono: la presenza di un tentativo non bloccante di acquisire il
lock (“tryLock()”), un tentativo di acquisire il lock interrompibile (“lockInterruptibly()”),
un tentativo di acquisire il lock che può essere interrotto da un timeout
“tryLock(long,TimeUnit)” o l'utilizzo di una classe chiamata “ReentrantLock(boolean
fair)” che permette di applicare politiche di fairness tra thread. I Lock vengono usati anche
in combinazione con variabili condition. L'interfaccia Condition mette a disposizione un
mezzo di sospendere l'esecuzione di un thread (“await()”) fino a quando non verrà
27
notificato (“signal()”) da un'altro thread che una certa “condizione di stato” è ora vera.
28
Capitolo 6: Caso d'uso comune
Produttore consumatore / modello ad ambiente locale
Uno dei problemi di cooperazione ricorrenti nel campo dell'informatica è quello di avere
due entità , una scrive su una data risorsa e l'altra ne legge il contenuto. Tale problema
prende il nome di “Produttore/Consumatore”. Per garantire una corretta riuscita delle varie
operazioni di lettura/scrittura fatte dalle relative entità, viene la necessità di inserire alcuni
vincoli:
•
il produttore non può inserire un nuovo valore prima che il consumatore abbia
prelevato il precedente;
•
il consumatore non può prelevare il messaggio se prima non è stato prodotto.
Anche se esiste un problema di mutua esclusione nell'utilizzo della risorsa comune , la
soluzione impone un netto ordinamento nelle operazioni dei rispettivi processi.
È necessario che produttori e consumatori si scambino segnali per indicare l'esecuzione
delle rispettive operazioni. Entrambi i processi devono aspettare l'arrivo dell'altro
processo.
6.1 Implementazione nei tre linguaggi
•
C++11:
Figura 6.1.1
29
Figura 6.1.2
•
C#
Figura 6.1.3
Figura 6.1.4
30
•
Java
Figura 6.1.5
Figura 6.1.6
Figura 6.1.7
Figura 6.1.8
31
Figura 6.1.9
Figura 6.1.10
32
Conclusioni
C++11
C#
Java
Mutex
Presente in <mutex.h>
Presente nel namespace Implementabile tramite
System.Threading
una classe semaphore
presente
in
java.util.concurrent
Monitor
Da implementare
Presente nel namespace Da Implementare
System.Threading
Semaphore
Implementabile tramite Presente nel namespace Presente nel package
M u t e x e V a r i a b i l i System.Threading
java.util.concurrent
Condition
ThreadPool
Da implementare
Presente nel namespace Presente nel package
System.Threading
java.util.concurrent
Barriere
Da implementare
Presente nel namespace Presente nel package
System.Threading
java.util.concurrent.Cy
clicBarrier
Primitive
di
terminazione/sospensio
ne/sincronizzazione/riat
tivazione
Join(), distruttore della A b o r t ( ) , j o i n ( ) , Destroy(), interrupt(),
c l a s s e ~thread(), suspend(), sleep(),
join(), resume(),
terminate(),
resume(), yield()
sleep(), stop(), yield()
sleep(),yield()
Condition Variables
Presenti
i n Da implementare
<condition_variables>
Presente nel package
java.util.concurrent.Loc
ks
Tabella 1
Come è possibile notare dalla Tabella 1, l'aggiornamento della libreria standard di C++ al
multithreading tende a fornire solo costrutti base rispetto a Java e C#. Da qui nasce il
problema riguardante la scelta se è meglio avere strutture funzionali, semplici o
complesse, già disponibili o fornire solo funzioni base tramite le quali ricavare quelle più
articolate. Nel primo caso abbiamo che ciò che andiamo ad agevolare è il “riuso del
codice” il quale permette un notevole risparmio in termini di scrittura di un programma,
facilitando il lavoro della programmazione e al tempo stesso migliorando la leggibilità del
codice divenuto più corto e sintetico. Nel secondo caso fornire costrutti base per poi
33
ricavarne altri più complessi fornisce una certa libertà al programmatore su come
strutturare a suo vantaggio gli stessi, in ogni minimo dettaglio.
Ovviamente fornire più strutture vuol dire mettere a disposizione meccanismi che tentano
di prevenire mal funzionamenti, ma ciò non toglie che per evitare situazioni come
deadlock e starvation è necessario un uso ottimale delle tecniche di programmazione
concorrente da parte dello sviluppatore. Per situazioni di deadlock, in entrambi i linguaggi
è consigliato scandire un ordine preciso di acquisizione (“lock”) delle risorse che cambia
da caso a caso. Per la starvation invece si è soliti fare uso dell'ausilio del metodo “yield()”
che permette al sistema di cedere il possesso della CPU ad un altro thread eseguibile,
oppure in casi particolari il linguaggio Java mette a disposizione meccanismi di politiche
di trattamento equo dei thread (classe ReentrantLock).
Abbiamo visto che la concorrenza nasce nel momento in cui ci sono più attività che
competono per il possesso di una risorsa comune. Istanziare un gran numero di thread e
strutture dati comporta la necessità di avere gestori di memoria performanti. In Java e C#
tutto ciò avviene in maniera del tutto trasparente per il programmatore, in quanto ci sono
rispettivamente il Garbage Collector e CLR che effettuano tali operazioni. In C++11 ciò
che si utilizza è la così detta tecnica RAII, acronimo di “Resource Acquisition Is
Initialization”, che lega il ciclo di vita di una risorsa (memoria allocata, mutex, thread, file
aperto, etc.) alla durata di un oggetto. Utilizzare l'acquisizione di una risorsa mediante un
costruttore e il rilascio della stessa in un distruttore, consente di eliminare “operazioni new
nude” e “ operazioni di delete nude”, in modo tale da rendere il codice molto meno
propenso ad errori. La stessa classe “std::thread” utilizza il protocollo RAII in quanto
acquisiscono la risorsa nei loro costruttori, che lanciano eccezioni nel momento in cui
l'acquisizione/inizializzazione non va a buon fine, e la rilasciano nei loro distruttori, senza
necessitare di meccanismi forzati di pulizia della memoria.
I due grafici mostrano rispettivamente lo speedup e il tempo di esecuzione di un
programma che esegue l'algoritmo di “decomposizione LU”, un algoritmo usato in campo
algebrico per la risoluzione di sistemi di equazioni lineari tramite matrici, al variare del
34
numero di thread adoperati.
35
30
Speedup
25
20
C++11
C#
Java
15
10
5
0
0
10
20
30
40
50
Numero di Thread
Grafico 1
•
C++11: lo speedup è per lo più proporzionale al numero di thread usati, anche se è
rilevabile una piccola diminuzione a partire da circa 30 thread usati .
•
C#: lo speedup assume un andamento non proporzionale all'aumentare dei thread ,
tendendosi ad assestare a partire da circa 35 thread usati.
•
Java: inizialmente tendente ad essere proporzionale all'aumentare del numero di
thread usati, ma dopo aver raggiunto un suo picco di speedup a circa 36 thread
tende a diminuire
160
Tempo di esecuzione (sec)
140
120
100
C++11
C#
Java
80
60
40
20
0
0
10
20
30
Numero di thread
Grafico 2
35
40
50
Dal grafico dei tempi di esecuzione notiamo che il C# è di gran lunga più lento rispetto a i
restanti linguaggi, con un andamento che tende migliorare all'aumentare del numero di
thread. Il C++11 inizialmente risulta essere più lento del Java, ma all'aumentare del
numero di thread il loro tempi tendono a coincidere.
Le statistiche appena riportate sono del tutto generali, dato che inquadrano solo un tipo di
ambito d'uso.
Il C#, come il Java, lascia meno possibilità e libertà al programmatore, però assicurando
vantaggi riguardanti sia la sicurezza sia riguardo la velocità di apprendimento e leggibilità
del codice. C# e Java partono da un livello di astrazione superiore a quello del C++, il che
comporta un totale disinteresse circa tutte le problematiche che possono insorgere quando
si programma a basso livello perdendo quindi proprietà di flessibilità e potenza espressiva.
Ovviamente tale target di scelte da parte di questi due linguaggi non sono del tutto casuali:
nel caso di C#/Java, i loro ambiti di utilizzo sono principalmente tutti quei campi che si
occupano di sviluppare applicazioni che devono interagire pesantemente col mondo della
rete, del web, dei database, delle applicazioni distribuite, etc., per cui si è deciso di
“semplificare” la vita del programmatore fornendogli un livello di astrazione più alto.
Il C++ è stato pensato invece per andare a coprire target quali applicazioni time, sistemi
embedded, etc., ovvero tutti quesi settori per cui è necessario e conveniente agire a
differenti livelli di astrazione.
Quindi la domanda da porti non è “quale de tre linguaggi è il migliore per la trattazione dei
thread?”, ma è buona prassi studiare il proprio caso d'uso e scegliere il miglior linguaggio
per il relativo ambito di sviluppo della propria applicazione. Quindi la miglior risposta
resta sempre “dipende”.
36
Bibliografia
[1] Bjarne Stroustrup, “C++, Linguaggio, libreria standard, principi di programmazione”,
Pearson, 2015;
[2] Daniele Bocchino, Cristian Civera, Marco De Sanctis, Alessio Leoncini, Marco
Leonicini, Stefano Mostarda, “C# 6 e Visual Studio 2015, Guida completa per lo
sviluppatore”, Hoepli, 2016;
[3] Paolo Ancilotti, Maurelio Boari, Anna Ciampolini, Giuseppe Lipari, “Sistemi
Operativi, Seconda edizione”, McGraw-Hill,2008;
[4] Oracle, https://docs.oracle.com/javase/7/docs/api/;
[5] cplusplus, http://www.cplusplus.com;
[6] cppreference, http://en.cppreference.com/;
[7] MSDN,https://msdn.microsoft.com/it-it;
[8] “A comparative analysis between parallel models in C/C++ and C#/Java “,
http://kth.diva-portal.org/smash/get/diva2:648395/FULLTEXT01.pdf , KTH Information
and Communication Technology;
[9] Wikipedia, https://en.wikipedia.org/wiki/Comparison_of_Java_and_C%2B%2B ;
[10] Bruce Eckel, “Thinking in Java”, Prentice Hall, 2006.
Scarica