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.