La formule proposée ici est de Herb Sutter. J'utilise parfois, pour des fins semblables, de la programmation par preuves, une technique plus stricte mais aussi plus complexe.
J'utilise CTAD de C++ 17 dans le code qui suit, pour alléger l'écriture; si votre compilateur n'est pas à jour, remplacez lock_guard par lock_guard<mutex> dans les exemples.
Supposons que l'on souhaite écrire des messages complexes sur un flux, mais de manière telle que chaque message apparaisse au complet, sans interférence ou entremêlement d'autres messages.
Les flux standards de C++, depuis C++ 11, sont tels que chaque sortie, prise individuellement, se fera sans entremêlement :
// ...
#include <thread>
#include <iostream>
#include <string_view>
#include <vector>
using namespace std;
int main() {
constexpr auto MESSAGE = "J'aime mon prof il est tres chouette\n"sv;
enum { NTHREADS = 10, NTESTS = 100 };
vector<thread> v;
for (int i = 0; i < NTHREADS; ++i)
v.emplace_back([] {
for (int i = 0; i < NTESTS; ++i)
cout << MESSAGE;
});
for (auto & th : v) th.join();
}
Ici, chaque message sera projeté sur std::cout sans interférence, car tous les threads écrivent sur le même flux et n'écrivent qu'une seule chose. Avant C++ 11, pour du code semblable (avec threads propres à chaque plateforme, évidemment), les caractères de chaque chaîne auraient été entremêlés et l'affichage aurait été pour le moins incohérent. Il faut comprendre ici qu'afficher une chaîne de caractères signifie afficher chacun des caractères un à un; sans synchronisation interne du flux, un changement de contexte permettant de passer d'un thread à l'autre peut survenir a tout moment.
Si nous changeons légèrement l'écriture, par contre, la situation se complique :
// ...
#include <thread>
#include <iostream>
#include <string_view>
#include <vector>
using namespace std;
int main() {
constexpr auto MESSAGE = "J'aime mon prof il est tres chouette"sv;
enum { NTHREADS = 10, NTESTS = 100 };
vector<thread> v;
for (int i = 0; i < NTHREADS; ++i)
v.emplace_back([i] {
for (int j = 0; j < NTESTS; ++j)
cout << "Thread " << i << ", message " << j << " : " << MESSAGE << endl;
});
for (auto & th : v) th.join();
}
L'écriture sur un flux n'est qu'un exemple (visuel) parmi plusieurs du problème plus général de synchroniser un groupe d'opérations.
Remarquez que i est capturé par copie dans l'expression λ. Que se serait-il produit si nous avions réalisé une capture par référence?
Ici, chaque thread écrit une séquence de valeurs sur le flux, or si chaque élément de la séquence s'affiche sans interférence, le message pris dans son ensemble n'offre pas cette garantie, n'ayant au fond aucune existence en propre.
Une solution à un tel problème est de confier le flux en sortie à un moniteur, entité responsable d'y synchroniser les accès, et de passer au moniteur une opération complète destinée à être exécutée sur ce flux.
// ...
#include <thread>
#include <iostream>
#include <string_view>
#include <vector>
#include <mutex>
using namespace std;
template <class T>
class monitor {
mutable mutex m;
T & obj;
public:
monitor(T & obj) : obj{ obj } {
}
template <class F>
auto operator()(F f) -> decltype(f(obj)) {
lock_guard _ { m };
return f(obj);
}
template <class F>
auto operator()(F f) const -> decltype(f(obj)) {
lock_guard _ { m };
return f(obj);
}
};
int main() {
constexpr auto MESSAGE = "J'aime mon prof il est tres chouette"sv;
enum { NTHREADS = 10, NTESTS = 100 };
vector<thread> v;
monitor<ostream> sync_cout{ cout };
for (int i = 0; i < NTHREADS; ++i)
v.emplace_back([i,&sync_cout] {
for (int j = 0; j < NTESTS; ++j)
sync_cout([i,j](ostream &os) {
os << "Fil " << i << ", message " << j << " : " << MESSAGE << endl;
});
});
for (auto & th : v) th.join();
}
Avec cet ajout, chaque écriture sur std::cout est remplacée par une injection d'une opération (sous la forme d'une expression λ) qui se fera de manière synchronisée sur le flux de par le recours, par le moniteur, à un mutex.
Raffinement syntaxique possible : si l'écriture monitor<ostream> sync_cout{ cout } vous semble déplaisante (devoir spécifier le type de l'objet contenu dans le moniteur, alors que ce type est clairement déterminé par le paramètre reçu à la construction), il est possible d'alléger l'écriture en suppléant une fonction génératrice, par exemple make_monitor() dans l'exemple ci-dessous :
// ...
#include <thread>
#include <iostream>
#include <string_view>
#include <vector>
#include <mutex>
using namespace std;
template <class T>
class monitor {
mutable mutex m;
T & obj;
public:
monitor(T & obj) : obj{ obj } {
}
monitor(monitor && autre) : m{ std::move(autre.m) }, obj{ autre.obj } {
}
template <class F>
auto operator()(F f) -> decltype(f(obj)) {
lock_guard _ { m };
return f(obj);
}
template <class F>
auto operator()(F f) const -> decltype(f(obj)) {
lock_guard _ { m };
return f(obj);
}
};
template <class T>
monitor<T> make_monitor(T &obj) {
return monitor<T>{obj};
}
int main() {
constexpr auto MESSAGE = "J'aime mon prof il est tres chouette"sv;
enum { NTHREADS = 10, NTESTS = 100 };
vector<thread> v;
auto sync_cout = make_monitor(cout);
for (int i = 0; i < NTHREADS; ++i)
v.emplace_back([i,&sync_cout] {
for (int j = 0; j < NTESTS; ++j)
sync_cout([i,j](ostream &os) {
os << "Fil " << i << ", message " << j << " : " << MESSAGE << endl;
});
});
for (auto & th : v) th.join();
}
Ici, la fonction génératrice est susceptible d'utiliser le constructeur de mouvement, que nous avons pris soin d'ajouter. Voilà!.