Chapitre 5
PROGRAMMATION PARALLELE
MULTITHREADING
 
 
Précédent Suivant
 

Programmation parallèle
Multithreading
Applications parallèles (1/2)
* Développement des processeurs multicoeurs
* Limitation technique à l'augmentation des fréquences d'horloge
* Nouvelle voie d'amélioration des performances d'une application
* Applications avec interface graphique nécessairement parallèles
* 1 thread ? interface graphique (gestion des événements)
* 1 ou plusieurs threads ? application métier
* Evite le gel de l'interface graphique
* Beaucoup d'applications peuvent bénéficier du parallélisme
* Possibilité d'exécution de tâches en parallèle
* Réduction des temps de latence d'un programme
* Accès à une ressource système (mémoire, fichier...) ==> latence
* Mécanisme de mise en attente d'un thread ==> gain même sur un seul coeur
* Règle empirique: "optimal" pour nombre de threads = 2 x nombre de coeurs
Applications parallèles (2/2)
* La conception d'une application parallèle est difficile
* Choisir la granularité des tâches à paralléliser
* Synchroniser les tâches
* Contrôler l'accès aux ressources partagées
* Avant C++11: bibliothèques/extensions non standards
* POSIX Threads, OpenMP...
* Depuis C++11: API objet standard
* Couche bas niveau (équivalent POSIX): thread, mutex...
* Couche intermédiaire (abstraction): async, future...
* Avec C++17
* Couche haut niveau (algorithmes parallèles): for_each, reduce...
Principe du thread
* Représente un nouveau fil d'exécution dans le programme
* Nouveau contexte d'exécution
* Possède sa propre pile
* Mais partage des données
* Accès (lecture/écriture) au tas du programme
* Attention aux accès concurrents
* A ne pas confondre avec un processus
* Granularité plus fine
* Intra-programme vs. inter-programme
* Plus léger
* Moins de consommation de ressources
* Processeurs adaptés aux threads
Classe "thread" (1/2)
* Entête: #include <thread>
* Threads représentés par la classe "thread"
* Deux états sont possibles
* Actif: représente une exécution parallèle effectivement en cours
* Inactif: symbolise un thread, mais aucune exécution parallèle effective
* Pour savoir si le thread est actif: méthode "joinable"
* Possède un identifiant unique: méthode "get_id"
* Peut être utilisé comme clé dans un conteneur associatif
* Un thread démarre lors de sa construction
* Si on lui fournit un "callable" (i.e. fonction, foncteur ou lambda)
* Ce callable est automatiquement exécuté au lancement du thread
* Des arguments peuvent être fournis
* Exemple: t = std::thread(ma_fonction,param1,param2);
? exécution en parallèle de ma_fonction(param1,param2)
* Sinon le thread est inactif
Classe "thread" (2/2)
* Possibilité de transférer le contrôle d'un thread actif
* Exemple: t1 = t2;
* Opération de mouvement (dépouillement de "t2")
* Si "t2" est actif, alors "t1" devient actif et "t2" inactif
* Intérêt: permet de séparer les phases de déclaration et de lancement
std::thread t;
...
t = std::thread(ma_fonction,param1,param2...);
* L'exécution d'un thread se termine
à la sortie de la fonction associée
* Le thread principal n'attend pas automatiquement
la fin des threads qu'il a lancé
* Attendre chaque thread à l'aide de sa méthode "join"
Lancement d'un thread
* Exemple
void zzz(void) {
std::cout << "zzz..." << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
}
int main(void) {
std::thread t; // Déclaration d'un thread inactif
t = std::thread(zzz); // Exécution parallèle de la fonction
// L'objet temporaire est un thread actif
t.join(); // Attente de la fin du thread
}
Lancement de plusieurs threads
* Exemple
void zzz(unsigned n) {
std::cout << "[" << n << "] zzz..." << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
}
int main(void) {
std::thread t[4];
for (unsigned i = 0; i<4; ++i) t[i] = std::thread(zzz,i);
for (unsigned i = 0; i<4; ++i) t[i].join();
}
Exécution parallèle d'une lambda
* Lambda sans capture
t[i] = std::thread(
[](unsigned n) {
std::cout << "[" << n << "] zzz..." << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
}
,i);
* Lambda avec capture
t[i] = std::thread(
[=]() {
std::cout << "[" << i << "] zzz..." << std::endl;
std::this_thread::sleep_for(std::chrono::seconds(1));
}
);
Accès à une ressource partagée
* Exemple précédent, sortie prévue (ordre non garanti)
[0] zzz...
[1] zzz...
[2] zzz...
[3] zzz...
* Mais sortie possible
[[01] zzz...] zzz...
[2] zzz...
[3] zzz...
* Car problème de partage de ressource
* La sortie standard est partagée par tous les threads
* Synchronisation nécessaire: chacun son tour ==> mutex
Principe du mutex (1/2)
* Mutual exclusion
* Objectif: empêcher l'exécution simultanée
d'une portion de code par plusieurs threads
* Mécanisme de jeton
* Mutex partagé par plusieurs threads
* Acquisition d'un mutex (méthode "lock")
* Si le mutex n'est pas verrouillé, le thread obtient l'accès
* La méthode "lock" se termine
* Si le mutex est verrouillé, le thread se met en pause
* La méthode "lock" continue
* Possibilité d'utiliser la méthode "try_lock" qui n'attend pas
Principe du mutex (2/2)
* Libération d'un mutex (méthode "unlock")
* Doit être déclenchée par le thread qui a verrouillé
* Signal aux threads en attente ==> l'un d'eux acquiert alors le mutex
* Exemple
std::mutex mutex;
void zzz(unsigned n) {
mutex.lock();
std::cout << "[" << n << "] zzz..." << std::endl;
mutex.unlock();
std::this_thread::sleep_for(std::chrono::seconds(1));
}
Eviter l'interblocage de threads (1/2)
* Attention: oubli de libération ==> blocage potentiel
* Thread 1: verrouillage du mutex m
* Thread 2: demande d'acquisition de m ==> attente libération m
* Thread 1: fin (sans libérer m)
* Thread principal: attente fin de thread 2...
* Pour éviter les blocages ==> toujours utiliser un "verrou"
* Il s'agit d'un wrapper implémentant l'idiome RAII
* Wrapper = objet (verrou) qui encapsule un objet (mutex)
* Il se fait passer pour l'objet ==> interface similaire
* Il contrôle l'appel à ses méthodes
* RAII: Resource Acquisition Is Initialization
* Le constructeur acquiert le mutex
* Le destructeur libère le mutex
* Réduction du risque de blocage des threads
Eviter l'interblocage de threads (2/2)
* Plusieurs types de verrous
* lock_guard
* Interface restreinte: appel explicite à "lock" et "unlock" impossible
* Copie impossible
* Remplacé par "scoped_lock" en C++17
* unique_lock
* Même interface que le mutex
* Copie impossible, mais mouvement autorisé (==> transfert de propriété)
* Exemple
void zzz(unsigned n) {
{ std::lock_guard<std::mutex> verrou(mutex);
std::cout << "[" << n << "] zzz..." << std::endl; }
std::this_thread::sleep_for(std::chrono::seconds(1));
}
Variable de condition (1/4)
* Mécanisme de synchronisation des threads
* Attendre qu'une condition sur des données partagées se réalise
* Attente passive du thread (comme l'acquisition d'un mutex)
* Plusieurs threads peuvent attendre la même condition
* Méthode "wait"
* Un thread qui change l'état des données surveillées
émet un signal vers le ou les threads en attente
* Méthodes "notify_one" et "notify_all"
Variable de condition (2/4)
* Implémentation: un mutex est nécessaire
* Un mutex est associé à la condition
* Ce qui permet de synchroniser l'accès aux données partagées
* Signal émis ==> un ou plusieurs threads tentent d'acquérir le mutex
* Le premier qui verrouille le mutex peut vérifier la condition
* Quel que soit l'état de la condition, le thread devra libérer le mutex
* Condition fausse ==> "wait" libère automatiquement le mutex
* Condition vraie ==> "wait" termine avec le mutex verrouillé
* Car les autres threads ayant reçu le signal sont en attente du mutex
* Exemple (1/4): variables globales
std::condition_variable condition;
std::mutex mutex;
unsigned compteur = 0;
Variable de condition (3/4)
* Exemple (2/4): 1 maître et 3 esclaves
int main(void) {
std::thread t[4];
t[0] = std::thread(maitre);
for (unsigned i = 1; i<4; ++i) t[i] = std::thread(esclave,i);
for (unsigned i = 0; i<4; ++i) t[i].join();
}
* Exemple (3/4): les esclaves attendent une condition
void esclave(unsigned n) {
std::unique_lock<std::mutex> verrou(mutex);
std::cout << "[" << n << "] attend..." << std::endl;
condition.wait(verrou,[](){ return compteur==5; });
std::cout << "[" << n << "] termine" << std::endl;
}
Variable de condition (4/4)
* Exemple (4/4): le maître modifie les données associées à la condition
void maitre(void) {
for (unsigned i = 0; i<5; ++i) {
{ std::lock_guard<std::mutex> verrou(mutex);
std::cout << "[0] compteur = " << compteur << std::endl; }
std::this_thread::sleep_for(std::chrono::seconds(1));
{ std::lock_guard<std::mutex> verrou(mutex);
++compteur; }
condition.notify_all();
}
}
Abstraction des threads
* Couche pour masquer les mécanismes multithread
* Simplifier le code
* Eviter les interblocages
* Garantir l'attente de la fin des threads
* Fonction "async"
* Exécution asynchrone d'un callable
* Création et démarrage automatique d'un thread
* Garantie de l'attente de la fin du thread
* Objets "future" et "promise"
* Mécanisme de synchronisation
* Attente des résultats d'un thread
Fonction "async" (1/2)
* Syntaxe similaire au constructeur d'un thread
* Arguments: politique d'asynchronisme + callable
* Politique d'asynchronisme
* std::launch::async: lancement sur un nouveau thread
* std::launch::deferred: lancement en mode "lazy"
* Exemple...
for (unsigned i = 0; i<4; ++i)
async(std::launch::async,zzz,i);
* ...qui ne fait pas ce qu'on pense
* "async" retourne un objet "future"
* Son destructeur attend la fin du thread (équivalent de "join")
* Dans l'exemple, l'exécution est donc synchrone !!!
Fonction "async" (2/2)
* Eviter de laisser un objet "future" dans une rvalue
* Destruction immédiate ==> synchronisation
* Stocker l'objet "future" dans une variable locale
* Variable détruite à la fin du bloc d'instructions
* Donc bien choisir l'endroit de la déclaration
* Exemple
{
std::future<void> f[4];
for (unsigned i = 0; i<4; ++i)
f[i] = async(std::launch::async,zzz,i);
// Attente des threads à la fin du bloc
}
Objet "future" (1/2)
* Représente la valeur retournée par l'exécution d'un thread
* Encapsule le mécanisme de synchronisation du thread
* L'attente de la fin du thread est prise en charge
* Méthode "wait"
* Attend que le résultat soit disponible
* Autrement dit, que le thread se termine
* Méthode "get"
* Retourne le résultat une fois qu'il est disponible
* Attend aussi que le thread se termine
* Si aucune des deux méthodes n'est appelée,
le destructeur attend le résultat
* Afin d'être sûr que la fin du thread sera toujours attendue
Objet "future" (2/2)
* Exemple
double calcul(unsigned n) {
std::this_thread::sleep_for(std::chrono::seconds(1));
return (n+1)*(n+1);
}
int main(void) {
std::future<double> f[4];
double somme = 0;
for (unsigned i = 0; i<4; ++i)
f[i] = std::async(std::launch::async,calcul,i);
for (unsigned i = 0; i<4; ++i) somme += f[i].get();
std::cout << "somme = " << somme << std::endl;
}
Objet "promise" (1/2)
* Permet à un thread de fournir un résultat avant la fin de son exécution
* Représente une valeur associée à un objet "future"
* Lorsque le thread attribue une valeur à un objet "promise"
* Avec la méthode "set_value"
* L'objet "future" associé est informé
* Sa méthode "get" ou "wait" en attente est débloquée
* Exemple (1/2)
void calcul(std::promise<double> p1,
std::promise<double> p2) {
std::this_thread::sleep_for(std::chrono::seconds(1));
p1.set_value(3);
std::this_thread::sleep_for(std::chrono::seconds(1));
p2.set_value(7);
}
Objet "promise" (2/2)
* Pas de constructeur de copie
* Utiliser "std::move" pour invoquer le constructeur de mouvement
* Exemple (2/2)
int main(void) {
std::promise<double> p1, p2;
std::future<double> f1 = p1.get_future();
std::future<double> f2 = p2.get_future();
std::future<void> f = std::async(std::launch::async,calcul,
std::move(p1),
std::move(p2));
f1.wait(); std::cout << "valeur1 = " << f1.get() << std::endl;
f2.wait(); std::cout << "valeur2 = " << f2.get() << std::endl;
}