Vous êtes sur la page 1sur 47

Multithreading en C++

A. ZAKARIA
2020/2021
Sommaire
 Introduction
 Multitasking vs multithreading
 C++11 threading support library
 Création de Thread
 Synchronization
 L'utilisation de Mutex et ses problèmes
 Condition variable
 Design patterns
 Producer/Consumer
 Active Object
 Reactor
 ThreadPool
Introduction
Parallélisme
 Effectuer des opérations simultanément (en parallèle)
 Nous pouvons marcher, parler, respirer, voir, entendre, sentir... tout en
même temps.
 Les ordinateurs peuvent également le faire - télécharger un fichier,
imprimer un fichier, recevoir des courriels, faire tourner l'horloge, plus ou
moins en parallèle....
 Comment ces tâches sont-elles généralement accomplies ?
 Les systèmes d'exploitation prennent en charge les processus
 Quelle est la différence entre un processus et un thread ?
 Les processus ont leur propre espace mémoire, les threads partagent la
mémoire
 Ainsi, les processus sont "lourds" tandis que les threads sont "légers".
Introduction
 Quoi et pourquoi ?

 Threads d ’execution
 Chaque thread est une partie d'un programme qui peut s'exécuter simultanément
avec d'autres threads (Multithreading)
 Exemple : téléchargement d'un clip vidéo
 Au lieu de devoir télécharger le clip en entier, vous pouvez le lire :
 Téléchargez une partie, jouez cette partie, téléchargez la partie suivante, jouez cette partie...
(streaming)
 Il faut que cela se fasse sans heurts (smoothly)

 ….
Introduction
 Multitasking vs multithreading:
 Les systèmes d'exploitation multitâches(Multitasking) permettent
d'exécuter plusieurs "processus" simultanément
 Une seule demande peut engendrer plusieurs processus
 Le système d'exploitation attribue un espace mémoire distinct à
chaque processus (un processus ne peut pas accéder directement
à l'espace mémoire d'un autre processus)
 Le multithreading permet à un seul processus d'exécuter T1 T2
plusieurs tâches simultanément (dans un espace mémoire
partagé)
 Partage rapide et facile des structures de données entre les tâches

 Les deux ont été introduites pour améliorer les performances et la


réactivité des systèmes informatiques
 Ce cours est axé sur le multithreading

T1 T2
Introduction
 Pourquoi le multithreading ?
 Permettre à une application d'effectuer plusieurs tâches simultanément
 Par exemple, l'utilisation d'une interface utilisateur réactive, la gestion des communications sur le
réseau et l'exécution simultanée de calculs lourds par lots

 Paralléliser les algorithmes afin d'exploiter les processeurs multicœurs


 Cette méthode est devenue populaire avec l'adoption généralisée des architectures multicœurs

 Assistance multithreading
 Côté matériel : nous sommes actuellement à l'ère du multicore (et du manycore)
 Des performances supplémentaires des architectures de calcul fournies par un nombre croissant de
cœurs

 Côté logiciel : Une importance croissante en informatique


 Les langages de programmation ajoutent une assistance (support) natif pour le multithreading
 Exemple : C++ à partir de la version standard C++11
Introduction
 Thread
Phisycal memory
 Un thread est défini comme une "tâche légère"
(lightweight task). Process Virtual Address Space

Process

 Chaque thread a une pile et un contexte séparés Thread Thread

(registres et valeur du compteur du programme)


Stack Stack

Registers Registers

 En fonction des implémentations, l'OS ou le runtime Program Counter Program Counter


de la langue sont responsables de
l'ordonnancement du thread-to-core

Operating System Scheduler

Processor
Sommaire
 Introduction
 Multitasking vs multithreading
 C++11 threading support library
 Création de Thread
 Synchronization
 L'utilisation de Mutex et ses problèmes
 Condition variable
 Design patterns
 Producer/Consumer
 Active Object
 Reactor
 ThreadPool
C++11 multithreading support
 Class std::thread
 Le constructeur lance un thread exécutant la fonction donnée

#include <iostream>
#include <thread> using
namespace std;
using namespace std::chrono;

void myThread() {
for(;;) {
cout<<"world "<<endl;
this_thread::sleep_for(milliseconds(500));
}
}
int main() {
thread t(myThread);
for(;;) {
cout<<"hello"<<endl;
this_thread::sleep_for(milliseconds(500));
}
}
C++11 multithreading support
 Class std::thread
$ g++ main.cpp -o test -std=c++11 -pthread

 Il n'y a aucune garantie concernant l'ordre d'exécution des threads


$ ./test
helloworld

helloworld

hello
world
helloworld

hello
world
helloworld
C++11 multithreading support
 Class std::thread
 Le constructeur de thread peut prendre des arguments supplémentaires
qui sont passés à la fonction thread
 La fonction membre "join()" attend que le thread soit terminé

#include <iostream>
#include <thread>
using namespace std;

void myThread(int i, const string & s) {


cout<<"Called with "<<i<<" and "<<s<<endl;
}

int main() {
thread t(myThread, 2, "test");
t.join();
}
C++11 multithreading support
 Synchronisation
 Quel est le résultat du code suivant ?
#include <iostream>
#include <thread>
using namespace std;
static int sharedVariable=0;

void myThread() {
for(int i=0;i<1000000;i++) sharedVariable++;
}

int main() {
thread t(myThread);
for(int i=0;i<1000000;i++) sharedVariable--;
t.join();
cout<<"sharedVariable="<<sharedVariable<<endl;
}
C++11 multithreading support
 Synchronisation
$ ./test
sharedVariable=-313096
$ ./test
sharedVariable=-995577
$ ./test
sharedVariable=117047
$ ./test
sharedVariable=116940
$ ./test
sharedVariable=-647018

 L'accès aux mêmes variables à partir de différents threads peut entraîner


un problème de race conditions.
 Les deux threads changent simultanément la valeur de la même variable
C++11 multithreading support
 Synchronisation
 Que se passe-t-il sous le capot ?
//sharedVariable++
movl sharedVariable(%rip), %eax
addl $1, %eax
movl %eax, sharedVariable(%rip)

//sharedVariable--
movl sharedVariable(%rip), %eax
subl $1, %eax
movl %eax, sharedVariable(%rip)

 Les incréments (++) et les décréments (--) ne sont pas des opérations
atomiques
 Le système d'exploitation peut anticiper un thread entre n'importe quelle
instruction
C++11 multithreading support
 Synchronisation
 Que se passe-t-il sous le capot ?

movl sharedVariable(%rip), %eax


addl $1, %eax Preemption

movl sharedVariable(%rip), %eax


time

subl $1, %eax


Preemption movl %eax, sharedVariable(%rip)

movl %eax, sharedVariable(%rip)

 MyThread a été préempté avant que le résultat de l'opération


d'incrémentation n'ait été réécrit (sharedVariable update)
 Cela conduit à des comportements incorrects et imprévisibles
C++11 multithreading support
 Synchronisation
 Une section critique (critical section) est une séquence
d'opérations accédant à une structure de données partagée
qui doit être effectuée de manière atomique pour préserver
l'exactitude du programme

 Dans cet exemple, nous avons été confrontés à un problème


de condition de course (race condition), puisque les deux
threads sont entrés en parallèle dans une section critique

 Pour prévenir les conditions de course, nous devons limiter


l'exécution simultanée chaque fois que nous entrons dans une
section critique
C++11 multithreading support
 Solution 1 : Désactiver la préemption
 Dangereux - il peut bloquer le système d'exploitation
 Trop restrictif - il est sûr d'anticiper sur un thread qui ne modifie
pas la même structure de données
 Ne fonctionne pas sur les processeurs multicœurs
 Solution 2 : Exclusion mutuelle
 Avant d'entrer dans une section critique, un thread vérifie si elle
est "libre".
Si c'est le cas, entrez dans la section critique
Sinon, il bloque
 En sortant d'une section critique, un thread vérifie s'il y a d'autres
threads bloqués
Si oui, l'un d'entre eux est sélectionné et réveillé
C++11 multithreading support
 Class std::mutex
 Il a deux fonctions membre:
 lock() à appeler avant d'entrer dans une section critique
 unlock() pour appeler après avoir quitté une section critique

#include <iostream>
#include <thread> int main() {
#include <mutex> thread t(myThread);
using namespace std; for(int i=0;i<1000000;i++) {
static int sharedVariable=0; myMutex.lock();
mutex myMutex; sharedVariable--;
myMutex.unlock();
void myThread() { }
for(int i=0;i<1000000;i++) { t.join();
myMutex.lock(); cout<<"sharedVariable="
sharedVariable++; <<sharedVariable<<endl;
myMutex.unlock(); }
}
}
problèmes?
C++11 multithreading support
 Deadlock
 Une utilisation incorrecte du mutex peut conduire à un
deadlock, selon lequel l'exécution du programme se bloque
 Les Deadlocks peuvent être dus à plusieurs causes
 Cause 1 : oubli de déverrouiller un mutex


mutex myMutex;
int sharedVariable;

void myFunction(int value) {


myMutex.lock();
if(value<0) {  Une fonction retourne sans
cout<<"Error"<<endl; déverrouiller le mutex
return; précédemment verrouillé
}
SharedVariable += value;
myMutex.unlock();
 Le prochain appel de fonction
}
aboutira à un deadlock.
C++11 multithreading support
 Deadlock
 Solution : Le C++11 fournit un scoped lock qui déverrouille
automatiquement le mutex, quelle que soit la façon dont on
quitte le scope.

mutex myMutex;
int sharedVariable;

void myFunction(int value) {


{
lock_guard<mutex> lck(myMutex);
if(value<0)
{
cout<<"Error"<<endl;
return;  myMutex déverrouillé ici
}
SharedVariable += value; OU
}  myMutex déverrouillé ici
}
C++11 multithreading support
 Deadlock
 Cause 2 : appels de fonction imbriqués (nested function)
verrouillant le même mutex

mutex myMutex;
int sharedVariable;

void func2()
{
lock_guard<mutex> lck(myMutex);  Deadlock si c'est
doSomething2(); appelé par func1()
}

void func1()
{
lock_guard<mutex> lck(myMutex);
doSomething1();
func2();  Appelé avec le
} mutex verrouillé
C++11 multithreading support
 Deadlock
 Solution : Le mutex récursif (Recursive mutex) permet de
multiples verrouillages par le même thread

recursive_mutex myMutex;
int sharedVariable;

void func2() {
lock_guard<recursive_mutex> lck(myMutex);
doSomething2();
}

void func1(){
lock_guard<recursive_mutex> lck(myMutex);
doSomething1();
func2();
}

 Cependant, les mutex récursifs sont plus coûteux que les mutex.
 Il faut l’utiliser uniquement en cas de besoin
C++11 multithreading support
 Deadlock
 Cause 3 : Ordre de verrouillage de plusieurs mutex

mutex myMutex1;
mutex myMutex2;

void func2() {
lock_guard<mutex> lck1(myMutex1);
lock_guard<mutex> lck2(myMutex2);
doSomething2();
}

void func1(){
lock_guard<mutex> lck1(myMutex2);
lock_guard<mutex> lck2(myMutex1);
doSomething1();
}

 Thread1 appelle func1(), verrouille myMutex2 et bloque sur myMutex1


 Thread2 appelle func2(), verrouille myMutex1 et bloque sur myMutex2
C++11 multithreading support
 Deadlock
 Solution : La fonction lock()du C++11 permet de respecter l'ordre de verrouillage

mutex myMutex1;
mutex myMutex2;

void func2() {
lock(myMutex1,myMutex2);
doSomething2();
myMutex1.unlock();
myMutex2.unlock();
}
void func1(){
lock(myMutex2,myMutex1);
doSomething1();
myMutex2.unlock();
myMutex1.unlock();
}

 Un nombre quelconque de mutex peut être passé au lock() et dans n'importe quel ordre.
 L'utilisation de lock est plus coûteuse que celle de lock_guard
C++11 multithreading support
 Deadlock & Race conditions
 Sont des défauts qui surviennent en raison d'un ordre d'exécution "inattendu" des
threads.
 Les programmes corrects doivent fonctionner quel que soit l'ordre d'exécution
 L'ordre qui déclenche la faute peut être extrêmement rare
 Le fait de lancer le même programme des millions de fois peut ne pas déclencher le
défaut.
 sont donc difficiles à déboguer
 Il est difficile de reproduire le bug
 Les tests sont presque inutiles pour vérifier ces erreurs
 Une bonne conception est indispensable!
C++11 multithreading support
 Perte de la concurrence
 Le fait de laisser un mutex verrouillé pendant une longue période réduit la
concurrence dans le programme

mutex myMutex;
int sharedVariable=0;

void myFunction()
{
lock_guard<mutex> lck(myMutex);
sharedVariable++;
this_thread::sleep_for(milliseconds(500));
}
C++11 multithreading support
 Perte de la concurrence
 Solution : Les sections critiques doivent être aussi courtes que possible
 Laissez les opérations inutiles en dehors de la section critique


mutex myMutex;
int sharedVariable=0;

void myFunction()
{
{
lock_guard<mutex> lck(myMutex);
sharedVariable++;
}
this_thread::sleep_for(milliseconds(500));
}
C++11 multithreading support
 Condition variable
 Dans de nombreux programmes multithreads, nous pouvons avoir des
dépendances entre les threads
 Une "dépendance" peut venir du fait que le thread doit attendre qu'un autre
thread termine son opération en cours
 Dans un tel cas, nous avons besoin d'un mécanisme pour bloquer explicitement un
thread.

 class std::condition_variable
 Fonction de trois membres
 wait(unique_lock<mutex> &) bloque le thread jusqu'à ce qu'un autre thread le
réveille.
 Le mutex est débloqué pour la durée de wait(...)
 notify_one() réveille l'un des threads en attente
 notify_all() réveille tous les threads en attente
 Si aucun thread n'est en attente, on ne fait rien
C++11 multithreading support
 class std::condition_variable
 Dans l'exemple, myThread attend que la fonction main() termine la lecture à partir
de l'entrée standard.

#include <iostream>
#include <thread> int main() {
#include <mutex> thread t(myThread); string s;
#include <condition_variable> cin>>s; // read from stdin
using namespace std; {
string shared; unique_lock<mutex> lck(myMutex);
mutex myMutex; shared=s;
condition_variable myCv; myCv.notify_one();
}
void myThread() { t.join();
unique_lock<mutex> lck(myMutex); }
if(shared.empty()) myCv.wait(lck);
cout<<shared<<endl;
}
Sommaire
 Introduction
 Multitasking vs multithreading
 C++11 threading support library
 Création de Thread
 Synchronization
 L'utilisation de Mutex et ses problèmes
 Condition variable
 Design patterns
 Producer/Consumer
 Active Object
 Reactor
 ThreadPool
Design patterns
 Producer/Consumer (producteur/consommateur)

Producer Consumer
Thread Thread

Queue
 Un thread (consommateur) a besoin de données d'un autre thread (producteur)
 Pour découpler les opérations des deux threads, on met une queue entre eux, pour
mettre en mémoire tampon(buffer) les données si le producteur est plus rapide
que le consommateur.
 L'accès à la queue doit être synchronisé
 Non seulement le consommateur utilise un mutex, mais il doit attendre si la queue est vide
 Facultativement, le producteur peut bloquer si la queue est pleine
Design patterns
 Producer/Consumer (producteur/consommateur)
 synchronized_queue.h (1/2)
#ifndef SYNC_QUEUE_H_
#define SYNC_QUEUE_H_
#include <list>
#include <mutex>
#include <condition_variable>
template<typename T>
class SynchronizedQueue {
public:
SynchronizedQueue(){}
void put(const T & data);
T get();
private:
SynchronizedQueue( const SynchronizedQueue &);
SynchronizedQueue & operator=(const SynchronizedQueue &);
std::list<T> queue;
std::mutex myMutex;
std::conditionVariable myCv;
};

Design patterns
 Producer/Consumer (producteur/consommateur)
 synchronized_queue.h (2/2)
template<typename T>
void SynchronizedQueue<T>::put(const T& data)
{
std::unique_lock<std::mutex> lck(myMutex);
queue.push_back(data);
myCv.notify_one();
}

template<typename T>
T SynchronizedQueue<T>::get()
{
std::unique_lock<std::mutex> lck(myMutex);
while(queue.empty())
myCv.wait(lck);
T result=queue.front();
queue.pop_front();
return result;
}
#endif // SYNC_QUEUE_H_
Design patterns
 Producer/Consumer (producteur/consommateur)
 main.cpp
#include “synchronized_queue.h”
#include <iostream>
#include <thread>
using namespace std;
using namespace std::chrono;

SynchronizedQueue<int> queue;

void myThread() {
for(;;) cout<<queue.get()<<endl;
}
int main() {
thread t(myThread);
for(int i=0;;i++) {
queue.put(i);
this_thread::sleep_for(seconds(1));
}
}
Design patterns
 Producer/Consumer (producteur/consommateur)
 Que se passe-t-il si on n'utilise pas la variable de condition ?
 synchronized_queue.h (1/2)
#ifndef SYNC_QUEUE_H_
#define SYNC_QUEUE_H_
#include <list>
#include <mutex>
template<typename T>
class SynchronizedQueue {
public:
SynchronizedQueue(){}
void put(const T & data);
T get();
private:
SynchronizedQueue( const SynchronizedQueue &);
SynchronizedQueue & operator=(const SynchronizedQueue &);
std::list<T> queue;
std::mutex myMutex;
// std::conditionVariable myCv;
};

Design patterns
 Producer/Consumer (producteur/consommateur)
 synchronized_queue.h (2/2)
template<typename T>
void SynchronizedQueue<T>::put(const T & data)
{
std::unique_lock<std::mutex> lck(myMutex);
queue.push_back(data);
//myCv.notify_one();
}

template<typename T>
T SynchronizedQueue<T>::get() {
for(;;) {
std::unique_lock<std::mutex> lck(myMutex);
if(queue.empty()) continue;
T result=queue.front();
queue.pop_front();
return result;
}
}
#endif // SYNC_QUEUE_H_
Design patterns
 Producteur/Consommateur
 Que se passe-t-il si on n'utilise pas la variable de condition ?
 Le consommateur est laissé "à la dérive" lorsque la queue est vide
 Cela occupe de précieux cycles de CPU et ralentit d'autres threads du système
 Le fait de garder le CPU occupé augmente la consommation d'énergie

 Bien que le code soit correct d'un point de vue fonctionnel, il s'agit d'une mauvaise
approche de programmation
 Lorsqu'un thread n'a rien à faire, il doit se bloquer pour libérer le CPU pour d'autres
threads et réduire la consommation d'énergie

 Extension : Essayez d'implémenter la version avec une taille de la queue limitée


 Le producteur bloque si la queue atteint la taille maximale
Design patterns
 Active Object
 Pour instancier les " task objects ".
 Un thread n'a pas de moyen explicite pour les autres threads de communiquer
avec lui
 Souvent, les données sont transmises au thread par des variables globales
 Inversement, ce pattern nous permet d'enrouler un thread dans un objet, ayant
ainsi un "thread avec des méthodes que l'on peut appeler"
 Nous pouvons avoir des fonctions de membre pour transmettre des données pendant
que la tâche est en cours, et collecter des résultats
 Dans certains langages de programmation (par exemple, Smaltalk et Objective C),
tous les objets sont des objets actifs « active objects ».
Design patterns
 Active Object
 La classe inclut un objet thread et une fonction membre run()
implémentant la tâche
#ifndef ACTIVE_OBJ_H_
#define ACTIVE_OBJ_H_
#include <atomic>
#include <thread>
class ActiveObject {
public:
ActiveObject();
~ActiveObject();
private:
virtual void run();
ActiveObject(const ActiveObject &);
ActiveObject& operator=(const ActiveObject &);
protected:
std::thread t;
std::atomic<bool> quit;
};
#endif // ACTIVE_OBJ_H_
Design patterns
 Active Object
#include "active_object.h"
#include <chrono>
#include <functional>
#include <iostream>
using namespace std;
using namespace std::chrono;

ActiveObject::ActiveObject():
t(bind(&ActiveObject::run, this)), quit(false) {}

void ActiveObject::run() {
while(!quit.load()) {
cout<<"Hello world"<<endl;
this_thread::sleep_for(milliseconds(500));
}
}
ActiveObject::~ActiveObject() {
if(quit.load()) return; //For derived classes
quit.store(true);
t.join();
}
Design patterns
 Active Object
 Le constructeur initialise l'objet thread, tandis que le destructeur se charge de le
joindre
 La fonction membre run()agit comme un " main " exécutant simultanément
 Généralement utilisé pour implémenter des threads communiquant à travers une
approche producteur/consommateur
 Dans l'exemple d'implémentation, nous avons utilisé la variable "atomique" quit
pour terminer la fonction run() lorsque l'objet est détruit.
 Une variable booléenne normale avec un mutex fonctionnerait également
Design patterns
 bind and function
 En C++11, bind et function nous permettent de regrouper une fonction et ses
arguments, et de l'appeler plus tard

#include <iostream>
#include <functional>
using namespace std;

void printAdd(int a, int b){ On veut traiter une


cout<<a<<'+'<<b<<'='<<a+b<<endl; fonction comme un objet
}

int main() {
function<void ()> func; On spécifie les arguments de la
func = bind(&printAdd, 2, 3); fonction sans effectuer l'appel.

func(); L'appel de fonction (avec des
} arguments déjà packagés)
Design patterns
 Reactor
 L'objectif est de découpler la création de tâches de l'exécution
 Un thread exécuteur attend dans une queue de tâches
 Toute autre partie du programme peut faire passer des tâches dans la queue.
 Les tâches sont exécutées de manière séquentielle
 La solution la plus simple est généralement de passer par la méthode FIFO
 On est libre d'ajouter au « reactor » des fonctions alternatives d'ordonnancement
(scheduling) des threads
 C++11: bind et function nous permet de créer la tâche, en laissant le temps de
démarrage au thread exécuteur, dans un second temps
Design patterns
 Reactor
 La classe dérive de la classe ActiveObject pour implémenter le thread exécuteur
et utilise la SynchronizedQueue pour la queue des tâches
#ifndef REACTOR_H_
#define REACTOR_H_
#include <functional>
#include “synchronized_queue.h”
#include “active_object.h”

class Reactor: public ActiveObject {


public:
void pushTask(std::function<void ()> func);
virtual void ~Reactor();
private:
virtual void run();
SynchronizedQueue<std::function<void ()>> tasks;
};
#endif // REACTOR_H_
Design patterns
 Reactor
#include "reactor.h"
using namespace std;

void doNothing() {}

void Reactor::pushTask(function<void ()> func) {


tasks.put(func);
}

Reactor::~Reactor() {
quit.store(true);
pushTask(&doNothing);
t.join(); // Thread derived from ActiveObject
}

void Reactor::run() {
while(!quit.load())
tasks.get()(); // Get a function and call it
}
Design patterns
 Reactor
 Dans l'exemple, on pousse une tâche pour exécuter la fonction printAdd()

#include <iostream>

using namespace std;

void printAdd(int a, int b)


{
cout<<a<<'+'<<b<<'='<<a+b<<endl;
}

int main()
{
Reactor reac;
reac.pushTask(bind(&printAdd,2,3));

}
Design patterns
 ThreadPool
 La limite du pattern Reactor est due au fait que les tâches sont traitées de manière
séquentielle
 La latence de l'exécution de la tâche dépend de la longueur de la queue des tâches.

 Pour réduire la latence et exploiter les architectures de calcul multicœurs, nous


pouvons avoir plusieurs threads exécuteurs en attente sur la même queue de
tâches

 Essayez de mettre en place votre propre ThreadPool à la maison !

Vous aimerez peut-être aussi