Tutorial: multitasking e CPU multicore

I processori in commercio per i Personal Computer raramente ormai hanno meno di due core, ed è piuttosto comune che ne abbiano quattro o più. I nostri computer sono quindi equipaggiati con due o più unità di elaborazione e sono perciò in grado di svolgere più di un compito per volta
Se questo già è un vantaggio per chi ha molte applicazioni aperte contemporaneamente, potrebbe non essere un gran beneficio per chi invece esegue un programma alla volta, magari piuttosto pesante, se i suoi programmi non sono ottimizzati per sfruttare le qualità degli odierni calcolatori.Utilizzo parallelo CPU

Indice

  • Multitasking, multithreading
  • Programmazione e librerie software
  • I problemi
  • Gli esempi
  • Esempio 1: Multitasking in C su sistemi POSIX con pipe
  • Esempio 2: Multitasking in C su sistemi POSIX con memoria condivisa
  • Esempio 3: Multitasking in C su Windows con pipe
  • Esempio 4: Multithreading in C con PThread
  • Esempio 5: Multithreading in Java
  • Esempio 6: Multithreading in C# su piattaforma .NET

Multitasking, multithreading
La maggior parte dei sistemi operativi è capace da tempo di eseguire più di un programma alla volta dandoci l’illusione che questi vengano eseguiti in parallelo anche con processori a singolo core e danno la possibilità a questi programmi di comunicare, se necessario. Questi sistemi sono, insomma, multitasking. Senza questa possibilità saremmo fermi all’MS-DOS in cui lanciavamo solo un programma alla volta e anche l’antivirus in tempo reale sarebbe solo un sogno irrealizzato! Quella che vedremo qui è un’estensione di questa possibilità, pure molto comune. Infatti i programmi a loro volta possono essere divisi in più “task” da eseguire parallelamente, dividendo fra essi il lavoro da svolgere. Questo porta benefici indiscutibili anche su CPU a singolo core, dato che anche senza una vera esecuzione parallela, un task che viene eseguito durante i tempi morti di un altro incrementa comunque le prestazioni.
Questi “task” hanno nomi e peculiarità diverse e possono essere offerti dal sistema operativo, dal linguaggio di programmazione, da una libreria software o anche da tutti questi, con diverse corrispondenze fra l’uno e l’altro. Vediamo i principali:

  • Processo: è l’entità principale considerata dal sistema operativo, composta da un certo numero di istruzioni da eseguire e dalle risorse assegnate per la loro esecuzione. A ogni programma in esecuzione è associato almeno un processo e viceversa. Ogni processo ha risorse separate dagli altri (salvo alcune eccezioni) ed in particolare ha un suo spazio di memoria assegnato. A ogni processo viene assegnato a turno uno dei processori (o dei core) disponibili per un certo quanto di tempo, così che ci sia un’esecuzione parallela, vera o simulata.
  • Thread: è un’entità che esiste all’interno di un processo. Ciascun thread all’interno di un processo condivide le risorse con gli altri ma esegue proprie istruzioni in modo parallelo. Non tutti i sistemi operativi offrono nativamente i thread, ma nei sistemi in cui non sono presenti possono comunque essere aggiunti dal linguaggio di programmazione o da una libreria software. Si parla di kernel thread per indicare un thread gestito direttamente dal sistema e di user thread per indicarne uno gestito dal software al di sopra di esso. Ciascuno ha i suoi vantaggi: un user thread di solito viene creato più velocemente perché non ha bisogno di effettuare chiamate al sistema operativo, mentre un kernel thread ha il vantaggio di poter essere eseguito da un processore (o core) separato come avviene per i processi e quindi di essere potenzialmente più veloce sulle nuove CPU nonché di essere gestito in modo più efficiente perché il sistema operativo può conoscere le risorse richieste di ogni thread e non solo quelle globali del processo. Questo vale anche in alcuni casi di deadlock, poiché un thread bloccato non necessariamente blocca tutto il processo.

Programmazione e librerie software
Quanto detto fin qui dovrebbe essere chiaro, ad eccezione di una cosa: se volessimo scrivere un programma diviso in più task, dovremmo farlo sfruttando i processi o i thread? Ebbene, non c’è una risposta unica: dipende dai requisiti del nostro programma, ma anche da cosa offre il sistema o i sistemi in cui intendiamo farlo girare ed anche cosa offre il linguaggio nel quale vorremmo scriverlo. Alcuni linguaggi, come Java, offrono la possibilità di dividere il programma in più thread ma non in più processi e non ci è dato sapere a priori se la macchina virtuale li mapperà in kernel thread, se disponibili. Certi sistemi operativi, come Windows, ci offrono kernel thread e user thread (chiamati fiber). Per quanto riguarda il mondo Unix, dipende dallo specifico sistema: Linux offre i kernel thread, Solaris no. Inoltre, linguaggi come il C non hanno sempre lo stesso set di chiamate, che dipendono esclusivamente dalle librerie, quindi un programma C scritto usando le chiamate specifiche di Linux o Windows per i thread sarà impossibile da compilare su un altro sistema senza modifiche. Sono tutti problemi da valutare prima di iniziare a scrivere il nostro software ed è il caso di documentarci bene per non dover modificare tutto a cose fatte. Se il nostro linguaggio (ad es. Java) ci impone dei paletti non abbiamo scelta, ma se per qualche motivo dobbiamo usarne uno come il C può venirci in aiuto qualche libreria software esistente su diversi sistemi operativi. Un esempio è POSIX Thread, che specifica un insieme di funzioni per la gestione dei thread uguali per ogni sistema operativo nel quale è implementata. Questa libreria esiste nella maggior parte dei sistemi conformi alle specifiche POSIX ed è disponibile anche per Windows. L’unico neo è che non sappiamo di preciso se una specifica implementazione usa kernel o user thread, sebbene abbia notato che quando sono disponibili si preferiscono gli ultimi. Un’altra libreria è GNU Portable Thread, estremamente portabile su sistemi POSIX ed utilissima se vogliamo scrivere programmi per sistemi Unix-like senza curarci del fatto che avremo o meno la libreria POSIX Thread (con la quale è fornita compatibilità) al momento della compilazione. I thread tuttavia sono implementati esclusivamente in modo utente. Un’altra piccola nota per i linguaggi C e C++: lo standard del 2011 (C11 e C++11) definisce un set di chiamate standard per i thread. Tuttavia ancora questo standard non è ancora molto diffuso e la maggior parte dei compilatori non lo implementa del tutto, quindi per il momento bisogna comunque ricorrere a delle librerie aggiuntive. Ce ne sono tante, tutte con vantaggi e svantaggi.

I problemi
Questo modo di concepire i programmi non è privo di insidie. Dobbiamo mettere in chiaro alcune cose:

  1. Due o più task potrebbero condividere delle risorse, come delle porzioni di memoria.
  2. Come sappiamo, un programma è composto da istruzioni. Le istruzioni che noi scriviamo nel nostro linguaggio possono essere a loro volta composte da più istruzioni macchina.
  3. Il passaggio da un task all’altro può avvenire in qualsiasi momento, anche fra un’istruzione macchina e l’altra, e quindi nel bel mezzo dell’esecuzione di una delle nostre istruzioni.

Cosa accadrebbe se un task venisse interrotto mentre scrive una variabile condivisa e un altro cercasse di leggerla? La variabile avrebbe un valore inconsistente!
Abbiamo già accennato al fatto che insieme alla possibilità di avere più task abbiamo anche quella di farli comunicare. Questo può avvenire per esempio creando una porzione di memoria comune, che è di fatto una risorsa condivisa. Fortunatamente insieme a questa possibilità abbiamo anche diversi meccanismi di protezione che consentono l’accesso in mutua esclusione a una risorsa, cioè di consentire l’accesso a un task per volta, e in generale di sincronizzare l’accesso stesso a tali risorse. Vedremo diversi di questi meccanismi negli esempi. I concetti generali che vi suggerisco di approfondire sono i seguenti:

  1. Il problema per il quale il risultato dell’elaborazione da parte di più task dipende dall’ordine di esecuzione dei task stessi si chiama race condition
  2. Per prevenire le race condition garantiamo la mutua esclusione creando delle sezioni critiche per proteggere il codice che accede alle risorse condivise fra più task. Creare una sezione critica significa vincolare una porzione di codice ad essere eseguita da un task per volta
  3. Le sezioni critiche devono essere il meno possibile ed anche più brevi possibile perché la loro presenza limita l’esecuzione parallela dei task degradando le prestazioni. Viene da sé che dobbiamo condividere solo il minimo di risorse necessario.
  4. Dobbiamo badare attentamente che il modo in cui definiamo le sezioni critiche non possa creare deadlock. Per spiegare cos’è un deadlock, immaginiamo due task, T1 e T2. Entrambi hanno bisogno delle risorse A e B. In un primo momento T1 impegna A e T2 impegna B. In un secondo momento T1 richiede B e T2 A senza che queste risorse siano state prime rilasciate. La nostra elaborazione è bloccata per sempre! Questo è un deadlock.

Gli esempi
Vi mostrerò ora alcuni esempi di utilizzo di processi e thread su sistemi e linguaggi diversi, in modo che possiate rendervi conto delle differenze. Aggiungerò qualche commento per illustrare vantaggi e svantaggi, nonché una brevissima descrizione delle chiamate utilizzate, senza la pretesa di fornire una spiegazione dettagliata, per la quale vi rimando alla loro documentazione ufficiale.
I programmi degli esempi faranno tutti la stessa identica cosa, ma la faranno di volta in volta utilizzando caratteristiche diverse del sistema operativo o del linguaggio di programmazione utilizzato. Si tratta del classico problema del produttore e del consumatore: un produttore genera dei dati e li invia al consumatore che li elabora in qualche modo. Sebbene l’esecuzione parallela possa servire in moltissimi altri casi, questo esempio può diventare “reale” ed è perfetto anche per mostrare la comunicazione e la sincronizzazione fra processi o thread. Eseguendo questi programmi vedremo in output i messaggi del produttore e del consumatore senza un ordine preciso, perché non ci è dato sapere a priori come il sistema operativo o la libreria decidono quale fra i processi o i thread pronti deve essere eseguito in un determinato momento. L’unico vincolo sarà la sincronizzazione che daremo per evitare che il produttore scriva più dati di quelli che il buffer può contenere e che il consumatore cerchi di leggere il buffer quando è vuoto.
Chi avesse bisogno di ulteriori spiegazioni o di un esempio con un altro linguaggio può sentirsi libero di richiedermeli! Ne arriveranno altri man mano che mi verrà in mente di provare.
Dovrete avere già dimestichezza col linguaggio che andare a considerare perché essendo questo un tutorial di programmazione avanzata non mi perderò in spiegazioni sulle basi.
Scarica gli esempi

Esempio 1: Multitasking in C su sistemi POSIX con pipe (cartella c-fork-pipe)
Questo esempio è il più semplice. Facciamo uso della chiamata fork dei sistemi Unix. Questa chiamata crea un processo identico a quello che la effetua. Gerarchicamente, il secondo processo sarà considerato “figlio” del primo. I due processi, come da definizione, sono completamente distinti ed anche se sono identici hanno fra l’altro la loro copia delle variabili. Per la comunicazione fra i due usiamo una pipe. La pipe si comporta come un tubo: da un’apertura inseriamo un numero arbitrario di byte e da un’altra li estraiamo. La pipe va creata prima di sdoppiare il processo, in modo che sia condivisa fra padre e figli. La pipe garantisce automaticamente la mutua esclusione, inoltre il processo che scrive viene bloccato se la pipe è piena e quello che legge se la pipe è vuota, così non dobbiamo occuparcene esplicitamente.
Questo metodo garantisce la massima portabilità fra sistemi Unix-like, perché tutti hanno la chiamata fork e le pipe. I processi ovviamente sfruttano le CPU multicore. Il maggiore svantaggio è il consumo di risorse: un processo occupa più memoria del sistema operativo rispetto a un thread, inoltre se anche il processo figlio deve svolgere poco lavoro è una copia esatta del padre e la copia stessa occupa tempo, quindi la creazione sarà più lenta di quella di un thread.

Esempio 2: Multitasking in C su sistemi POSIX con memoria condivisa (cartella c-fork-sysv)
In questo esempio usiamo ancora la chiamata fork per creare dei nuovi processi, ma per la comunicazione utilizziamo un’area di memoria condivisa creata usando delle chiamate di sistema introdotte per la prima volta su UNIX System V ed ora standard POSIX. Se vi state chiedendo perché non abbiamo dichiarato un normale array per implementare il buffer, significa che non avete ancora capito niente sui processi :-). Infatti la fork creerebbe una copia di tale array per ogni processo e comunque i processi figli non potrebbero puntare la copia del padre perché si troverebbe in uno spazio di memoria riservato a quest’ultimo! Un vantaggio della memoria condivisa è che può essere usata anche fra processi non “imparentati”.
Questa volta dobbiamo gestire esplicitamente la sincronizzazione e la mutua esclusione per l’accesso al buffer. Lo facciamo usando il sistema più classico: i semafori! Brevemente, il semaforo è un tipo di dato astratto sul quale possiamo fare tre operazioni: inizializzazione, wait e signal (nell’esempio si chiama post). L’inizializzazione da al semaforo un valore intero. La wait controlla il valore del semaforo. Se è maggiore di zero lo decrementa. Se è zero il processo viene bloccato e messo in una coda. La signal controlla se ci sono processi in coda per aver chiamato una wait. Se ce ne sono sblocca il primo, altrimenti incrementa il valore del semaforo. La particolarità delle primitive per l’utilizzo dei semafori è che sono atomiche. Ciò significa che non possono essere interrotte: se il sistema operativo passa da un processo all’altro lo fa prima o dopo l’esecuzione di una di queste istruzioni, ma mai durante, come può avvenire per tutte le altre.
I semafori creati sono tre: uno che indica se il buffer ha spazio libero, uno che indica se vi sono elementi dentro e uno è un semaforo binario, detto anche mutex, che serve a garantire l’accesso in mutua esclusione al buffer. Questo sistema consente un accesso molto efficiente alla memoria condivisa. Da notare che se fossimo stati disattenti avremmo messo la wait sul mutex prima di quella sull’altro semaforo e questo presto o tardi avrebbe causato un deadlock, riflettete voi sul come!

Esempio 3: Multitasking in C su Windows con pipe (cartella c-win-pipe)
Questa è una specie di versione per Windows dell’esempio 1. In realtà la similitudine è un po’ forzata perché la funzione CreateProcess esige un file eseguibile come parametro, quindi siamo costretti a compilare il programma principale, il produttore e il consumatore separatamente. Venendo a mancare un handle condiviso per la pipe come avviene su Unix dobbiamo collegare la pipe agli stream di I/O dei processi che creiamo. Per mantenere l’esempio semplice e più simile possibile a tutti gli altri ho dovuto fare ricorso a dei trucchi abbastanza brutti, ma l’importante è vedere i meccanismi. Esiste un’altro tipo di pipe, la Named Pipe, a cui ci si riferisce chiamandola per nome come un file e che è comoda per certi utilizzi o anche la memoria condivisa.

Esempio 4: Multithreading in C con PThread (cartella c-pthread)
In questo esempio utilizziamo la libreria POSIX Thread (pthread) di cui abbiamo parlato nella parte teorica. Il funzionamento è semplicissimo: si chiama la funzione pthread_create e le si passa una funzione da eseguire in un thread separato con gli eventuali parametri. In questo caso possiamo creare il buffer come semplice array perché i thread condividono tutti lo stesso spazio di indirizzamento, quindi niente “strane” chiamate per l’allocazione! Questa versione ha il vantaggio di poter essere eseguita anche su Windows senza modifiche: basta scaricare la libreria pthread-win32. Come già detto, inoltre, la creazione di un thread è un’operazione meno costosa della creazione di un processo e ci sono diversi altri motivi per cui potremmo preferirli. Anche qui usiamo i semafori per la sincronizzazione. Si veda l’esempio n.2 per la spiegazione sui semafori.

Esempio 5: Multithreading in Java (cartella java)
Java ci offre la possibilità di creare Thread separati.
Il codice che andrà a eseguire il lavoro in un thread distinto dovrà essere messo in una classe che implementi l’interfaccia Runnable. L’istanza di questa classe poi sarà passata a un oggetto Thread. Per la comunicazione fra produttore e consumatore ho creato un’ulteriore classe synchronizedBuffer. Questa, attraverso l’uso della parola chiave synchronized nella dichiarazione dei metodi, si assicura automaticamente che i due thread non cerchino di operare contemporaneamente sul buffer. Inoltre genera un’eccezione se si cerca di leggere da buffer vuoto o scrivere su buffer pieno. In questo caso chi raccoglie l’eccezione aspetta un breve intervallo prima di riprovare. Forse ci sono metodi più efficienti di gestire queste incombenze, ma così l’ho mantenuto più semplice. Ho già accennato al fatto che non possiamo sapere a priori se questi thread saranno gestiti dal sistema operativo o se si tratta di user thread. Tuttavia, a mio parere, questo può essere trascurato: se avete scelto Java più delle prestazioni vi interesserà il fatto di poter eseguire il programma su qualsiasi sistema dotato di una Java Virtual Machine.

Esempio 6: Multithreading in C# su piattaforma .NET (cartella c#-thread)
La piattaforma .NET della Microsoft ci offre una grande varietà di strumenti per rendere più agevole la nostra programmazione. Uno di questi è la possibilità di creare più Thread, che ove possibile (su Windows, ad esempio) saranno gestiti direttamente dal kernel e quindi si avvantaggeranno delle CPU multicore.
L’esempio somiglia a quello Java. Per eseguire un qualunque metodo su un thread separato basta passarlo a un’istanza della classe Thread e invocare su essa il metodo Start. Per la sincronizzazione .NET offre molti oggetti, ma noi andiamo sul classico usando i semafori! Si veda l’esempio n.2 per la spiegazione. La classe Semaphore (e la controparte più leggera SemaphoreSlim) è molto semplice da usare, anche se la documentazione non è, a mio parere, molto chiara in alcuni punti. Li creiamo dando il massimo numero di Thread che possono accedervi ed il valore iniziale. La differenza rispetto a come li abbiamo già visti è che al posto del metodo Post o Signal abbiamo Release, ma l’uso è uguale. In quest’esempio al posto di usare esplicitamente un Mutex facciamo uso dell’istruzione lock che garantisce l’accesso in mutua esclusione al buffer. Come per l’esempio in Java, la gestione del buffer è completamente incapsulata nella classe SynchronizedBuffer, con la differenza che il funzionamento è più trasparente in quanto non genera eccezioni, ma blocca il thread chiamante se si cerca di leggere il buffer vuoto o si cerca di scrivere scrive sul buffer pieno. Da un lato la gestione è più semplice, ma dall’altro c’è maggior rischio di deadlock, se non si sta attenti!
Ques’esempio vale per tutti i linguaggi che supportano .NET, dal momento che le classi sono le stesse e cambiano solo le parole chiave (per esempio per Visual Basic si veda SyncLock al posto di lock). Per quanto riguarda la portabilità, ci sono molte implementazioni alternative del Framework .NET, di cui una della stessa Microsoft per Mac OS X e BSD. Per quanto riguarda Linux, ho eseguito con successo questo stesso esempio con Mono.

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *