Un thread permet de paralléliser du code et son exécution, au sein d'un programme tout en partageant la mémoire virtuelle du programme, mais possédant chacun sa pile d'appels. En général, le code exécuté dans la fonction main sera appelé le « main thread » ou thread principal, les threads que nous créerons seront des threads secondaires. Puisque le code est exécuté en parallèle, les fonctions bloquantes ne seront pas un problème : seul le thread qui les exécute sera bloqué, le reste du programme pouvant continuer son exécution.
L’avantage apporté par l’utilisation des threads est qu’il est possible d’exécuter des sous-tâches dans un processus sans pour autant bloquer l’ensemble. Par exemple dans le cas d’une interface avec l’utilisateur : les interactions sont traitées dans un thread séparé et n’interrompent pas d’autres calculs plus lourds. La difficulté de la programmation multithreading est d’assurer la sécurité de l’accès à la mémoire (accès concurrentiels), et également d’éviter les situations d’interblocage.
Les thread et mutex ont maintenant une API disponible dans la std : std::thread et std::mutex. Leurs documentations sont disponibles en ligne aux adresses respectives suivantes http://www.cplusplus.com/reference/thread/thread/ et http://www.cplusplus.com/reference/mutex/.
Dans ce chapitre, nous allons voir les principes essentiels à leur utilisation.
std::thread possède un constructeur par défaut, surtout utile pour pouvoir gérer ses threads dans des collections comme std::vector, mais surtout un constructeur qui prend une fonction et des arguments en paramètres et lancera un thread l'exécutant. On pourra bien sûr l'utiliser avec une lambda également.
Lancement d'un thread :
#include <thread>
#include <mutex>
#include <iostream>
// une fonction à deux arguments entier
void display(int start, int nb)
{
for (int i = start; i < start + nb; ++i)
std::cout << i << ",";
}
int main(){
// lancement d'un thread t1 appellant la fonction display
std::thread t1(display, 0, 5);
// lancement d'une fonction lambda ( en l'occurence display() )
std::thread t2([]() { display(5, 5); });
// attente de t1
t1.join();
// attente de t2
t2.join();
return 0 ;
}Dans l'exemple ci-dessus, vous avez pu constater l'appel à une méthode join. Cette fonction est bloquante jusqu'à ce que le thread ait terminé son exécution, dans notre cas jusqu'à ce que la boucle et l'affichage de chaque valeur ait été faite. Quand la fonction d'un thread est terminée, le thread est automatiquement terminé.
Avec le multi-threading vient les soucis de synchronisation : chaque thread est exécuté en parallèle, mais nous n'avons aucune assurance de l'ordre des opérations de chaque thread.
Pour en prendre conscience, essayez le code suivant :
#include <thread>
#include <iostream>
int main() {
std::thread t1([]() {
for (int i = 0; i < 10; ++i)
{
std::cout << (i * 3) << " ";
}
});
std::thread t2([]() {
for (int i = 0; i < 10; ++i)
{
std::cout << (i * 3) + 1 << " ";
}
});
t1.join();
t2.join();
return 0;
}Lancez ce programme plusieurs fois à la suite et constatez que les résultats ne sont pas toujours identiques.
L'accès à la console via std::cout est concurrentiel entre les threads. On ne maîtrise pas quand chacun y accède et y écrit, d'où les différents résultats ci-dessus. Dans certains cas, un accès concurrent n'est pas gênant, ici écrire dans la console dans un ordre à priori aléatoire, en tout cas non maîtrisé, ne dérange pas le programme. Mais dans certains cas, il peut s'agir d'une ressource critique qui nécessitera alors que les threads l'utilisant soient synchronisés, d'une ressource nécessitant d'être utilisée par un unique thread à la fois.
Pour synchroniser nos applications, nous pouvons avoir recours à un mutex. Un mutex est un point de contention, utilisé pour que deux tâches ne puissent s'exécuter en parallèle. On verrouillera un mutex pour l'acquérir, les appels successifs ne pourront alors pas le verrouiller et devront attendre qu'il soit libéré. Il s'agit bel et bien d'une attente, donc de bloquer le thread en question.
std::mutex est le plus simple d'entre eux. Il peut être verrouillé une seule et unique fois, chaque appel à lock supplémentaire attendra que le mutex soit déverrouillé via un appel à unlock avant de retourner. Il existe également la méthode try_lock qui permet, si le mutex est déjà verrouillé, et un appel à lock serait alors bloquant de retourner false directement. Si le mutex peut être acquis, il est verrouillé et try_lock retourne true.
On pourra ainsi modifier le premier code comme ceci :
#include <thread>
#include <mutex>
#include <iostream>
int main() {
//déclaration du mutex
std::mutex lock;
//
std::thread t1([&lock]() {
lock.lock();
std::cout << "Dans le thread t1" << std::endl;
for (int i = 0; i < 10; ++i)
{
std::cout << (i * 3) << " ";
}
std::cout << std::endl;
lock.unlock();
});
std::thread t2([&lock]() {
lock.lock();
std::cout << "Dans le thread t2" << std::endl;
for (int i = 0; i < 10; ++i)
{
std::cout << (i * 3) + 1 << " ";
}
std::cout << std::endl;
lock.unlock();
});
t1.join();
t2.join();
return 0;
}Chaque thread effectue maintenant sa boucle complète, dû au blocage du mutex.
std::recursive_mutex est identique au std::mutex, mais peut être verrouillé plusieurs fois dans un même thread. Particulièrement utile si une fonction peut être amenée à se retrouver dans la callstack d'un thread qui aurait déjà verrouillé le mutex en question.
Avec l'utilisation des mutex en contexte multi-thread, il faut faire attention à ne pas se retrouver en situation de deadlock. La situation classique est d'avoir deux mutex, M1 et M2, et deux threads, T1 et T2, qui verrouillent chacun un mutex puis l'autre dans l'ordre inverse :
std::mutex m1;
std::mutex m2;
std::thread t1([&m1, &m2]() {
m1.lock();
m2.lock();
std::cout << "Dans T1" << std::endl;
m2.unlock();
m1.unlock();
});
std::thread t2([&m1, &m2]() {
m2.lock();
m1.lock();
std::cout << "Dans T2" << std::endl;
m1.unlock();
m2.unlock();
});En effet, puisqu'on n'a aucune assurance de l'ordre d'exécution du code, ce scénario peut (et va) se produire :
- T1 verrouille M1.
- T2 verrouille M2.
- T1 essaye de verrouiller M2 qui est déjà verrouillé.
- T2 essaye de verrouiller M1 qui est déjà verrouillé.
- Ni T1 ni T2 ne peuvent continuer leur exécution !
Bien entendu, dans la plupart des programmes la détection d'un deadlock ne sera pas aussi simple que le cas présenté ici. Certains peuvent s'avérer bien vicieux à déceler, impliquant plus de deux threads, plus de deux mutex, ou suite à une exception survenue avant l'appel à unlock…
Pour ce dernier cas, et pour une utilisation vraiment pratique et simplifiée des mutex, il existe un petit objet nommé std::lock_guard.
Cet objet prend un mutex en paramètre et le verrouille à sa création, puis se charge de le libérer à sa destruction. Il est ainsi très simple de limiter un verrou à un bloc de code, et en particulier en cas d'exception, early-return ou toute sortie prématurée du bloc, le verrou est également libéré automatiquement.
std::mutex m;
{
std::lock_guard lock(m);
// m est maintenant verrouillé
}
// m n'est plus verrouillé
Le code du précédent exemple serait alors écrit ainsi :
#include <thread>
#include <mutex>
#include <iostream>
int main() {
std::mutex lock;
std::thread t1([&lock]() {
std::lock_guard guard(lock);
std::cout << "Dans le thread t1" << std::endl;
for (int i = 0; i < 10; ++i)
{
std::cout << (i * 3) << " ";
}
std::cout << std::endl;
});
std::thread t2([&lock]() {
std::lock_guard guard(lock);
std::cout << "Dans le thread t2" << std::endl;
for (int i = 0; i < 10; ++i)
{
std::cout << (i * 3) + 1 << " ";
}
std::cout << std::endl;
});
t1.join();
t2.join();
return 0;
}
La notion de Thread est relativement récente dans la STL. D'autres librairies permettent également de créer des threads, sitons notamment Qt ou Boost. Ces librairies permettent d'aller un peu plus loin assez facilement.
En prenant l'exemple de boost, Il est par exemple possible de stoper un thread avant d'attendre sa fin. Les points d’interruption sont les suivants :
Attente d’un autre thread :
- boost::thread::join()
- boost::thread::timed_join()
- boost::thread::try_join_for(),
- boost::thread::try_join_until(),
Attente du signalement d’un évènement :
- boost::condition_variable::wait()
- boost::condition_variable::timed_wait()
- boost::condition_variable::wait_for()
- boost::condition_variable::wait_until()
- boost::condition_variable_any::wait()
- boost::condition_variable_any::timed_wait()
- boost::condition_variable_any::wait_for()
- boost::condition_variable_any::wait_until()
Mise en sommeil :
- boost::thread::sleep()
- boost::this_thread::sleep_for()
- boost::this_thread::sleep_until()
Déclaration de point d’interruption :
- boost::this_thread::interruption_point()
Bref, de quoi faire des choses bien compliqués...