🔝 Retour au Sommaire
std::mutex est la primitive de synchronisation la plus utilisée en C++. Son rôle est d'assurer l'exclusion mutuelle : à tout instant, un seul thread peut détenir le verrou. Tous les autres threads qui tentent de l'acquérir sont bloqués (suspendus par le scheduler) jusqu'à ce que le propriétaire le libère.
Le header <mutex> expose le type std::mutex ainsi que ses variantes. Sous Linux, l'implémentation repose sur les futex (fast userspace mutex) du noyau : l'acquisition d'un mutex non-contenu se fait entièrement en espace utilisateur (quelques nanosecondes), et l'appel système n'intervient que lorsqu'un thread doit effectivement attendre.
L'interface de std::mutex est volontairement minimale :
#include <mutex>
std::mutex mtx;
mtx.lock(); // Acquiert le verrou (bloque si déjà pris)
// ... section critique ...
mtx.unlock(); // Libère le verrou
bool ok = mtx.try_lock(); // Tente d'acquérir sans bloquer
// Retourne true si acquis, false sinonAppeler lock() sur un mutex a deux issues possibles :
- Le mutex est libre : le thread l'acquiert immédiatement et continue son exécution. C'est le fast path, entièrement en espace utilisateur.
- Le mutex est déjà détenu par un autre thread : le thread appelant est suspendu par le noyau. Il sera réveillé automatiquement quand le mutex sera libéré.
unlock() libère le verrou et réveille l'un des threads en attente (s'il y en a). L'ordre de réveil n'est pas garanti par le standard — en pratique, sur Linux, c'est approximativement FIFO, mais vous ne devez pas vous y fier.
try_lock() tente d'acquérir le mutex sans bloquer. Si le mutex est libre, il est acquis et la fonction retourne true. S'il est déjà détenu, elle retourne immédiatement false sans suspendre le thread.
C'est utile dans les scénarios où un thread a du travail alternatif à faire si la ressource n'est pas disponible :
#include <mutex>
#include <print>
std::mutex mtx;
int shared_data = 0;
void opportunistic_update(int value) {
if (mtx.try_lock()) {
shared_data = value;
mtx.unlock();
std::println("Mise à jour effectuée");
} else {
std::println("Mutex occupé, on passe à autre chose");
// Faire un travail alternatif...
}
}Utiliser lock() et unlock() directement est possible mais fortement déconseillé en code de production. Le problème est la fragilité face aux exceptions et aux retours anticipés :
#include <mutex>
#include <stdexcept>
#include <vector>
std::mutex mtx;
std::vector<int> data;
void fragile_push(int value) {
mtx.lock();
// Si validate() lève une exception, unlock() n'est jamais appelé.
// → Le mutex reste verrouillé indéfiniment → deadlock.
validate(value);
data.push_back(value);
// Si on ajoute un 'return' ici plus tard (refactoring), idem.
mtx.unlock();
}Il suffit d'un seul chemin d'exécution qui saute le unlock() — exception, return anticipé, break, continue — pour créer un deadlock. Et ce genre de bug est presque invisible à la relecture.
⚠️ Règle absolue : n'appelez jamaislock()etunlock()directement sur un mutex. Utilisez toujours un wrapper RAII (std::lock_guard,std::unique_lock, oustd::scoped_lock). Les sous-sections 21.2.2 à 21.2.4 couvrent ces wrappers en détail.
Pour les exemples qui suivent dans cette section, nous utilisons le lock/unlock manuel uniquement pour illustrer le fonctionnement interne du mutex. Tout code réel doit utiliser les wrappers RAII.
Reprenons l'exemple de la data race présenté en 21.2 et protégeons-le avec un mutex :
#include <thread>
#include <mutex>
#include <print>
std::mutex mtx;
int counter = 0;
void increment() {
for (int i = 0; i < 1'000'000; ++i) {
mtx.lock();
++counter; // Section critique : un seul thread à la fois
mtx.unlock();
}
}
int main() {
std::thread t1(increment);
std::thread t2(increment);
t1.join();
t2.join();
std::println("counter = {}", counter); // Toujours 2'000'000
}Le résultat est maintenant déterministe. Mais notez que cette approche — verrouiller et déverrouiller un mutex à chaque incrément — est très inefficiente. Pour un simple compteur, std::atomic<int> (section 21.4) est bien plus approprié. Les mutex sont pertinents quand la section critique est plus complexe qu'une seule opération atomique.
std::mutex est non-récursif : si un thread tente de verrouiller un mutex qu'il détient déjà, le comportement est indéfini. En pratique sur la plupart des implémentations Linux, cela provoque un deadlock — le thread s'attend lui-même indéfiniment.
std::mutex mtx;
void outer() {
mtx.lock();
inner(); // ❌ Comportement indéfini : inner() tente de reverrouiller mtx
mtx.unlock();
}
void inner() {
mtx.lock(); // Le thread détient déjà mtx → deadlock ou UB
// ...
mtx.unlock();
}Si vous avez légitimement besoin de verrouillage récursif, utilisez std::recursive_mutex (voir plus bas). Mais dans la majorité des cas, un verrouillage récursif est le signe d'une conception fragile qu'il vaut mieux refactorer.
Un std::mutex ne peut être ni copié, ni déplacé. Il représente une ressource système (un futex sous Linux), et il n'a de sens qu'à une adresse mémoire fixe. Les threads en attente se réfèrent à cette adresse précise.
std::mutex mtx;
// std::mutex mtx2 = mtx; // ❌ Erreur de compilation
// std::mutex mtx3 = std::move(mtx); // ❌ Erreur de compilationConséquence pratique : si une classe contient un std::mutex membre, cette classe ne peut pas être copiée ni déplacée (à moins de définir explicitement les opérations spéciales en excluant le mutex de la copie/déplacement).
Le standard C++ ne restreint pas quel thread appelle unlock(). Techniquement, un thread pourrait déverrouiller un mutex verrouillé par un autre thread, bien que cela soit un comportement indéfini avec std::mutex. En pratique, vous ne devriez jamais faire cela — le thread qui verrouille est celui qui déverrouille, et les wrappers RAII garantissent cette discipline automatiquement.
Le standard fournit des variantes adaptées à des besoins spécifiques. Elles partagent la même philosophie mais ajoutent des capacités supplémentaires.
Permet au même thread de verrouiller le mutex plusieurs fois. Le mutex n'est libéré que lorsque le nombre d'unlock() correspond au nombre de lock().
#include <mutex>
#include <print>
std::recursive_mutex rmtx;
void recursive_function(int depth) {
rmtx.lock();
std::println("Profondeur {}", depth);
if (depth > 0) {
recursive_function(depth - 1); // Re-verrouille le même mutex — OK
}
rmtx.unlock();
}
⚠️ std::recursive_mutexa un coût supérieur àstd::mutexcar il doit maintenir un compteur de récursion et identifier le thread propriétaire. Plus important, il masque souvent un problème de design. Si vous pensez en avoir besoin, demandez-vous d'abord :
- La section critique peut-elle être restructurée pour séparer la logique interne (sans verrou) de l'interface publique (avec verrou) ?
- Faut-il extraire la partie protégée dans une méthode privée appelée une seule fois sous verrou ?
Dans la grande majorité des cas, la réponse est oui.
Ajoute la possibilité d'attendre le verrou pendant une durée limitée, plutôt que de bloquer indéfiniment :
#include <mutex>
#include <chrono>
#include <print>
std::timed_mutex tmtx;
void cautious_operation() {
using namespace std::chrono_literals;
// try_lock_for : attend au maximum la durée spécifiée
if (tmtx.try_lock_for(100ms)) {
std::println("Verrou acquis");
// ... section critique ...
tmtx.unlock();
} else {
std::println("Timeout : le verrou n'a pas été obtenu en 100ms");
}
// try_lock_until : attend jusqu'à un instant absolu
auto deadline = std::chrono::steady_clock::now() + 500ms;
if (tmtx.try_lock_until(deadline)) {
// ... section critique ...
tmtx.unlock();
}
}Les timeouts sont utiles pour éviter les deadlocks dans des systèmes où la latence maximale est contrainte (systèmes temps réel souple, health checks). Attention cependant : un timeout déclenché n'est pas une solution au deadlock — c'est un pansement. Il faut investiguer la cause racine.
Il existe aussi std::recursive_timed_mutex, qui combine récursivité et timeout.
Le reader-writer lock : plusieurs threads peuvent détenir le verrou en lecture simultanément, mais un seul thread peut le détenir en écriture, et exclusivement.
#include <shared_mutex>
#include <mutex>
#include <string>
#include <map>
#include <print>
class ThreadSafeCache {
mutable std::shared_mutex mtx_;
std::map<std::string, std::string> cache_;
public:
std::string get(const std::string& key) const {
// Verrou partagé : plusieurs lecteurs simultanés
std::shared_lock lock(mtx_);
auto it = cache_.find(key);
return (it != cache_.end()) ? it->second : "";
}
void set(const std::string& key, const std::string& value) {
// Verrou exclusif : un seul écrivain, aucun lecteur
std::unique_lock lock(mtx_);
cache_[key] = value;
}
};std::shared_mutex est pertinent quand les lectures sont très fréquentes et les écritures rares (caches, tables de configuration, registres de services). Si les écritures sont fréquentes, le surcoût de std::shared_mutex par rapport à un simple std::mutex n'est pas justifié.
💡 Notez l'utilisation de
mutablesur le mutex. C'est une pratique courante : le mutex n'est pas une donnée logique de la classe, c'est un mécanisme de synchronisation. Le qualifiermutablepermet aux méthodesconst(les lectures) d'acquérir le verrou partagé.
L'une des difficultés de la programmation concurrente en C++ est que le lien entre un mutex et les données qu'il protège n'est pas exprimé dans le système de types. Rien dans le langage n'empêche d'accéder à data sans verrouiller mtx. Cette discipline repose entièrement sur le programmeur.
La convention la plus répandue est de suffixer le mutex avec _mutex ou _mtx et de le déclarer juste à côté des données qu'il protège :
class AccountManager {
std::mutex accounts_mtx_; // Protège accounts_
std::vector<Account> accounts_;
std::mutex log_mtx_; // Protège log_entries_
std::vector<std::string> log_entries_;
};Une approche plus robuste consiste à encapsuler données et mutex dans une structure dédiée, forçant l'accès via le verrouillage :
#include <mutex>
#include <vector>
template <typename T>
class Synchronized {
mutable std::mutex mtx_;
T data_;
public:
template <typename F>
auto with_lock(F&& func) -> decltype(func(data_)) {
std::lock_guard lock(mtx_);
return func(data_);
}
template <typename F>
auto with_lock(F&& func) const -> decltype(func(data_)) {
std::lock_guard lock(mtx_);
return func(data_);
}
};
// Utilisation
Synchronized<std::vector<int>> safe_vec;
safe_vec.with_lock([](std::vector<int>& v) {
v.push_back(42);
});
int size = safe_vec.with_lock([](const std::vector<int>& v) {
return static_cast<int>(v.size());
});Ce pattern rend impossible l'accès aux données sans acquérir le mutex. Le lambda reçoit une référence aux données protégées, et le verrou est automatiquement relâché à la fin du lambda. C'est plus lourd syntaxiquement mais beaucoup plus sûr.
Sur un système Linux moderne, le coût d'un std::mutex dépend du scénario :
Sans contention (le cas le plus fréquent dans un programme bien conçu) : l'acquisition et la libération d'un mutex se font via une opération atomique en espace utilisateur. Le coût est de l'ordre de 15-25 nanosecondes — comparable à un accès mémoire en cache L2.
Avec contention (plusieurs threads se battent pour le même mutex) : le thread qui ne peut pas acquérir le verrou effectue un appel système (futex_wait), est suspendu par le scheduler, puis réveillé (futex_wake). Ce chemin coûte de l'ordre de quelques microsecondes et implique un changement de contexte.
Pour cette raison :
- Ne verrouillez que ce qui a besoin de l'être.
- Gardez les sections critiques aussi courtes que possible.
- Si un mutex est constamment en contention (visible via
perf lockouperf stat), c'est un signal pour repenser l'architecture : réduire le partage, passer à des structures lock-free, ou partitionner les données.
void oubli() {
mtx.lock();
if (check_condition()) {
return; // ❌ unlock() jamais appelé → deadlock
}
// ...
mtx.unlock();
}Solution : utilisez std::lock_guard (section 21.2.2). Ce problème disparaît entièrement.
void double_lock() {
mtx.lock();
mtx.lock(); // ❌ Comportement indéfini (deadlock en pratique)
}Solution : restructurez le code pour que chaque mutex ne soit verrouillé qu'une fois par thread, ou utilisez std::recursive_mutex si c'est réellement nécessaire.
std::mutex mtx;
std::vector<int> shared_vec;
int& get_ref() {
std::lock_guard lock(mtx);
return shared_vec.back(); // ❌ La référence survit au verrou !
}
void consumer() {
int& ref = get_ref(); // Le mutex est déjà relâché ici
ref += 1; // ❌ Data race : accès sans protection
}Retourner une référence ou un itérateur vers des données protégées par un mutex est un piège courant. Le verrou ne protège la donnée que pendant sa durée de vie, pas après. Retournez des copies, ou utilisez le pattern with_lock présenté plus haut.
| Aspect | Détail |
|---|---|
| Header | <mutex>, <shared_mutex> |
| Opérations de base | lock(), unlock(), try_lock() |
| Coût sans contention | ~15-25 ns (futex userspace) |
| Copiable/Déplaçable | Non / Non |
| Récursif | Non (utiliser std::recursive_mutex si nécessaire) |
| Reader-writer | std::shared_mutex (C++17) |
| Timeout | std::timed_mutex, try_lock_for(), try_lock_until() |
| Règle d'or | Ne jamais appeler lock()/unlock() directement — toujours via un wrapper RAII |
À suivre : la section 21.2.2 présente
std::lock_guard, le wrapper RAII le plus simple pour exploiter un mutex de façon sûre et concise.