UNIVERSITÀ DEGLI STUDI DI PARMA
Dipartimento di Matematica e Informatica
Corso di Laurea in Informatica
Un caso di studio
sulla Thread Safety:
la Parma Polyhedra Library
Candidato: Maxim Gaina
Relatore: Prof. Enea Zaffanella
Anno Accademico 2013/2014
Indice
Introduzione
3
1 Thread Safety
1.1 Codice Thread Unsafe . . . . . . . . . . . . . . .
1.1.1 Tabella dei simboli . . . . . . . . . . . . .
1.2 Strategie per scrivere codice Thread Safe . . . . .
1.2.1 Programmazione funzionale . . . . . . . .
1.2.2 Conservazione stato nel thread chiamante
1.2.3 Thread Local Storage . . . . . . . . . . . .
1.2.4 Mantenere istanze diverse di una libreria .
1.2.5 Stati Condivisi . . . . . . . . . . . . . . .
1.2.6 Strategie adottate nel caso della PPL . . .
2 L’ambiente di sviluppo
2.1 Piattaforma . . . . . . . . . . . .
2.1.1 Configurazione elaboratore
2.2 Configurazione dell’ambiente . . .
2.3 Debug . . . . . . . . . . . . . . .
2.4 Altri strumenti . . . . . . . . . .
2.4.1 GNU nm . . . . . . . . . .
2.4.2 GNU grep . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
3 PPL e Thread Local Storage
3.1 Conditional Thread Safety . . . . . . . . . . . . . . . . .
3.1.1 Standard Template Library C++ e variabili const
3.1.2 PPL e variabili const . . . . . . . . . . . . . . . .
3.2 Thread Local Storage . . . . . . . . . . . . . . . . . . . .
3.2.1 Alternative allo standard C++: TLS in GCC . .
3.2.2 TLS in C++11 . . . . . . . . . . . . . . . . . . .
3.3 Ricerca di codice thread-unsafe . . . . . . . . . . . . . .
3.3.1 Ricerca testuale . . . . . . . . . . . . . . . . . . .
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
6
6
8
10
10
11
11
12
13
13
.
.
.
.
.
.
.
15
15
16
16
17
18
18
19
.
.
.
.
.
.
.
.
20
20
20
21
22
22
23
25
26
INDICE
3.4
3.5
3.6
3.7
2
3.3.2 Ricerca sulla tabella dei simboli
Modifiche TLS . . . . . . . . . . . . .
3.4.1 Dichiarazioni PPL_TLS . . . .
Inizializzazione . . . . . . . . . . . . .
Funzione wrapper . . . . . . . . . . . .
Interfaccia C . . . . . . . . . . . . . . .
3.7.1 Inizializzazione . . . . . . . . .
4 Testing
4.1 ppl_lcdd . . . . . . . . . . . .
4.1.1 Modifiche a ppl_lcdd .
4.1.2 Prova pratica . . . . .
4.2 ppl_lpsol . . . . . . . . . . .
4.2.1 Modifiche a ppl_lpsol
4.2.2 Prova pratica . . . . .
Bibliografia
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
27
28
28
30
31
33
34
.
.
.
.
.
.
36
36
37
40
42
43
45
50
Introduzione
Un thread di esecuzione è un singolo flusso di controllo in un programma, a
partire dall’invocazione iniziale di una funzione e includendo ricorsivamente
ogni funzione successivamente invocata. In un processo, che è il programma
in esecuzione, ogni thread può accedere ad un oggetto o funzione ad esso
appartenente. L’esecuzione di un programma consiste nell’esecuzione di tutti
i suoi thread.
Per single threading si intende l’esecuzione di un processo formato da un
unico thread, per cui in ogni momento è in esecuzione una sola istruzione del
programma.
Per multi threading si intende un metodo di programmazione ed esecuzione che permette l’esistenza di più thread nello stesso contesto, che riguardano
un processo. In altre parole è l’esecuzione concorrente di diversi thread, dove questi ultimi condividono risorse come la memoria, il codice eseguibile, i
descrittori dei file e le periferiche in uso.
L’insieme degli elementi appena elencati formano lo stato del processo.
Parma Polyhedra Library
La Parma Polyhedra Library (PPL, [1]) fornisce astrazioni numeriche indirizzate soprattutto alle applicazioni nel campo dell’analisi e verifica di sistemi
complessi. Queste astrazioni includono:
• poliedri convessi, definiti come l’intersezione di un numero finito di
semispazi (aperti o chiusi), ciascuno descritto da una disuguaglianza
lineare (stretta o non stretta) con coefficienti razionali;
• alcune classi speciali di poliedri che hanno lo scopo di offrire un compromesso tra complessità e precisione;
• grid (griglie) che rappresentano punti regolarmente distanziati che soddisfano una serie di relazioni di congruenza lineari.
Introduzione
4
La libreria supporta anche sottoinsiemi finiti di poliedri e prodotti cartesiani tra poliedri (di qualsiasi tipo) e grid, un risolutore di problemi di programmazione lineare a interi misti tramite una versione ad aritmetica esatta
dell’algoritmo del simplesso, un risolutore per la programmazione lineare parametrica, e primitive per l’analisi di terminazione via sintesi automatica di
funzioni lineari di ranking.
PPL e Multithreading
Per iniziare è bene distinguere due diversi obiettivi: quello di scrivere librerie multi-thread e quello di scrivere librerie che possono essere chiamate da
programmi multi-thread, garantendo la correttezza.
È possibile creare una libreria che è multi-thread internamente, cioè alla
ricezione di una chiamata essa suddivide il lavoro su diversi thread per ottenere un miglioramento prestazionale (vedere Figura 1). Alternativamente,
Figura 1: Libreria che genera internamente dei thread
si può volere una libreria che si comporta in maniera corretta se chiamata
da un programma multithread. Quando una libreria possiede questa seconda
proprietà, si dice che essa è Thread Safe (vedere Figura 2). Altrimenti, si
dirà che la libreria è Thread Unsafe.
Introduzione
5
Figura 2: Libreria thread-safe
Inizialmente la PPL non aveva nessuna delle proprietà sopra elencate: era
una libreria single-thread e thread-unsafe. Infatti, lo scopo di questo lavoro è
rendere la PPL thread-safe e non verrà presa in considerazione la possibilità
(seppure interessante) di aumentare le prestazioni della libreria facendole
generare thread internamente. Ci si concentra appunto sulla sua correttezza
e robustezza nel caso l’utente volesse utilizzarla in un applicativo a thread
multipli. Una libreria, ovviamente, può contemporaneamente essere threadsafe e generare thread internamente.
Capitolo 1
Thread Safety
Come già accennato, ogni thread viene eseguito indipendentemente dagli altri
e ognuno esegue diverse sequenze di istruzioni. In ogni caso tutti i thread
condividono lo stesso spazio degli indirizzi, e possono accedere alle stesse
istanze di certe categorie di dati. Infatti nella programmazione multi-thread
le variabili globali rimangono tali, puntatori e riferimenti ad oggetti possono
essere condivisi fra thread.
In questo capitolo si parlerà degli elementi più sensibili dal punto di vista
della thread-safety, di come individiarli e delle possibili soluzioni.
1.1
Codice Thread Unsafe
Verranno mostrati in maniera preliminare le tipologie di variabili che rendono
thread-unsafe una porzione di codice. Come è stato già detto i thread condividono le risorse del proecesso, quindi bisogna vedere come è strutturato
quest’ultimo per individuarne i punti critici. Trattandosi di una libreria dedicata a calcoli matematici è possibile escludere ragionamenti su risorse che
non siano la memoria di un processo: sarà l’utente a preoccuparsi di eventuali condivisione degli stessi file descriptor, socket e così via. Sarà invece
fondamentale la gestione della memoria. Nell’architettura di un processo 1.1,
la memoria assegnatagli è generalmente composta dalle seguenti aree:
• stack ;
• heap;
• area per allocazione statica dei dati, cioè segmento dei dati;
• segmento del codice del programma;
1.1 Codice Thread Unsafe
7
Stack L’area dello stack non presenta momenti critici in relazione all’obiettivo posto, dal momento che ogni thread ne possiede uno ed è destinato
a contenere le variabili locali, che contano esclusivamente nello stack frame
in cui si trovano. Questa sicurezza però potrebbe essere compromessa se si
prende l’indirizzo di un oggetto sullo stack e lo si passa ad un altro thread:
tali situazioni vanno gestite con cura, possono essere causati dei data race e
puntatori ad aree deallocate (se il thread a cui apparteneva tale stack non
c’è più).
Heap L’heap è l’area assegnata al processo per rispondere alle esigenze
di allocazione dinamica della memoria. Essa è condivisa da tutti i thread
presenti nel processo e a prima vista potrebbe essere un problema per la
thread-safety. Lo standard C++11 (come anche quello precedente) offre le
new expression [8, New, Sezione 5.3.4]. Esempio di new expression:
1 Object * object = new Object ( args ...) ;
2
3 int * array1 = new int [5] {1 , 2 , 3 , 4 , 5};
Una volta chiamata, una new expression ottiene spazio per l’oggetto chiamando l’operator new, se il tipo da allocare è un array invoca l’operator
new[] [8, Dynamic memory management, Sezione 18.6]. Dopo di che la new
expression invoca il costruttore del relativo oggetto inizializzandolo nella locazione appena ottenuta. Gli elementi sui cui porre attenzione ora sono due:
operator new e costruttori. Per quanto riguarda l’operator new, lo standard promette che, in caso di chiamate concorrenti, esso allocherà una porzione di memoria alla volta, senza quindi creare problemi [8, Data races,
Sezione 18.6.1.4]. La new expression potrebbe invocare più costruttori: quello dell’oggetto per il quale è stata chiamata, costruttori di sue classi base
e costruttori di suoi membri. Tutti loro devono essere thread-safe per far sì
che lo sia l’intera new expression. Il ragionamento è analogo per le delete
expression [8, Delete, Sezione 5.3.5] e relativi operator delete, operator
delete[] e distruttori coinvolti. Anche qui c’è il rischio che un thread che
ha ottenuto un indirizzo tramite la new expression lo renda disponibile ad
altri thread; quindi è necessario evitare data race e puntatori non validi.
Allocazione statica dei dati Il segmento dei dati è usato per contenere
le variabili globali e static inizializzate già a tempo di compilazione. Il
segmento può essere ulteriormente suddiviso in area per sola lettura o per
lettura e scrittura. Mentre le variabili di sola lettura non presentano alcun
pericolo, le altre fanno parte dello stato condiviso del processo modificabile.
1.1 Codice Thread Unsafe
1
2
3
4
5
6
7
8
9
10
11 }
8
// Costante globale ( nessun pericolo )
const int a = 1;
// Variabile globale in area dati
char b = ’B ’;
void foo () {
// Variabile static in area dati
static int c = 2;
return ;
L’area .bss che solitamente fa parte del segmento dati, contiene le variabili
non inizializzate. Essa contiene tutte le variabili static e globali che sono
state inizializzate a valore nullo o non hanno nessuna inizializzazione esplicita
nel codice sorgente.
1
2
3
4
5
6
7
8
9
10
11
12
13
// Variabile globale costante ( nessun pericolo )
const int a ;
// Variabili globali in area . bss
extern char b ;
double c = 0;
void foo () {
// Variabile static in area . bss
static int d ;
return ;
}
Quindi si è visto che gli oggetti critici per la thread-safety sono costruttori
e distruttori, variabili allocate staticamente e le variabili allocate su stack e
heap soggette a condvisione. La responsabilità per la condivisione di variabili
allocate su stack e heap è nelle mani dell’utente. Nel capitolo a venire tutti
gli aspetti critici verranno trattati separatamente fornendo delle soluzioni
adeguate.
1.1.1
Tabella dei simboli
La tabella dei simboli è una struttura dati usata da compilatori e interpreti,
dove ogni identificatore nel codice sorgente viene associato alle informazioni
1.1 Codice Thread Unsafe
9
Figura 1.1: Esempio di astrazione dell’architettura di Processo e Thread
relative a dove è stato dichiarato e dove appare, il suo tipo e la porzione del
programma in cui tale identificatore è valido. Attraverso l’uso della tabella dei
simboli è possibile, infatti, risolvere correttamente tutti i riferimenti ai nomi
introdotti dal programmatore, risalendo così all’opportuno oggetto denotato.
Senza cominciare ad analizzare le scelte fatte da ogni singolo compilatore, in
genere ogni linguaggio che supporta scope statico, uso di funzioni, procedure
e diversi tipi di passaggio dei parametri, fa generare una tabella dei simboli
che deve fornire:
• per ogni nome la categoria, cioè variabile, parametro, funzione o procedura;
1.2 Strategie per scrivere codice Thread Safe
10
• per ogni variabile il suo tipo, l’offset nel Record di Attivazione del blocco
in cui è definita;
• per ogni parametro il suo tipo, la modalità di passaggio e l’offset;
• per ogni funzione o procedura l’indirizzo della locazione di memoria
nella zona codice alla quale inizia la sua traduzione.
In presenza di riferimenti a nomi non appartenenti all’ambiente locale di
un blocco, sarà necessario cercare il nome nell’ambiente locale dei blocchi in
cui il blocco attivo in un determinato momento risulta annidato. Al fine di
implementare correttamente lo scope statico e dinamico è necessario, quindi,
conoscere quanti livelli di annidamento ci sono tra il blocco attivo e quello
che definisce il nome utilizzato. Questa informazione può essere calcolata staticamente (e quindi opportunamente memorizzata nella tabella dei simboli)
in regime di scope statico.
La tabella dei simboli risulterà particolarmente importante e la si vedrà
nella Sezione 3.3.2.
1.2
Strategie per scrivere codice Thread Safe
Prima di tutto è bene conoscere alcuni approcci da seguire per arrivare ad
avere una libreria thread-safe. Si vedrà di seguito quali di questi approcci si
riveleranno utili per la PPL.
1.2.1
Programmazione funzionale
Si tratta di un paradigma di programmazione in cui il flusso di esecuzione del
programma assume la forma di una serie di valutazioni di funzioni. Il punto
di forza principale di questo paradigma è la mancanza di effetti collaterali
delle funzioni, il che comporta una più facile verifica della correttezza e della
mancanza di bug del programma e la possibilità di una maggiore ottimizzazione dello stesso. Un uso particolare del paradigma, per l’ottimizzazione dei
programmi, è quello di trasformare gli stessi per utilizzarli nella programmazione parallela. I problemi che sorgono quando si costruisce una libreria
thread-safe generalmente derivano dall’uso di stati condivisi tra le chiamate.
Una scelta è quella di eliminare semplicemente qualsiasi stato memorizzato,
utilizzando uno stile di programmazione funzionale.
Tuttavia la programmazione funzionale è una scelta opportuna se si scrive
una libreria da zero, e non è il caso della PPL. Quest’ultima contiene codice
scritto anche non recentemente, e in funzione soprattutto dell’efficienza: sono
1.2 Strategie per scrivere codice Thread Safe
11
presenti un numero non trascurabile di variabili globali (e anche variabili
locali allocate staticamente).
1.2.2
Conservazione stato nel thread chiamante
In questo modello, lo stato delle variabili viene mantenuto dal chiamante.
L’API (Application Programming Interface) deve fornire un modo alla libreria per accedere allo stato, in genere utilizzando un puntatore passato ad
ogni chiamata alla libreria. Così facendo ogni chiamante mantiene uno stato
completamente isolato da altri thread ed è un approccio comodo quando si
prevedono scambi di flussi di controllo fra diversi thread (Figura 1.2).
Figura 1.2: Conservazione stato nel chiamante
Il principale svantaggio è che l’API in questione va modificata, richiedendo modifiche alle applicazioni. Prendere questa strada significa complicare
parecchio la situazione, nel caso della PPL.
1.2.3
Thread Local Storage
Gli stati delle variabili si possono salvare nell’ambiente locale dei thread.
Da qui nascono le variabili Thread Local, esse permettono di avere istanze
separate dello stesso oggetto per ogni thread del programma. Il punto di
1.2 Strategie per scrivere codice Thread Safe
12
forza di questo approccio è che non richiede la modifica dell’interfaccia della
libreria (Figura 1.3).
Figura 1.3: Thread Local Storage
Si potrebbe obiettare che il metodo Thread Local Storage (TLS), in alcuni
casi, trasformi in difetto il suo più grande pregio: quello di isolare completamente i thread gli uni dagli altri. Questo può essere un problema quando
un applicativo prevede di passare flussi di esecuzione fra thread. Si vedrà
comunque che tale problema è aggirabile e, siccome il meccanismo TLS sembra interessante e complica di meno l’obiettivo posto, verrà scelto e discusso
meglio più in avanti (si veda nella Sezione 3.2).
1.2.4
Mantenere istanze diverse di una libreria
Durante l’esecuzione, è possibile caricare in memoria copie separate della
stessa libreria che può essere Thread Unsafe, quindi con un’istanza unica
per ogni thread chiamante. Il vantaggio di questa scelta sta nel fatto che il
programmatore può caricare in memoria tante copie della stessa libreria (non
thread-safe) quanti sono i thread di cui ha bisogno, senza per questo dover
conoscere la sua implementazione (Figura 1.4). Non lo si può fare facilmente
su tutti i sistemi operativi, dato che alcuni di loro seguono la politica delle
librerie condivise, ed è senza dubbio una soluzione esigente dal punto di vista
delle risorse.
1.2 Strategie per scrivere codice Thread Safe
13
Figura 1.4: Istanze diverse di una Libreria Thread Unsafe
È inoportuno pensare a una strada del genere, dato che si sposa poco con
l’obiettivo di rilasciare una versione thread-safe della PPL, semplificando il
lavoro degli utenti.
1.2.5
Stati Condivisi
Si vedrà più in avanti che, in un processo, non sempre si ha la necessità
di isolare una variabile a livello di thread. Talvolta questi ultimi devono
condividere lo stato di un oggetto, sarà compito della libreria mantenerlo in
uno stato ben definito e sincronizzare le letture e gli accessi, se ce ne fosse
bisogno (Figura 1.5).
1.2.6
Strategie adottate nel caso della PPL
Si noti che è possibile mescolare più di una tra le metodologie discusse. In
questo caso particolare, l’attenzione cadrà sulle variabili TLS e sugli stati
delle variabili condivisi. Il meccanismo TLS verrà ampiamente discusso nella
Sezione 3.2. Non è possibile sfruttare i principi della programmazione funzionale in quanto la PPL va modificata e non scritta da zero. La PPL è stata già
implementata secondo principi che non rispettano i criteri della programmazione funzionale. Non è nemmeno possibile la conservazione dello stato nel
1.2 Strategie per scrivere codice Thread Safe
14
Figura 1.5: Stati Condivisi
thread chiamante, dato che andrebbe modificata l’interfaccia della libreria. A
sua volta questo implicherebbe dover modificare tutti i programmi già scritti
che usano la PPL. Anche mantenere istanze diverse della libreria può essere
utile in alcuni casi ma non fa al caso della PPL.
Capitolo 2
L’ambiente di sviluppo
Prima di apporofondire il lavoro svolto, in questo capitolo si vedrà la descrizione degli strumenti più rilevanti che sono stati usati. Tutti i dettagli
degli strumenti usati verranno presentati qui; il come sono stati impiegati in
funzione dell’obiettivo posto lo si vedrà in un secondo momento.
2.1
Piattaforma
La PPL è scritta in C++ standard, con l’intento di essere portabile. Sono presenti interfacce in altri linguaggi di programmazione quali C, Java, OCaml e
Prolog.
Per apportare modifiche interne alla PPL è necessario un supporto alla programmazione multi-thread che non comprometta la portabilità della libreria.
A tale scopo lo sviluppo punterà sullo standard C++11, talvolta indicato con
C++0x. Esso infatti è il primo a introdurre il supporto per la programmazione
multi-thread, e, fra gli altri, aggiunge i seguenti elementi:
• libreria per il supporto di thread di esecuzione multipli, e per l’interazione fra loro;
• miglioramento per il modello di accesso sequenziale alla memoria, che
permette la coesistenza di più thread;
• oggetti e operazioni atomiche;
• Thread Local Storage (TLS).
Il meccanismo TLS si rivelerà particolarmente importante, è stato introdotto
nella sezione 1.2.3 e verranno approfonditi i suoi aspetti nella sezione 3.2.
Ci si potrebbe chiedere per esempio quali fra le implementazioni pthread e
2.2 Configurazione dell’ambiente
16
std::thread sia più efficiente. Tuttavia, come già discusso precedentemente,
le modifiche sulla libreria non prevedono l’utilizzo interno di thread attivi,
e, per questo motivo, questo lavoro non ha interesse nello stabilire quale
supporto alla programmazione multi-thread sia più efficiente: sarà l’utente a
seguire le sue preferenze a riguardo, nelle proprie applicazioni.
Verranno analizzate brevemente le alternative all’uso del supporto al multithreading fornito dallo standard C++11. L’utilizzo della libreria Boost [2]
avrebbe comportato alla dipendenza da terzi. L’uso diretto dello standard
POSIX [3] per l’utilizzo dei thread avrebbe portato la necessità di considerare
il modello TLS implementato da GNU Compiler Collection (GCC, [4]), che
porta delle limitazioni. Esso verrà visto nella sezione 3.2.1.
Allo stato attuale esiste già lo standard C++14 che è un’estensione di C++11,
ed esiste già il nome informale C++17 per indicare il prossimo rilascio. Tuttavia C++11 è l’ultima versione completamente supportata da un numero più
ampio di compilatori [10] quali GCC, Clang++ [5] e Intel C++ [6].
2.1.1
Configurazione elaboratore
Hardware Il lavoro è stato svolto su una macchina che supporta 8 thread, resi
possibili da 4 core fisici e altrettanti core logici. È assente qualsiasi
supporto al calcolo parallelo tramite scheda grafica, cioè il così detto
General Purpose Graphic Processing Unit (GPGPU), oggi ampiamente usato. Quindi nel lavoro svolto è stato impiegato esclusivamente il
processore centrale.
SO e Kernel Il sistema operativo alla base è la distribuzione Linux Fedora 21, 64
bit. La versione del kernel è 3.18.7-200.fc21.x86_64.
gcc Versione del compilatore gcc 4.9.2 20150212 (Red Hat 4.9.2-6)
clang Versione del compilatore clang 3.5.0 x86_64-redhat-linux-gnu,
Thread model: posix
2.2
Configurazione dell’ambiente
Facendo uso della nota piattaforma di versionamento dei sorgenti git [11],
è stato creato un branch separato da quello ufficiale della libreria PPL. Tramite alcune passaggi meccanici sono stati scaricati i file sorgenti della PPL. Lo
script di configurazione è stato modificato per aggiungere l’opzione
–enable-thread-safe, mediante il quale si può abilitare il supporto alla
thread-safety. È stato usato il comando autoreconf per aggiornare gli script
2.3 Debug
17
di configurazione. In una directory diversa da quella delle sorgenti, sono state
create due cartelle dedicate alla generazione di eseguibili e ausiliari tramite
il comando make. La prima directory, build, viene usata per compilare la
libreria PPL in modalità produzione. Nel caso della cartella debug, la PPL viene compilata in funzione della ricerca di anomalie e bug. In questa modalità
però, la libreria stessa e i programmi demo presenti nella PPL (per esempio
ppl_lcdd), effettuano numerosi calcoli non necessari per controllare le consistenza interna della libreria e quindi possono risultare estremamente lenti
rispetto alla versione ottimizzata. Ci si sposta in build e si lancia il seguente
comando:
/ppl/configure --enable-interfaces=c,c++ --enable-thread-safe
Dopo di che tocca alla directory debug:
/ppl/configure --enable-interfaces=c,c++ --enable-thread-safe
--disable-optimization --enable-assertions
Il comando configure ha diverse opzioni. Tramite –enable-interfaces
si comunica quali interfacce verso altri linguaggi abilitare. Oltre all’interfaccia c e c++ abilitata sopra, si può abilitare l’interfaccia della PPL verso java,
ocaml e prolog. Nel caso della directory debug è stata disabilitata l’ottimizzazione e sono state abilitate le asserzioni. L’opzione –enable-thread-safe
è stata aggiunta in seguito a questo stesso lavoro per far scegliere all’utente se
vuole usufruire della PPL in modalità di programmazione parallela. Se questa
opzione è assente, verrà compilata la versione della PPL thread-unsafe, ovvero
quella iniziale. Si vedrà più avanti come è stato realizzato tale meccanismo,
nella Sezione 3.4.
2.3
Debug
Durante la fase di stesura di codice aggiuntivo o della modifica di quello
esistente si sono anche verificati casi in cui il codice non funzionava come
desiderato, portando a fallimenti a tempo di esecuzione o risultati inattesi.
Per analizzare le situazioni anomale e risalire alla loro causa sono stati usati,
in questo lavoro di tirocinio, diversi tool per il debugging del codice. In particolare è stato utilizzato il debugger gdb ed i checker messi a disposizione da
valgrind (helgrind, memcheck e drd). Per utilizzare questi strumenti in fase di sviluppo del codice della PPL è necessario invocarli attraverso libtool,
nel modo seguente:
1. libtool --mode=execute gdb --args
./<prog_name> <prog_options>
2.4 Altri strumenti
18
2. libtool --mode=execute valgrind --tool=helgrind
./<prog_name> <prog_options>
3. libtool --mode=execute valgrind --tool=drd
./<prog_name> <prog_options>
4. libtool --mode=execute valgrind --tool=memcheck
./<prog_name> <prog_options>
Nella PPL i comandi precedenti vanno eseguiti nella directory debug. gdb
permette di vedere cosa sta succedendo all’interno di un programma mentre viene eseguito. Esso permette di far partire il programma da analizzare
specificando dei comportamenti che deve assumere; fermare l’esecuzione in
un punto a piacere ed esaminare l’accaduto; apportare dei cambiamenti al
programma in modo da sperimentarne i comportamenti. valgrind offre un
insieme di strumenti che aiutano a rendere il programma più veloce e corretto. helgrind è particolarmente utile in quanto è specializzato nell’identificare
errori di sincronizzazione in ambienti di programmazione parallela. È stato
usato particolarmente per la sua abilità di invidividuare accessi a memoria
senza un’adeguata gestione degli stessi. Uno strumento utile da affiancare
a helgrind è drd, che svolge funzioni simili. memcheck invece è utile per
individuare accessi a porzioni di memoria già rilasciate, individuare l’uso di
valori non ancora inizializzati, memory leak e altro. Quest’ultimo tool si è
rivelato particolarmente utile.
2.4
2.4.1
Altri strumenti
GNU nm
Sui sistemi operativi Unix like, GNU nm è un comando usato per esaminare file
binari quali librerie, codice oggetto o eseguibili. La tabella dei simboli 1.1.1
viene costruita a tempo di compilazione, una parte delle sue informazioni
sono salvate nell’object file per consentire il collegamento. Le informazioni a
disposizione sono più precise se, compilando con gcc, si attiva l’opzione -g.
Esso permette di visualizzare i contenuti di questi file e le meta-informazioni
contenute al loro interno, specialmente quella parte della tabella dei simboli
contenuta nell’object file. Per ogni simbolo viene visualizzato suo valore e
tipo, che può appartenere alle seguenti categorie (lettera minuscola se si tratta
di simboli locali, maiuscola se di tipo extern):
• B b, indica che il simbolo si trova nell’area .bss;
2.4 Altri strumenti
19
• D d, indica che il simbolo si trova nel segmento dati;
• R r, indica che il simbolo è read only.
Per un elenco completo delle categorie di simboli previsti da nm, si rimanda il
lettore alla documentazione ufficiale completa [9]. L’impiego di questo strumento ai fini dello studio della PPL verrà descritto più avanti, nella Sezione
3.3.2.
2.4.2
GNU grep
GNU grep è un programma che prende in input uno o più nomi di file e cerca,
al loro interno, le righe che soddisfano determinati pattern di ricerca. Una
volta individuate, le righe sono copiate su standard output, e questo output
può essere formattato a seconda di opzioni aggiuntive che grep offre. Questi
strumenti torneranno utili per la ricerca testuale di variabili nella Sezione
3.3.1.
Capitolo 3
PPL e Thread Local Storage
3.1
Conditional Thread Safety
La thread-safety si suddivide in tre livelli, partendo dal più debole per finire
con il più forte:
1. Thread Unsafe, quando al codice sorgente non deve accedere più di un
thread alla volta (la situazione iniziale della PPL), nel caso si abbiano
accessi da parte di più thread, l’esito è imprevedibile;
2. Conditionally Thread Safe, quando al codice sorgente vi possono accedere più thread contemporaneamente, ma a condizione che essi non
operino sullo stesso oggetto, nemmeno in lettura. Come nel caso precedente, l’accesso contemporaneo da parte di più thread allo stesso
oggetto, se non protetto da opportuni meccanismi di sincronizzazione
(la cui predisposizione è a carico dell’utente), potrebbe incorrere in un
data race e dare quindi luogo ad comportamento imprevedibile;
3. Thread Safe, se più thread accedono in sola lettura allo stesso oggetto
allora l’utente fa a meno di usare meccanismi di sincronizzazione; se almeno un thread accede in scrittura, vanno implementati degli adeguati
meccanismi di sincronizzazione;
Questi tre termini sono spesso usati dai produttori di software per specificare
con esatteza le proprietà dei loro prodotti.
3.1.1
Standard Template Library C++ e variabili const
Citando lo standard C++11, due valutazioni di espressione vanno in conflitto
se una di loro modifica una locazione di memoria mentre l’altra cerca di accedervi o cercare di modificare la stessa locazione [8, Multi-threaded executions
3.1 Conditional Thread Safety
21
and data races, Sezione 1.10/4]. L’esecuzione di un programma contiene una
data race se contiene due azioni che vanno in conflitto in thread differenti,
una delle quali non è atomica e accade che una non venga eseguita prima
dell’altra [8, Multi-threaded executions and data races, Sezione 1.10/21], in
casi del genere si ottiene undefined behavior.
Lo standard offre anche dei requisiti che ogni implementazione di funzione
appartenente alla Standard Template Library (STL) deve rispettare, affinché
non accadano fenomeni di data race [8, Data race avoidance, Sezione 17.6.5.9].
Una implementazione della STL non deve modificare direttamente o indirettamente oggetti accessibili da thread che non siano quello corrente, a meno
che agli oggetti stessi sia stato fatto accesso, direttamente o indirettamente,
usando gli argomenti non-const della funzione data, this incluso data race
[8, Data race avoidance, Sezione 17.6.5.9/3].
In altre parole questo significa che qualsiasi operazione sugli oggetti di
tipo const è thread-safe, e che quindi la STL è thread-safe. Nella prossima
sezione si vedrà che la PPL, invece, non garantisce questa proprietà.
3.1.2
PPL e variabili const
Verrà spiegato ora, mediante un esempio astratto, il motivo per il quale la PPL
non può essere facilmente resa thread safe. Queste difficoltà ci indirizzeranno verso l’obiettivo della conditional-thread-safety. Si vedrà ora un esempio
astratto.
Siano in un processo due thread t1 e t2 . Supponiamo che entrambi operino sullo stesso numero razionale r che in un determinato momento vale 48 .
Consideriamo anche il caso apparentemente più innocuo, siano i metodi m1
e m2 che prendono in input il razionale r e promettono di non modificarne il
valore (ce ne sono diversi di questo tipo nella PPL). t1 invoca m1 passandogli
r, e contemporaneamente t2 invoca m2 passandogli r. Come è stato detto,
m1 e m2 non modificano r, ma potrebbero avere delle preferenze diverse sulla rappresentazione interna di r, assumiamo che t1 decida all’interno di m1
di trasformare la rappresentazione 84 in 12 , semplificandolo. Esso comincia a
modificare il numeratore e gli assegna 1, ma in questo momento t2 (tramite
m2 ) decide di leggere r e ottiene 18 anziché 48 .
Per ovviare a questo problema, nei metodi di una libreria dovrebbero
essere inserite delle operazioni atomiche o la gestione delle risorse tramite
semafori. La PPL non può permettersi il "lusso" di implementare tali meccanismi: questo implicherebbe un sostanziale peggioramento dell’efficienza,
che, stando ai vincoli imposti dagli sviluppatori, è una caratteristica cruciale
di questa libreria. La scelta è quella di lasciare che la PPL rimanga a livello
3.2 Thread Local Storage
22
conditional-thread-safe, dove la condizione per gli utilizzatori è quella di non
avere più thread diversi che operano sullo stesso oggetto.
D’ora in poi verrà detto per semplicità che la PPL vorrà essere thread-safe,
ma di fatto questo significherà conditional-thread-safe.
3.2
Thread Local Storage
Qui verrà affrontato in maniera più dettagliata il metodo di programmazione Thread Local Storage (TLS). Esso usa aree di memoria statica o globale
localmente per un thread. Si ricorre a ciò perché diversi thread possono riferirsi alla stessa area di memoria e non sempre lo si vuole. In altre parole,
le variabile global e static vengono allocate normalmente nella stessa area
di memoria, quando ci si riferisce a loro da diversi thread. Le variabili nello
stack delle chiamate sono comunque locali ai thread, perché ognuno ha uno
stack proprio.
3.2.1
Alternative allo standard C++: TLS in GCC
Il metodo di programmazione TLS era supportato dal compilatore GCC ancora
prima dello standard C++11, con una propria implementazione. Consultando
la documentazione [4], si evince che essa richiede un supporto significativo
da parte del linker ld, linker dinamico ld.so e le librerie di sistema libc.so
e libpthread.so, che non sono disponibili ovunque. Il qualificatore di una
variabile thread-local è __thread e può essere usato singolarmente o con le
parole chiave extern e static, che vanno messe prima di __thread.
1
2
3
4
5
6
7
8
// Variabile thread - local
__thread int i ;
// Variabile thread - local definita altrove
extern __thread struct state s ;
// Variabile thread - local statica
static __thread char * p ;
Inoltre, se è presente lo specificatore __thread non sono ammesse altre
parole chiave oltre alle due già citate. Esso può essere applicato a:
• variabili globali;
• variabili static nello scope di un file;
3.2 Thread Local Storage
23
• variabili static a scope di funzione;
• dati membro static di una classe.
Il qualificatore __thread non può essere usato su variabili non statiche
nello scope di un blocco o su dati membro non static di una classe.
Quando l’operatore address-of (&) viene applicato ad una variabile threadlocal, l’indirizzo viene valutato a tempo di esecuzione e ritorna l’indirizzo
che ha tale variabile nell’istanza del thread corrente. L’indirizzo ottenuto
può essere usato poi anche da altri thread, e una volta che il thread originario "muore", qualsiasi puntatore a sue variabili thread-local diventa non
valido. Nessuna inizializzazione statica può riferirsi all’indirizzo di una variabile __thread. In C++, se c’è un inizializzatore per una variabile threadlocal, deve essere una constant expression così come definita nello standard
ANSI/ISO C++ 5.19.2. Come si può vedere appena sotto, quindi, una variabile thread-local deve essere inizializzata con una espressione nota a tempo
di compilazione e non a run time.
1
2
3
4
5
6
7
// Errato
int a = 1;
static __thread int b = a ;
// Corretto
const int c = 2;
static __thread int d = c ;
3.2.2
TLS in C++11
In C++11, si qualifica una variabile come thread-local tramite la parola chiave thread_local. Seguendo lo standard [8, Storage Class Specifiers, Sezione 7.1.1] tale specificatore fa parte della categoria degli storage-class-specifier
che comprendono:
• register;
• static;
• thread_local;
• extern;
• mutable.
3.2 Thread Local Storage
24
Al massimo uno di questi può comparire nella dichiarazione di una variabile, eccetto thread_local che può essere accompagnato dagli specificatori
extern e static. Se lo specificatore thread_local compare nella dichiarazione di una variabile, lo stesso deve essere presente in ogni sua altra
dichiarazione. Possono essere dichiarate thread-local:
• le variabili nello scope di un namespace;
• i dati membro static di una classe;
• le variabili locali.
Infatti, secondo lo standard [8, Class Members, Sezione 9.2], il membro di
una classe non può essere dichiarato thread_local se non è static. Se un
membro static è dichiarato thread_local c’è esattamente una copia di
tale membro per ogni thread, altrimenti, c’è una copia condivisa da tutti gli
oggetti di tale classe [8, Static Data Members, Sezione 9.4.2].
1
2
3
4
5
6
7
8
9
10
11
12
// Variabile thread - local a scope di namespace
thread_local int x ;
class tls {
// Membro static e thread - local di una classe
static thread_local std :: string s ;
};
void foo () {
// Variabile thread - local locale
thread_local std :: vector < int > v ;
}
La storage duration, ovvero la durata di "conservazione", è la proprietà
di un oggetto che definisce il tempo di vita di un contenitore di informazione.
thread_local indica che l’oggetto ha la durata di vita del thread, ed è detta
thread storage duration [8, Thread Storage Duration, Sezione 3.7.2].
Inizializzazione e Distruzione
L’inizializzazione di una generica variabile con la proprietà thread-storageduration deve avvenire prima del suo primo utilizzo, e, se costruita, deve
essere distrutta prima della terminazione del thread. Se il thread t2 vuole
inizializzare una variabile mentre lo sta già facendo t1 , t2 dovrà aspettare
finché l’inizializzazione non avverrà per mano di t1 . La distruzione di una
3.3 Ricerca di codice thread-unsafe
25
variabile thread-local locale avverrà solo se è stata in precedenza inizializzata
[8, Declaration Statement, Sezione 6.7].
L’inizializzazione di una variabile locale (block scoped ) con la proprietà
thread-storage-duration, avviene la prima volta che un flusso di controllo ci
passa all’interno. Se l’inizializzazione lancia un’eccezione, essa è incompleta, e
l’inizializzazione verrà ritentata al prossimo flusso di controllo che incontrerà
tale dichiarazione.
L’inizializzazione di variabili thread_local a visibilità di namespace e
membri di classi è implementation defined. Esse sono costruite prima del loro
utilizzo proveniente dalla stessa unità di traduzione, ma senza specificare
quanto prima. Alcune implementazioni possono inizializzarle appena prima
dell’utilizzo; appena il thread è stato creato, oppure in istanti ancora diversi.
Se nessuna delle variabili thread-local nella stessa unità di traduzione viene
usata, non c’è nessuna garanzia che esse siano mai state costruite.
Per il resto, le variabili thread_local condividono le altre proprietà con
quelle static: sono zero-inizializzate prima ancora che avvenga ogni altro
tipo di inizializzazione (come quella dinamica). I distruttori delle variabili
thread-local vengono invocati in ordine inverso alla loro costruzione, se un
thread è ancora in esecuzione quando il thread padre è già uscito, i distruttori
delle sue variabili non vengono invocati. Bisogna tenere conto che l’ordine di
inizializzazione non è specificato, ed evitare interdipendenze fra distruttori.
Come è stato già detto, le variabili thread-local hanno diversi indirizzi
per ogni thread. È possibile ottenere il puntatore di una di loro e accedere a
tale locazione a partire da diversi flussi di esecuzione, ma il relativo accesso
è undefined behaviour se il thread d’origine è già terminato, e la sue relative
variabili thread-local sono state già distrutte.
3.3
Ricerca di codice thread-unsafe
Per cercare entità thread-unsafe è stata analizzata la directory delle sorgenti della PPL e la directory che contiene le interfacce per i diversi linguaggi
previsti:
1. src/;
2. interfaces/<lang>/.
Dove lang è uno dei linguaggi tra C, Java, OCaml e Prolog. In questo lavoro
di tirocinio è stata modificata soltanto l’interfaccia C.
La directory src contiene prevalentemente classi, suddivise per file come
segue:
3.3 Ricerca di codice thread-unsafe
26
• file di implementazione;
• *_defs.hh file header contenente la definizione della classe;
• *_inlines.hh file header contenente le sue funzioni di tipo inline;
• *_types.hh file header contenente contenente le dichiarazioni "pure"
della classe (le cosiddette forward declaration).
Ci sono alcuni file e altre classi che non rispettano questo schema.
Essendo il codice sorgente da analizzare molto ampio, sia in termini di
numero di file sorgenti che, in alcuni casi, di dimensioni dei file stessi, non è
proponibile pensare ad una ricerca manuale delle porzioni di codice threadunsafe; inoltre, l’approccio sarebbe troppo sensibile ad errori e sviste.
3.3.1
Ricerca testuale
Per prima cosa ci si è concentrati sulle variabili di tipo extern. Se una variabile è dichiarata con il qualificatore extern questo implica che è soggetta ad
allocazione statica, è può essere stata dichiarata e/o definita altrove. È stato
usato lo strumento grep 2.4.2 per la ricerca di tale parola chiave. Sia quindi
il seguente insieme di righe generate dal comando
grep -w -n "extern" <dir>/ppl/src/*.
Si usa -w per estrarre solo le righe in cui extern forma una parola intera e
-n per mostrare il numero della riga nel file, in modo da facilitare la ricerca.
In C++, non è detto che la parola chiave extern indichi per forza che si
tratti di una variabile. Infatti, oltre a un tipo di durata dello storage extern
viene usata per linkare moduli scritti in linguaggi diversi e viene usata anche
insieme ai template, per evitare l’istanziazione implicita (a partire da C++11).
Per ogni riga quindi, c’è la necessità di verificare manualmente di cosa si
tratta: se non è una variabile, la riga viene eliminata dall’insieme. Sia ora
var una variabile appartenente a tale insieme, per ogni var è stato verificato
se essa è stata definita o dichiarata altrove ripetendo il comando precedente
grep -w -n "<var>" <dir>/ppl/src/*.
Il risultato di questa serie di ricerche su ogni var viene unito con quello del
comando precedente, eliminando i duplicati, in questo modo si è riusciti a
risalire ad una parte delle variabili globali della PPL e loro dichiarazioni.
Le variabili static si possono individuare nella stessa maniera, tramite
il comando
3.3 Ricerca di codice thread-unsafe
27
grep -w -n "static" <dir>/ppl/src/*.
Oltre che variabili, di tipo static possono essere anche funzioni membro di
una classe. Anche in questo caso si è andati a verificare manualmente e, se si
trattava di una funzione, essa veniva eliminata dalla lista ottenuta lasciando
solo le variabili.
3.3.2
Ricerca sulla tabella dei simboli
Il comando nm (visto nella Sezione 2.4.1) è stato usato sulle varie librerie prodotte da linker nella directory di compilazione build (Sezione 2.3).
Spostandosi nella seguente directory
cd build/src/.libs/
è possibile trovare le librerie statiche con estensione .a e quelle dinamiche,
con estensione .so. Sono state analizzate le librerie statiche con la seguente
sequenza di comandi:
nm -A -C *.a | egrep ’ [A-Z] ’ | egrep -v ’ [UTWRV] ’,
dove -A fa visualizzare il nome del file da dove viene un dato simbolo, e grazie
a -C è possibile leggere facilmente i namespace ed il nome della variabile.
Tramite egrep ’ [A-Z] ’ si filtrano i simboli globali. Tramite egrep -v ’
[UTWRV] ’ vengono rimossi i simboli indefiniti; i simboli nell’area del codice;
i così detti weak objects; i simboli che rappresentano variabili read only ed i
così detti weak symbol. Nella ricerca era possibile includere anche i simboli
marcati tramite la u minuscola che aiutano a risalire alle variabili locali di
tipo static, ma ciò è superfluo dato che per esse è stata usata la ricerca
testuale.
Lo svantaggio di questo metodo è che il procedimento va ripetuto per
ogni tipo di configurazione di una directory di compilazione. Per esempio,
in modalità -enable-assertions, a causa di eventuali direttive condizionali
del preprocessore, potrebbe essere dichiarato un numero diverso di variabili
globali. Un altro punto a sfavore è che è necessario studiare il comportamento
di nm in base al compilatore usato. Ad ogni modo, esistono altri modi più
eleganti per cercare in maniera sistematica le variabili globali: si può per
esempio scrivere un visitor per il parser clang, approccio che comunque non
è stato preferito per via della sua complessità.
3.4 Modifiche TLS
3.4
28
Modifiche TLS
Una volta individuato l’insieme delle variabili thread-unsafe esse vanno convertite in variabili thread-local. A prescindere dalle varie implementazioni
esistenti, il costo dell’utilizzo di variabili thread-local è di norma relativamente basso, tanto da non far percepire all’utente alcuna differenza. Tuttavia, se
alle variabili thread-local si accede troppo frequentemente il costo aumenta a
sua volta. C’è anche l’eventualità che l’utente non abbia intenzione di usare
la PPL in ambiente multithread, quindi il costo del meccanismo TLS è del
tutto superfluo in ogni caso. È stato creato il file header thread_safe.hh in
cui viene definita la macro PPL_TLS:
1
2
3
4
5
# ifdef PPL_THREAD_SAFE
# define PPL_TLS thread_local
# else
# define PPL_TLS
# endif
Alle variabili trovate nella sezione precedente è stato aggiunto come qualificatore la macro PPL_TLS. Per errori di mancanza di dichiarazione di PPL_TLS,
l’header thread_safe.hh è stato incluso nei seguenti file:
1. global_defs.hh
2. Init_defs.hh
3. Temp_defs.hh
4. Threshold_Watcher_defs.hh
5. Variable_defs.hh
Lo standard C++11 afferma che se lo storage class specifier thread_local
compare in una qualsiasi dichiarazione di una variabile, lo stesso specificatore
deve essere inserito in tutte le sue dichiarazioni [8, Storage Class Specifiers,
Sezione 7.1.1/2]. Quando ciò non accadeva, tramite gli errori stessi del compilatore GCC è stato possibile risalire a un numero maggiore di dichiarazioni
di variabili.
3.4.1
Dichiarazioni PPL_TLS
Verrano ora proposti degli esempi di variabili che hanno ottenuto il qualificatore PPL_TLS, nella directory /src. Gli esempi verranno raggruppati in
base al tipo di variabile. Si cercherà anche di quantificare le variabili per ogni
3.4 Modifiche TLS
29
categoria; per le variabili di tipo static i valori possono non essere esatti
datto che la categorizzazione è stata fatta manualmente.
Variabili extern
Lo specificatore PPL_TLS è stato inserito per 12 variabili di tipo extern. Si
può vedere un esempio di variabili PPL_TLS di tipo extern nel file header
Coefficient_inlines.hh:
extern PPL_TLS const Coefficient* Coefficient_zero_p;
extern PPL_TLS const Coefficient* Coefficient_one_p;
Variabili static locali
Due delle circa 10 variabili locali static che hanno ottenuto il qualificatore
PPL_TLS sono le seguenti:
PPL_TLS
N(-2,
N(-1,
N( 0,
N( 1,
N( 2,
};
PPL_TLS
static N stop_points[] = {
ROUND_UP),
ROUND_UP),
ROUND_UP),
ROUND_UP),
ROUND_UP)
static Coefficient zero(0);
stop_points si trova nel file Octagonal_Shape_inlines.hh mentre la seconda la si trova nel file Coefficient_inlines.hh.
Dati membro static
Hanno ottenuto lo specificatore PPL_TLS circa 32 dati membro di tipo static.
Un esempio si può tovare nel file header Congruence_defs.hh:
static const Congruence* zero_dim_false_p;
static const Congruence* zero_dim_integrality_p;
Variabili static con visibilità di file
Esistono circa 30 variabili static con visibilità di file che hanno ottenuto lo
specificatore PPL_TLS:
3.5 Inizializzazione
30
PPL_TLS static Parma_Polyhedra_Library::Thread_Init
Parma_Polyhedra_Thread_initializer;
PPL_TLS static Parma_Polyhedra_Library::Thread_Init*
Parma_Polyhedra_Thread_initializer_p = 0;
Come già specificato in precedenza, lo standard C++11 esige che se la
parola chiave thread_local (che nel nostro caso equivale a PPL_TLS) come
pare in una dichiarazione di una variabile essa deve comparire in tutte le
altre dichiarazioni e definizioni. Anche a causa di questa direttiva, alla fine,
lo specificatore PPL_TLS è stato inserito come qualificatore per 135 variabili.
Una volta concluso l’inserimento dello specificatore PPL_TLS, ci sono stati problemi nei programmi di prova (che verranno approfonditi nella Sezione
4.1): la libreria non veniva più inizializzata automaticamente. Per questo,
all’interno del programma, è comparsa la necessità di inizializzare esplicitamente la PPL prima di ogni utilizzo della stessa.
3.5
Inizializzazione
Nella PPL, la classe Init è una classe di inizializzazione: essa contiene funzioni
di inizializzazione di tutte le altre classi necessarie, e quindi anche variabili.
Tutte quante le funzioni vengono eseguite prima di usare la libreria. Dopo
l’inserimento del meccanismo TLS descritto nella sezione precedente, ci si
aspetta avere di più thread di esecuzione e, per ognuno di questi, va eseguita
l’inizializzazione in quanto hanno tutti una propria versione degli stati delle
variabili. Tuttavia, le funzioni di inizializzazione da invocare, alcune delle
quali inizializzano variabili globali, possono essere ulteriormente classificate
nel seguente modo:
• Inizializzazione a livello di libreria, cioè funzioni che inizializzano
variabili inizializzabili una sola volta prima di usare la PPL, dove il
numero dei thread possibili e il loro ordine di esecuzione non influiscono
sul loro stato;
• Inizializzazione a livello di thread, cioè funzioni che inizializzano
variabili inizializzabili ogni volta che diventa operativo un nuovo thread,
in quanto quelle variabili hanno un’istanza diversa in base al thread.
Se tutte le funzioni di inizializzazione fossero a livello di thread, la classe
Init non avrebbe dovuto subire modifiche, ma non è così. Infatti, come
afferma la documentazione GMP [12], la funzione
ppl_set_GMP_memory_allocation_functions();
3.6 Funzione wrapper
31
fa uso di variabili globali per memorizzare le funzioni di allocazione di memoria impostate. Non essendo una libreria su cui si possa mettere mano ai
fini della tesi, la funzione mp_set_memory_functions non è thread-safe. Non
potrebbe far parte dell’inizializzazione a livello di thread. Infine, è stato pensato che anche modifiche future della PPL possano introdurre variabili globali
a livello di libreria, e che quindi una differenziazione torni ancora utile.
La classe Init verrà suddivisa nelle classi Library_Init e Thread_Init.
È stato fatto in modo che nella PPL i restanti oggetti globali siano di proprietà
esclusiva di un thread; di conseguenza tutti loro verranno inizializzati a livello
di thread, quindi nella classe Thread_Init. Va ricordato ancora che, l’utente
non deve condividere oggetti PPL fra thread diversi.
3.6
Funzione wrapper
A questo punto in ogni programma è necessario, prima di ogni altra operazione con la PPL, inizializzarla a livello di libreria:
PPL::Library_Init library_init;
In C++11, per iniziare l’esecuzione di un thread è necessario passare al suo
costruttore un diverso thread già creato, oppure un oggetto chiamabile (funzione, oggetto funzione...) da eseguire nel nuovo thread, seguito dagli argomenti che servono per invocare l’oggetto chiamabile. All’inizio di ognuno di
questi oggetti chiamabili l’utente deve inizializzare la PPL a livello di thread:
PPL::Thread_Init thread_init;
Si supponga che per qualche ragione l’utente non voglia o non possa modificare un oggetto chiamabile già esistente, in cui si usa la PPL. Una soluzione è
quella di implementare una funzione wrapper, ovvero una funzione che riveste
l’oggetto chiamabile originario, e prima di eseguirlo inizializza la PPL a livello
di thread.
1 template < typename Fun >
2 class Threadable_Fun {
3
private :
4
Fun fun ;
5
6
public :
7
explicit Threadable_Fun ( Fun f ) : fun ( f ) {}
8
9
template < typename ... Args >
3.6 Funzione wrapper
10
11
32
auto operator () ( Args &&... args ) const
-> decltype ( fun ( std :: forward < Args >( args ) ...) )
{
Thread_Init thread_init ;
12
13
14
return fun ( std :: forward < Args >( args ) ...) ;
15
}
16 };
17
18 template < typename Fun >
19 Threadable_Fun < Fun > make_PPL_Threadable ( Fun fun )
{
20
return Threadable_Fun < Fun >( fun ) ;
21 };
Tale implementazione permette di creare un nuovo thread che prende
come oggetto chiamabile la funzione wrapper che prende come parametro
l’oggetto chiamabile, seguita dagli argomenti necessari all’oggetto chiamabile:
std::thread t(PPL::make_PPL_Threadable(oggetto_chiamabile),
arg_1,
...,
arg_n);
Nel caso in cui l’utente facesse uso di thread-pool (verrà visto nella Sezione
4.1.1), il seguente è un altro esempio di utilizzo della funzione wrapper:
std::function<void()> func = std::bind(oggetto_chiamabile,
arg1_,
...,
arg_n);
thread_pool.submit(PPL::make_PPL_Threadable(func));
Dove si suppone che il thread-pool sia una classe con il metodo submit che
aggiunge un altro compito da svolgere. In questo caso, tramite std::bind si
crea un oggetto funzione che viene passato alla funzione wrapper.
La funzione templatica make_PPL_Threadable prende come parametro
formale un oggetto fun di tipo Fun, e crea un oggetto della classe templatica
Threadable_Fun. Il costruttore della classe Threadable_Fun prende in input fun. L’overloading dell’operatore () della classe Threadable_Fun ha lo
stesso tipo di ritorno di fun, interrogandolo nella dichiarazione (decltype).
L’operatore () prende in input gli argomenti necessari a fun. A questo punto
3.7 Interfaccia C
33
viene inizializzata la libreria a livello di thread e, dopo l’esecuzione di fun,
viene ritornato il suo stesso tipo di ritorno.
Si ricorda che alla fine di un blocco vengono invocati i distruttori di
tutti gli oggetti create in tale blocco. Non è quindi necessario finalizzare
esplicitamente la PPL, sia a livello di thread che di libreria. L’operazione
di finalizzazione viene eseguita dai distruttori invocati su library_init e
thread_init.
3.7
Interfaccia C
Tra tutte le interfacce presenti nella PPL, è stato affrontata la thread-safety
dell’interfaccia C. Per farlo si è subito pensato di usare lo standard C11, ma il
supporto per il multithreading secondo questo standard è ancora incompleto
e quindi si è deciso di usare le estensioni di GCC. Nel file ppl_c_header è
stata definita la macro PPL_C_TLS nel seguente modo:
1
2
3
4
5
# ifdef PPL_THREAD_SAFE
# define PPL_C_TLS __thread
# else
# define PPL_C_TLS
# endif
Come nel caso della macro PPL_TLS, PPL_C_TLS verrà usata come qualificatore delle variabili che necessitano di essere TLS, se richiesto. Analogamente a
quanto fatto con il codice sorgente della PPL, sono stati combinati i risultati
delle seguenti ricerche testuali:
grep -w -n "extern" /ppl/interfaces/C/*
grep -w -n "static" /ppl/interfaces/C/*
Dopo di che è stata fatta un ricerca per informazioni della tabella dei simboli
nei seguenti file:
nm -A -C *.a | egrep ’ [A-Z] ’ | egrep -v ’ [UTWRV] ’
Nell’interfaccia C della PPL ci sono relativamente pochi file sorgenti, quindi è stata possibile anche una breve ricerca visiva. Le variabili allocabili
staticamente che necessitano di essere TLS sono le seguenti:
error_handler_type user_error_handler = 0;
static char buffer[20];
ppl_io_variable_output_function_type* c_variable_output_function;
Variable::output_function_type* saved_cxx_Variable_output_function;
3.7 Interfaccia C
34
Parma_Polyhedra_Library::Watchdog* p_timeout_object = 0;
Weightwatch* p_deterministic_timeout_object = 0;
static timeout_exception e;
static timeout_exception e;
extern error_handler_type user_error_handler;
Le variabili elencate si trovano tutte nel file ppl_c_implementation_common.cc
ad eccezione dell’ultima, che è una dichiarazione e nel file
ppl_c_implementation_common_defs.cc.
3.7.1
Inizializzazione
L’header initializer.hh che si trova nella directory delle sorgenti della PPL,
fornisce le seguenti funzioni:
inline
inline
inline
inline
void
void
void
void
library_initialize();
library_finalize();
thread_initialize();
thread_finalize();
Le funzioni library_initalize() e thread_initalize() allocano rispettivamente gli oggetti Library_Init e Thread_Init. Le funzioni library_finalize()
e
thread_finalize() invece, li deallocano. Tali funzioni sono chiamabili da
interfacce dei vari linguaggi per inizializzare e finalizzare la PPL. Per quanto
riguarda l’interfaccia C, per inizializzare e finalizzare la PPL c’erano i metodi
int ppl_initialize(void);
int ppl_finalize(void);
Queste sono state sostituite con le seguenti:
1. int ppl_library_initialize(void) inizializza a livello di libreria,
chiamando il metodo library_initalize() e inizializza le variabili
allocabili staticamente che non necessitano di essere TLS, nell’interfaccia
C;
2. int ppl_thread_initialize(void) inizializza a livello di thread, chiamando il metodo thread_initalize() e inizializza un sottoinsieme
delle variabili allocabili staticamente che necessitano di essere TLS,
nell’interfaccia C;
3. ppl_library_finalize(void) finalizza a livello di libreria, chiamando
il metodo library_finalize() e impostando la funzione di output di
default;
3.7 Interfaccia C
35
4. ppl_thread_finalize(void) finalizza a livello di thread, chiamando
il metodo thread_finalize() e impostando la funzione di output di
default.
Capitolo 4
Testing
In questo capitolo si descriveranno le modifiche effettuate ad alcuni programmi demo che utilizzano la PPL, inizialmente sviluppati in versione singlethread, al fine di testare la correttezza e l’efficienza della versione thread-safe
della PPL.
4.1
ppl_lcdd
ppl_lcdd è un programma demo allegato alla libreria PPL ed è basato su
quest’ultima. Ci sono più modi per rappresentare un poliedro, tramite una
H-rappresentazione o una V-rappresentazione. ppl_lcdd prende in input un
poliedro in V-rappresentazione e produce una H-rappresentazione (o viceversa) dello stesso poliedro. All’interno della PPL, ppl_lcdd fa uso di alcune
cache globali alle quali si accede senza alcun controllo di sincronizzazione.
Come discusso nei capitoli precedenti, nel caso di thread multipli questo causerebbe dei data race e comportamenti indefiniti, per cui tali cache sono state
dichiarate thread_local. Per questo motivo, il programma ppl_lcdd costituisce un test significativo per la versione thread-safe della libreria. Il programma è stato modificato per renderlo in grado di operare su più poliedri
contemporaneamente producendo risultati corretti per ognuno dei poliedri in
input. Inizialmente ppl_lcdd supportava le seguenti opzioni:
• -CSECS, –max-cpu=SECS, limita il tempo di utilizzo della CPU;
• -RMB, –max-memory=MB, limita l’utilizzo di della memoria a MB megabyte;
• -h, –help, stampa il testo dell’help su standard output;
• -oPATH, –output=PATH, appendi l’output a PATH;
4.1 ppl_lcdd
37
• -t, –timings, stampa i tempi su stderr;
• -v, –verbose, produce più output durante l’esecuzione;
• -V, –version, stampa su standard output la versione del programma;
• -cPATH, –check=PATH, controlla se il poliedro ottenuto è uguale a
quello che c’è in PATH.
Si vedrà come ognuna di queste opzioni finirà nella versione modificata
4.1.1
Modifiche a ppl_lcdd
Verranno elencate le più rilevanti modifiche apportate a ppl_lcdd. Per la
programmazione parallela è stata usata la classe std::thread.
Come prima cosa, a inizio del main è stata introdotta l’inizializzazione a
livello di libreria, cioè creando un oggetto della classe Library_Init.
Il corpo del main si occupa della conversione tra i due tipi di rappresentazione del poliedro. Siccome ora i poliedri in input saranno più di uno, il
codice che si occupa della conversione è stato spostato nel corpo di una nuova
funzione:
void convert(char* input_file_name);
Ogni thread potrà far partire l’esecuzione della funzione convert, ognuno con
un nome del file di input diverso. All’inizio della funzione convert è possibile inserire l’inizializzazione a livello di thread, ma avendo a disposizione la
funzione wrapper (Sezione 3.6) ciò non è necessario.
ppl_lcdd possiede la funzione
void process_options(int argc, char* argv[]);
che viene chiamata dal main per processare le opzioni ed i file in ingresso.
Sono state fatte delle modifiche a process_options in modo che, oltre a cambiare il comportamento di ppl_lcdd in base alle opzioni, sia capace di rilevare
nomi di file multipli in ingresso. Questi file rappresentano i poliedri rappresentati in una certa forma. Per ogni poliedro in ingresso process_options
lancia un nuovo thread. Il thread cambia la rappresentazione del poliedro e
lo salva in una directory specificata tramite l’opzione -o.
A questo punto dello sviluppo è comparso un problema: cosa succede se il
numero dei poliedri in ingresso supera il numero dei thread supportati dalla
macchina? Tale numero, ovviamente, è limitato.
4.1 ppl_lcdd
38
Thread Pool
Il thread-pool indica un gestore software di thread, viene utilizzato per semplificare ed ottimizzare l’utilizzo dei thread all’interno di un programma.
C’è una moltitudine di tipi di thread-pool e più numerose sono le possibili
implementazioni.
Nel caso di ppl_lcdd è bastato un thread-pool relativamente semplice.
Non è necessario badare a un valore di ritorno dei thread dato che, una volta
iniziata la conversione della rappresentazione del poliedro, per tutta la sua
durata e alla fine, il master -thread non deve più interagire con il worker thread. Una ulteriore semplificazione è data dal fatto che tutti i thread sono
indipendenti fra loro. Nel frame 4.1.1 viene rappresentata l’interfaccia del
thread-pool inserito nella directory del programma demo ppl_lcdd.
1 class Thread_Pool
2 {
3
private :
4
std :: atomic_bool done ;
5
Threadsafe_Queue < std :: function < void () > >
6
work_queue ;
7
std :: vector < std :: thread > threads ;
8
Join_Threads joiner ;
9
10
void worker_thread () ;
11
12
public :
13
Thread_Pool () ;
14
15
template < typename FunctionType >
16
void submit ( FunctionType f ) ;
17
void free () ;
18
19
~ Thread_Pool () ;
20 };
Nel seguito si fornirà una breve spiegazione del funzionamento di questo
thread-pool, senza scendere troppo nei dettagli implementativi. Al riguardo,
il lettore interessato può consultare il libro [13], dal quale si è preso spunto per l’implementazione del thread-pool. Una volta creato l’oggetto di tipo
Thread_Pool, il suo costruttore (riga 13) interroga la macchina hardware su
quanti
thread
riesca
a
supportare,
tramite
la
funzione
std::thread::hardware_concurrency(). A volte hardware_concurrency()
4.1 ppl_lcdd
39
può ritornare il valore 0: se questo accade il thread-pool assume che la macchina sia in grado di supportare 4 thread. Questi thread appena creati vengono
salvati nel vettore threads (riga 7). A tutti i thread viene fatta eseguire la
stessa funzione membro worker_thread (riga 10). worker_thread non fa altro che verificare se sulla work_queue (riga 6) ci siano oggetti chiamabili da
eseguire, che sono di fatto i task che l’utente impone di eseguire al threadpool, tramite la funzione membro templatica void submit(FunctionType
f). submit infatti aggiunge generici oggetti chiamabili alla work_queue. In
altre parole, l’utente assegna al thread-pool dei lavori in coda da far fare
ai thread, che sono tanti quanti la macchina riesce a supportare senza creare overhead. Quando un thread finisce un task cerca di prelevarne un’altra
dalla coda. Quando l’utente è sicuro che tutte i task le abbia assegnate al
thread-pool, può invocare il metodo void free() per comunicarlo, e tale
informazione verrà salvata in std::atomic_bool done.
Se il blocco in cui è stato creato l’oggetto della classe Thread_Pool giunge
alla sua fine, viene invocato il suo distruttore che potrebbe mettere fine al
lavoro dei thread anche se questi non hanno ancora finito la conversione della
rappresentazione dei poliedri. Ma ciò non accade perché Thread_Pool()
tenterà di distruggere i membri dati in ordine inverso alla loro dichiarazione,
ed il primo è l’oggetto joiner (riga 8) della classe Join_Threads 4.1.1.
1 class Join_Threads
2 {
3
private :
4
std :: vector < std :: thread >& threads ;
5
6
public :
7
explicit
8
Join_Threads ( std :: vector < std :: thread >&
9
threads_ ) ;
10
11
~ Join_Threads () ;
12 };
L’oggetto joiner viene costruito prendendo in input l’insieme dei thread
creati dal thread-pool. Il distruttore invocato su joiner prima di uscire invoca la funzione std::thread::join su ogni thread all’interno del thread-pool.
std::thread::join non fa altro che fermare il flusso di esecuzione finché il
thread in questione non ha finito. In questo modo il distruttore invocato
sull’oggetto joiner farà aspettare anche il distruttore invocato sull’oggetto
della classe Thread_Pool, impedendo la sua distruzione prima che il lavoro
in corso venga finito.
4.1 ppl_lcdd
40
L’oggetto work_queue è della classe implementata Threadsafe_Queue,
che si comporta come una generica struttura dati di tipo queue, con le stesse
operazioni, ma è anche thread-safe.
Opzioni ppl_lcdd_mt
La versione multi-thread di ppl_lcdd verrà d’ora in poi chiamata ppl_lcdd_mt.
Le opzioni eventualmente modificate che sono supportate da ppl_lcdd_mt
sono le seguenti:
• -CSECS, –max-cpu=SECS, che limita il tempo di utilizzo della CPU del
processo, causando la morte di tutti i threads;
• -RMB, –max-memory=MB, che limita l’utilizzo di della memoria a MB
megabyte, a livello di processo;
• -h, –help, stampa i tempi su stderr;
• -oPATH, –output=PATH, tutti gli output vengono salvati come file nella
directory PATH, i nuovi file avranno lo stessi nome (assieme alla vecchia
estensione) dei file in input, ma con l’aggiunta della stringa .out alla
fine;
• -V, –version, stampa su standard output la versione del programma.
In ppl_lcdd_mt è stata lasciata anche l’opzione –verbose, ma non è stata
adeguatamente testata dal momento che ogni thread potrebbe impegnare
lo standard output senza sincronizzazione. È stata tolta del tutto l’opzione
–check=PATH.
4.1.2
Prova pratica
La correttezza di ppl_lcdd_mt è stata testata facendolo partire ogni volta con
un numero arbitrario di poliedri (a partire da 12 fino a 15) su cui operare. Così
è stato fatto per più gruppi di poliedri. Ogni volta che finiva la conversione
di un gruppo di poliedri, i risultati venivano confrontati con i risultati di
ppl_lcdd sugli stessi poliedri. Non sono mai state riscontrate differenze: nei
test effettuati ppl_lcdd_mt e ppl_lcdd si sono comportati nello stesso modo
per ogni singolo poliedro.
4.1 ppl_lcdd
41
ppl_lcdd_mt con la PPL thread-unsafe
Come verifica ulteriore, sono stati anche effettuati alcuni test con il programma ppl_lcdd_mt al quale, però, era stata collegata la versione thread-unsafe
della PPL. Come prevedibile, in questo caso l’esecuzione mostra i classici sintomi dell’undefined behavior, causando errori a tempo di esecuzione e/o la
produzione di output non corretto. Tali errori sono facilmente riscontrabili,
ma avendo natura non deterministica non possono essere riprodotti con esattezza e rientrano quindi nella categoria di errori estremamente difficile da
correggere. Le variabili staticamente allocabili all’interno della PPL non sono
TLS, e quindi ogni thread cerca di scrivere e di leggere sulle stesse variabili,
che hanno un’unica istanza, senza alcuna sincronizzazione. Il contenuto dei
file in output è imprevedibile.
ppl_lcdd e ppl_lcdd_mt su singolo poliedro
Si vogliono confrontare le prestazioni di ppl_lcdd (sulla PPL thread-unsafe)
e ppl_lcdd_mt (sulla PPL thread-safe) su singoli poliedri, con l’intento di
riuscire a vedere eventuali inefficenze introdotte dal meccanismo TLS e dalla
maggiore complessità di ppl_lcdd_mt. Nella tabella appena sotto è possibile
vedere un confronto dei tempi (espresso in secondi) di esecuzione di ogni
singolo file elencato. Ogni valore è una media di più prove eseguite. Si nota che
ppl_lcdd ppl_lcdd_mt
Secondi
cyclic17_8.ine
0.03
0.045
dcube12.ext
0.06
0.08
0.1
0.13
kkd38_6.ext
in7.ine
0.77
1.04
mit31-20.ine
2.46
2.95
sampleh8.ine
8.05
10.51
16.05
18.2
mit41-16.ine
l’esecuzione di ppl_lcdd_mt richiede più tempo man mano che la complessità
dei calcoli aumenta.
ppl_lcdd_mt con più poliedri
Si prenda come esempio il gruppo dei poliedri nella tabella sotto. I tempi di
esecuzione sono di ppl_lcdd abbinato alla PPL thread-unsafe. Far convertire
a ppl_lcdd tutti questi file in sequenza impiegherebbe almeno 43 secondi,
nella condizione ideale in cui tra un comando e l’altro i tempi sono nulli.
ppl_lcdd_mt invece, prendendo in input tutti i file elencati alla volta, richiede
4.2 ppl_lpsol
42
Nome file
ppl_lcdd
ccc6.ine
0.03
dcube12.ext
0.06
kkd38_6.ext
0.1
in7.ine
0.77
2.46
mit31-20
cyclic25_13.ext
15.49
8.05
sampleh8.ine
mit41-16.ine
16.05
20,9 secondi. Il tempo richiesto per convertire il poliedro più impegnativo
(mit41-16.ine) è di 18.2 secondi, ciò significa che ppl_lcdd_mt converte
tutti i file in un tempo che supera di poco il file più impegnativo.
4.2
ppl_lpsol
ppl_lpsol, un altro programma demo basato sulla PPL, è un risolutore di
problemi nella programmazione lineare intera e mista scritto in C, anche allo
scopo di testare la funzionalità dell’interfaccia della PPL verso tale linguaggio. Lo useremo quindi, in modo analogo a quanto fatto precedentemente per
ppl_lcdd, per testare la correttezza della versione thread-safe dell’interfaccia
C. ppl_lpsol legge un file nel formato .mps e cerca di trovare la soluzione
usando degli algoritmi di ottimizzazione forniti dalla PPL. ppl_lpsol usa
strumenti della PPL che fanno uso della classe MIP_solver. Per facilitare il
lavoro, le opzioni di ppl_lpsol sono state classificate in due categorie: le
opzioni che impongono dei vincoli sul problema da risolvere e le opzioni che
condizionano il comportamento del programma, da un punto di vista tecnico.
Alla seconda categoria appartengono le seguenti opzioni:
• -c, –check[=THRESHOLD], verifica il risultato ottenuto usadndo GLPK
(GNU Linear Programming Kit [14]);
• -CSECS, –max-cpu=SECS, limita il tempo di utilizzo della CPU a SECS
secondi;
• -RMB, –max-memory=MB, limita l’utilizzo di della memoria a MB megabyte;
• -h, –help, stampa il testo dell’help su standard output;
• -oPATH, –output=PATH, appendi l’output a PATH;
4.2 ppl_lpsol
43
• -t, –timings, stampa i tempi su stderr;
• -v, –verbosity=LEVEL, produce più output durante l’esecuzione;
• -V, –version, stampa su standard output la versione del programma.
Mentre le opzioni che esprimono vincoli per la risoluzione del problema sono
le seguenti:
• -i, –incremental, risolvere il problema in maniera incrementale;
• -m, –min, minimizza la funzione oggetto;
• -M, –max, massimizza la funzione oggetto;
• -n, –no-optimization, verificare soltanto la soddisfacibilità;
• -r, –no-mip, considerare variabili intere come reali;
• -e, –enumerate, usare il costoso metodo dell’enumerazione;
• -pM, –pricing=M, da usare insieme al metodo del simplesso, sceglie di
usare lo pricing method M;
• -s, –simplex, usare il metodo del simplesso.
Le opzioni di vincolo sulla risoluzione del problema non verranno modificate
in alcun modo.
4.2.1
Modifiche a ppl_lpsol
Per la programmazione parallela è stata usata a libreria POSIX Threads
Programming [3]. Verrano descritte le principali modifiche in maniera sintetica. Non è stato implementato un thread-pool: i thread da eseguire possono
essere al massimo 8. Se ppl_lpsol_mt riceve in input più di 8 file, ne leggerà
i primi 8, gli altri file verranno ignorati.
È stata introdotta l’inizializzazione a livello di libreria fornita dall’interfaccia C
if (ppl_library_initialize() < 0)
fatal("cannot initialize the Parma Polyhedra Library");
La funzione
static void process_options(int argc, char* argv[]);
4.2 ppl_lpsol
44
è stata modificato in modo che sia capace di fare il parsing di più file in input,
che rappresentano i problemi da risolvere. ppl_lpsol fa uso della libreria
glpk che, dopo alcuni tentativi di parallelizzare il programma, ha causato
problemi dimostrando di non essere thread-safe. A causa di questo problema
il procedimento della risoluzione del problema è stato diviso in due funzioni.
Dato un problema in input infatti, c’è una prima fase di preparazione del
problema. In questa prima fase si legge il problema e se ne crea l’ambiente
per tale problema. Questa prima fase è rappresentata dalla funzione
static pthread_t solve(char* file_name);
Alla fine, la funzione solve crea un thread worker al quale passa il costrutto di tipo thread_args_t specificamente preparato per il problema da
affrontare:
1
2
3
4
5
6
7
8
9
10
11
12
13
typedef struct {
ppl_Constraint_System_t ppl_cs ;
ppl_Linear_Expression_t ppl_objective_le ;
ppl_dimension_type * integer_variables ;
ppl_dimension_type num_integer_variables ;
mpz_t den_lcm ;
char * input_file_name ;
FILE * output_file ;
int dimension ;
# ifdef PPL_LPSOL_SUPPORTS_TIMINGS
struct timeval saved_ru_utime ;
# endif
} thread_args_t ;
solve inizializza i campi nella maniera dovuta. Tale costrutto servirà al futuro thread di tipo p_thread per usare i metodi forniti dalla PPL. Il nuovo
thread rappresenta la seconda fase della risoluzione del problema ed esso
eseguirà la funzione
static void* thread_solve(void* vp);
Qui si andrà a operare con gli strumenti forniti dalla PPL. Prima di tutto,
all’interno di thread_solve la PPL va inizializzata a livello di thread:
if (ppl_thread_initialize() < 0)
fatal("cannot thread-initialize the Parma Polyhedra Library");
Nella fase finale di thread_solve vengono finalizzate tutte le componenti del
costrutto di tipo thread_args_t. A differenza di ppl_lcdd_mt, la libreria va
4.2 ppl_lpsol
45
finalizzata esplicitamente dato che non si dispone in C del meccanismo dei
distruttori di un oggetto:
(void) ppl_thread_finalize();
Per riassumere, la funzione solve viene chiamata per ogni problema in input
e ogni chiamata viene risolta in sequenza. Ogni chiamata di solve genera
un nuovo thread che continua con la risoluzione del problema usando però
la PPL. In altre parole la risoluzione di un problema in ppl_lpsol è stata
parallelizzata a metà.
Sia ora il programma ppl_lpsol_mt il programma ppl_lpsol parallelizzato come descritto sopra. Le opzioni che sono state lasciate ed eventualmente
modificate in ppl_lpsol_mt sono le seguenti:
• -CSECS, –max-cpu=SECS, limita il tempo di utilizzo della CPU a SECS
secondi, a livello di processo;
• -RMB, –max-memory=MB, limita l’utilizzo di della memoria a MB megabyte, a livello di processo;
• -h, –help, stampa il testo dell’help su standard output;
• -oPATH, –output=PATH, tutti gli output vengono salvati come file nella
directory PATH, i nuovi file avranno lo stessi nome (assieme alla vecchia
estensione) dei file in input, ma con l’aggiunta della stringa .out alla
fine;
• -V, –version, stampa su standard output la versione del programma.
Come per ppl_lcdd_lpsol, è stata lasciata anche l’opzione –verbose, ma
non è stata adeguatamente testata dal momento che ogni thread potrebbe
impegnare lo standard output senza sincronizzazione. È stata tolta del tutto
l’opzione -c.
4.2.2
Prova pratica
ppl_lpsol_mt con la PPL thread-unsafe
Presi in input più di un problema da risolvere, ppl_lpsol_mt che fa uso della
PPL thread-unsafe è semplicemente undefined behavior. Le variabili staticamente allocabili della PPL non sono TLS. Come nel caso di ppl_lcdd_mt,
l’esito degli output è imprevedibile.
4.2 ppl_lpsol
46
ppl_lpsol e ppl_lpsol_mt su singolo problema
Si vogliono confrontare le prestazioni di ppl_lpsol e ppl_lpsol_mt su singoli problemi, con l’intento di riuscire a vedere eventuali inefficienze introdotte dal meccanismo TLS. ppl_lpsol userà la PPL thread-unsafe, mentre
ppl_lpsol_mt userà la versione thread-safe. Nella tabella appena sotto è
possibile vedere un confronto dei tempi (espresso in secondi) di esecuzione di
ogni singolo file elencato. Ogni valore è una media di più prove eseguite.
markashare2.mps
kb2.mps
mas76.mps
blend.mps
mas74.mps
boeing1.mps
lseu.mps
opt1217.mps
ppl_lpsol ppl_lpsol_mt
0.16
0.16
0.27
0.27
2.48
2.54
3.56
7.49
5.01
5.22
5.85
6.71
21.66
23.02
62.85
67.19
ppl_lpsol_mt con più problemi
Per risolvere più problemi alla volta, ppl_lpsol_mt necessita che questi problemi siano risolvibili tutti quanti con gli stessi vincoli. Si intende che tutti
i problemi devono essere risolvibili con l’unica configurazione di opzioni che
l’utente dà al programma. Il test verrà fatto sul seguente insieme di file:
ppl_lpsol
markashare2.mps
0.16
0.27
kb2.mps
mas76.mps
2.48
blend.mps
3.56
mas74.mps
5.01
boeing1.mps
5.85
lseu.mps
21.66
opt1217.mps
62.85
Il tempo richiesto per eseguirli tutti quanti in sequenza sarebbe di almeno
102 secondi, con ppl_lpsol. Con ppl_lpsol_mt sono il tempo richiesto è di
67.8 secondi, per eseguirli tutti quanti. Si noti anche che ppl_lpsol_mt, per
eseguire tutti i file, ha richiesto tanti secondi quanti ne richiede il problema
opt1217.mps, che è il più complesso da risolvere.
Conclusioni
In questo lavoro di tesi si è partiti da una panoramica generale sugli approcci
da assumere per scrivere una generica libreria thread-safe. In particolare ci si
è concentrati sulle tecniche per rendere thread-safe una libreria esistente (originariamente sviluppata per thread singoli e quindi thread-unsafe). In seguito
è stato selezionato l’approccio che meglio si adatta al caso della PPL. Sono
state fornite le caratteristiche dell’ambiente di sviluppo e si sono discusse le
linee guida da usare nella scelta fra le varie alternative.
Sono stati discussi i vari livelli della thread-safety di una libreria, arrivando alla conclusione che l’opzione migliore per la PPL è il livello conditionallythread-safe.
Sono state approfondite alcune implementazioni del meccanismo Thread
Local Storage. In seguito ad una serie di valutazioni si è arrivati a optare per
la specifica fornita dallo standard C++11, specifica che è stata studiata per
esteso per conoscerne al meglio le caratteristiche. Si è quindi passati alla fase
di implementazione che ha richiesto, fra le altre cose, la ricerca sistematica
delle occorrenze di variabili staticamente allocate.
La fase di inizializzazione della PPL è stata spezzata in due: inizializzazione
a livello di libreria e a livello di thread. Tale cambiamento nel processo di
inizializzazione, non più implicita, comporta un intervento diretto da parte
dell’utente, che deve ricordarsi di inizializzare la PPL ogni volta che crea un
nuovo thread. Per aiutare l’utente è stata introdotta una funzione wrapper
che inizializza automaticamente la PPL a livello di thread. In seguito il lavoro
è stato esteso per rendere thread-safe anche l’interfaccia verso il linguaggio
C della PPL.
Per verificare la correttezza della soluzione adottata sono stati fatti dei
test tramite programmi demo della PPL, modificati a loro volta in modo
che lavorassero in ambiente multithread. Si è verificato che la PPL produce
risultati corretti per ppl_lcdd_mt e ppl_lpsol_mt. Sia con ppl_lcdd_mt
che con ppl_lpsol_mt si è verificato che in un processo a singolo thread la
versione thread-safe della PPL risulta leggermente più lenta. Si è verificato che
in ambienti a thread multipli il vantaggio in termini di velocità di esecuzione
4.2 ppl_lpsol
48
è sostanziale.
Il lavoro svolto ha permesso di garantire la thread-safety di una vasta
porzione della PPL, e costituisce un primo e sostanziale passo per ottenere una
versione della PPL thread-safe. Possibili estensioni future potranno riguardare
questi punti:
• estendere la thread-safety alle interfacce Java, Prolog e OCaml;
• adattare la documentazione della PPL spiegando l’implementazione adottata per la thread-safety, fornendo esempi su cosa non si può fare;
• testare la portabilità della soluzione adottata, utilizzando compilatori
che non siano GCC e Clang;
• trovare una soluzione per rendere thread-safe la classe Watchdog, che
implementa dei meccanismi di timeout per la PPL.
Ringraziamenti
Vorrei ringraziare in particolar modo i miei genitori Tamara e Valentin, i
quali mi hanno dato l’opportunità di intraprendere questo cammino e mi
hanno supportato sia durante i momenti di successo che nei momenti più
difficili. Ringrazio il mio relatore Enea Zaffanella, professore dal quale non
ho mai smesso di imparare; che mi ha offerto la possibilità di arricchire la
mia esperienza con argomenti che mi appassionano più degli altri nel vasto
mondo dell’informatica; e professore che ogni ateneo vorrebbe avere. Un altro
ringraziamento importante per la mia formazione va a Gianfranco Rossi,
Roberto Bagnara, Alessandro Zaccagnini, Grazia Lotti, Federico Bergenti e
Alessandro Dal Palù. Ringrazio i miei compagni e le compagne di studio
che hanno reso questo percorso più vario e divertente: Francesco Trombi,
Daniela Pedroni, Sebastian Davrieux, Jacopo Fagio Freddi, Paolo Grossi,
Alessio Bortolotti, Victoria Nica, Luca Cirani, Eleonora Fontanesi, Federico
Bertoli e tutti coloro che rendevano le pause più interessanti davanti alla
macchinetta del caffè.
Ringrazio la mia costante voglia di migliorare
Bibliografia
[1] BUGSENG s.r.l.
PPL: Parma Polyhedra Library
http://bugseng.com/products/ppl/
[2] Boost Developers
Boost: C++ Libraries
http://www.boost.org/
[3] Austin Group
POSIX Thread Programming
https://computing.llnl.gov/tutorials/pthreads/
[4] GCC Team
GCC: GNU Compiler Collection
https://gcc.gnu.org/
[5] LLVM Team
clang: a C language family frontend for LLVM
http://clang.llvm.org/
[6] Intel Compilers
Intel C++ Compiler
https://software.intel.com/en-us/c-compilers
[7] Steve Lewin-Berlin (Intel)
Hot and Safe: a Beginner’s Guide to Multithreaded Libraries
[8] ISO
C++ International Standard: Final Draft International Standard
ISO/IEC JTC1 SC22 WG21 N 3290, 11 Aprile 2011
BIBLIOGRAFIA
[9] Free Software Foundation
GNU Development Tools, nm
http://linux.die.net/man/1/nm
[10] C++ Enthusiasts
C++ compiler support
http://en.cppreference.com/w/cpp/compiler_support
[11] Junio Hamano
git
http://git-scm.com/doc
[12] Free Software Foundation
The GNU Multiple Precision Arithmetic Library
https://gmplib.org/manual/Reentrancy.html
[13] Anthony Williams
C++ Concurrency In Action: Practical Multithreading
Manning Publications Co., 2012.
[14] Free Software Foundation
The GNU Linear Programming Kit
https://www.gnu.org/software/glpk
51
Scarica

Un caso di studio sulla Thread Safety: la Parma Polyhedra Library