La concurrence en C++

 

 

 

 

Généralités

 

La gestion de la concurrence peut être réalisée à 3 niveaux : bas niveau, via des variables atomiques, qui se manipule sans possibilités d’accès simultanés, avec les threads, où finalement, le programmeur traite directement des threads systèmes, et enfin via les tâches, un concept bien plus proche de l’objectif du développeur en général.

 

 

 

La gestion bas-niveau  de la concurrence

 

 

 

La classe atomic<T> représente un type sur lequel les opérations sont atomiques, c’est-à-dire qu’elles s’exécutent dans un seul thread sans interférences avec un autre. Les spécialisations de atomic peuvent concerner les types fondamentaux, et l’implémentation peut varier. Les différentes fonctions membres proposées sur atomic suppose que vous écriviez un code très bas niveau qui, dans bien des cas, peut être remplacé par des abstractions bien plus simples à mettre en place.

 

Les atomic_flags sont des types très simples, qui sont garantis d’opérations atomiques. Les fonctions disponibles sont essentiellement set() et clear(), permettant de connaître et de positionner l’état de ce flag.

 

Le mot-clé volatile permet de son côté d’indiquer qu’une variable peut être modifiée de l’extérieur du thread dans lequel elle est déclarée. Cela évite la réorganisation des opérations de lecture/écriture.

 

 

 

La gestion de threads

 

 

 

Les threads du C++ sont prévus pour mapper un à un les threads systèmes. Les threads possèdent en propre leurs piles, mais partagent la mémoire du tas. Les variables partagées sont donc susceptibles de concurrences malheureuses entre threads.

 

#include <thread>

 

Penser à inclure la bibliotheque thread.

 

std::cout << "thread courant "<<std::this_thread::get_id();

 

Permet de récupérer l’id du thread courant.

 

Pour lancer les threads, il suffit de passer une fonction, éventuellement des arguments.

 

std::thread t1(ma_fonction);

 

std::thread t2(ma_fonction2);

 

 

 

t1.join();

 

t2.join();

 

 

 

std::cout << "FIN du thread " << std::this_thread::get_id()<<std::endl;

 

Dans ce code, 2 threads sont créés, utilisant 2 fonctions qui font juste un affichage et attendent un peu. Puis le thread principal est bloqué.

 

 

 

void ma_fonction() {

 

std::chrono::duration<int> duree(1);

 

for (int i = 0; i < 10; i++) {

 

std::cout << "thread courant " << std::this_thread::get_id()<<std::endl;

 

std::this_thread::sleep_for(duree);

 

}

 

}

 

void ma_fonction2() {

 

std::chrono::duration<int> duree(3);

 

for (int i = 0; i < 10; i++) {

 

std::cout << "thread courant " << std::this_thread::get_id() << std::endl;

 

std::this_thread::sleep_for(duree);

 

}

 

}

 

 

 

 

 

Le résultat dépend de l’ordonnancement des threads, le join() garantit que le main se terminera lorsque les deux threads seront terminés.

 

 

 

Pour éviter les « data races », soit les accès concurrents malheureux sur les données, il faut synchroniser les threads. Cela va se faire soit par les mutexes, soit en utilisant des variables de conditions.

 

Les mutexes permettent de bloquer un thread sur une ressource. Un thread acquiers un mutex avec lock() et le relâche avec unlock(). Il est possible d’utiliser un mutex à durée limitée et/ou avec appel réentrant. Pour ne pas lever d’exception à l’acquisition du mutex, on peut utiliser try_lock(). Dans ce cas, tester le résultat pour savoir si l’acquisition est ok. Si des exceptions sont levées par l’utilisation des mutex, il s’agit de system_error. Les codes d’erreur sont récupérés par code().

 

 

 

La gestion des tâches

 

 

 

Le code de gestion des threads reste assez complexe pour réaliser ce qui est finalement de la gestion de tâches. Le développeur est détourné de son objectif initial pour mettre en place toute la cuisine de synchronisation etc. Par ailleurs, il est parfois difficile d’être assuré de la pertinence de créer tant et tant de threads pour rendre le service. La création de threads reste coûteuse. Si vous créez plus de threads que nécessaire, vous perdez en efficacité. Avec la gestion de tâches, nous allons travailler plus haut niveau, le bénéfice est double : mieux se focaliser sur son besoin et laisser les tâches activer ou pas les threads sous-jacents.

 

Le support des tâches est réalisé à travers quelques classes, telles packaged_task<T>, promise<T>, future<T> ou les fonctions async. Utiliser ces éléments va drastiquement simplifier la mise en oeuvre du parallélisme. Les future et promise représentent respectivement les valeurs à récupérer et la valeur positionnée par la tâche.

 

Une utilisation simpliste des tâches se réalisent de la façon suivante :

 

//une_fonction attend un paramètre int et ne retourne rien

 

std::packaged_task<void(int)> tache1(une_fonction);

 

std::packaged_task<void(int)> tache2(une_fonction);

 

 

 

//appel de la fonction avec passage de paramètre

 

tache1(1);

 

tache2(2);

 

Les tâches peuvent correspondre aux threads en fonction des possibilités de la machine.

 

On peut récupérer le résultat d’un calcul lorsqu’il est réalisé seulement (c’est un future ! ). Ici les tâches sont en fait une fonction qui fait la somme entre deux bornes.

 

std::packaged_task<int(int,int)> tache1(somme_entre);

 

std::packaged_task<int(int, int)> tache2(somme_entre);

 

 

 

std::future<int> f1(tache1.get_future());

 

std::future<int> f2(tache2.get_future());

 

 

 

std::thread t1{ std::move(tache1), 1, 51 };

 

std::thread t2{ std::move(tache2), 51, 100 };

 

 

 

int ret = f1.get() + f2.get();

 

 

 

std::cout << "somme totale " << ret << std::endl;

 

L’intérêt de ce genre de code est que le développeur se focalise sur sa tâche, et non sur la mécanique du thread et de la synchronisation. Le « future » est la variable telle que l’appelant l’attend et le correspondant pour la tâche appelée est la « promise ».

 

Finalement, nous pouvons simplifier ces appels en utilisant std ::async, qui va réduire encore la complexité du code et finalement nous permettre de nous concentrer sur le cœur du problème :

 

auto future1 = std::async(somme_entre, 1, 51);

 

auto future2 = std::async(somme_entre, 51, 100);

 

 

 

int res = future1.get() + future2.get();

 

 

 

std::cout << "somme totale " << res << std::endl;

 

 

 

Conclusion

 

 

Pour conclure, le C++11 apporte son lot de nouveautés pour la maîtrise du parallélisme. Nous n’avons pas ici détailler les différentes classes et fonctions offertes par la bibliothèque standard, mais nous avons un aperçu de ce qu’il est possible de faire. Si possible, utiliser la notion de tâche, sinon, se rabattre sur les threads et finalement utiliser les éléments bas niveau. Plus on reste à haut niveau, plus le code est clair et simple.