🔝 Retour au Sommaire
std::scoped_lock, introduit par C++17, est la réponse définitive au problème du verrouillage de plusieurs mutex. Il combine la simplicité RAII de lock_guard avec un algorithme intégré d'évitement de deadlock capable de verrouiller un nombre arbitraire de mutex simultanément.
#include <mutex>
std::mutex mtx1, mtx2, mtx3;
void multi_lock_operation() {
std::scoped_lock lock(mtx1, mtx2, mtx3); // Verrouille les 3 sans deadlock
// ... section critique utilisant les ressources des 3 mutex ...
} // Destruction → déverrouillage des 3 mutex dans l'ordre inverseUne seule ligne, aucun risque de deadlock, libération automatique à la sortie du scope. C'est exactement le pattern std::lock() + lock_guard(adopt_lock) que la section 21.2.2 présentait comme solution pré-C++17, mais condensé dans une abstraction propre.
Avant C++17, verrouiller plusieurs mutex de façon sûre nécessitait un idiome en trois étapes :
// Pré-C++17 : verbeux et fragile
void transfer_pre17(Account& from, Account& to, double amount) {
std::unique_lock lock1(from.mtx, std::defer_lock);
std::unique_lock lock2(to.mtx, std::defer_lock);
std::lock(lock1, lock2);
// ... opération ...
}
// Ou la variante avec adopt_lock :
void transfer_pre17_v2(Account& from, Account& to, double amount) {
std::lock(from.mtx, to.mtx);
std::lock_guard lock1(from.mtx, std::adopt_lock);
std::lock_guard lock2(to.mtx, std::adopt_lock);
// ... opération ...
}Ces deux formes fonctionnent, mais elles partagent le même défaut : si le programmeur oublie une étape, inverse l'ordre, ou mélange les mutex entre les lock et les wrappers, le résultat est un deadlock ou un mutex laissé verrouillé. L'idiome correct n'est pas exprimé en une seule construction atomique — il repose sur la discipline du programmeur.
std::scoped_lock élimine cette classe d'erreurs :
// C++17 : une seule ligne, impossible de se tromper
void transfer(Account& from, Account& to, double amount) {
std::scoped_lock lock(from.mtx, to.mtx);
// ... opération ...
}Quand std::scoped_lock reçoit plusieurs mutex, son constructeur utilise le même algorithme que std::lock() — typiquement un try-and-back-off :
- Verrouiller le premier mutex (bloquant).
- Tenter de verrouiller les suivants avec
try_lock()(non-bloquant). - Si l'un des
try_lock()échoue, tout relâcher et recommencer dans un ordre potentiellement différent.
Ce mécanisme garantit qu'aucune attente circulaire ne peut s'établir, quelle que soit la combinaison d'appels concurrents :
// Thread A
std::scoped_lock lock(mtx1, mtx2); // Acquiert mtx1, puis tente mtx2
// Thread B (simultanément)
std::scoped_lock lock(mtx2, mtx1); // Acquiert mtx2, puis tente mtx1
// Sans algorithme d'évitement → deadlock classique
// Avec scoped_lock → l'un des deux relâche et réessaie → pas de deadlockL'ordre dans lequel vous listez les mutex dans le constructeur n'a aucune importance pour la correction. L'algorithme s'en charge. Cela dit, pour des raisons de performance (réduire le nombre de tentatives), utiliser un ordre cohérent dans tout le code base reste une bonne pratique.
std::scoped_lock fonctionne parfaitement avec un seul mutex. Dans ce cas, il se comporte exactement comme lock_guard — pas d'algorithme de deadlock, juste un lock() au constructeur et un unlock() au destructeur :
std::mutex mtx;
void single_mutex_operation() {
std::scoped_lock lock(mtx); // Équivalent à std::lock_guard lock(mtx)
// ... section critique ...
}Le compilateur spécialise le template pour un seul mutex et élimine tout surcoût lié à l'algorithme multi-mutex. Le code généré est identique à celui de lock_guard.
Certaines équipes adoptent la convention d'utiliser std::scoped_lock partout — y compris pour un seul mutex — par cohérence. L'avantage est qu'ajouter un second mutex plus tard ne nécessite aucun changement de type de wrapper :
// Version initiale
std::scoped_lock lock(data_mtx);
// Évolution : ajout d'un second mutex — seul le constructeur change
std::scoped_lock lock(data_mtx, log_mtx);Avec lock_guard, ce refactoring aurait nécessité de changer le type du wrapper et potentiellement la stratégie de verrouillage.
Un cas limite mais valide : std::scoped_lock peut être construit sans aucun argument. Il ne fait alors strictement rien — ni au constructeur, ni au destructeur :
std::scoped_lock lock; // No-opCe cas est utile en programmation générique, quand un template peut recevoir zéro ou N mutex selon le contexte :
template <typename... Mutexes>
void generic_operation(Mutexes&... mutexes) {
std::scoped_lock lock(mutexes...);
// Fonctionne avec 0, 1, 2, ... mutex
do_work();
}
// Appels valides :
generic_operation(); // Pas de verrouillage
generic_operation(mtx1); // Un mutex
generic_operation(mtx1, mtx2, mtx3); // Trois mutex Reprenons l'exemple classique du transfert entre comptes, qui illustre parfaitement le problème que scoped_lock résout :
#include <mutex>
#include <thread>
#include <vector>
#include <print>
struct Account {
std::string name;
double balance;
std::mutex mtx;
};
void transfer(Account& from, Account& to, double amount) {
// Un seul scoped_lock verrouille les deux comptes sans deadlock,
// quel que soit l'ordre des arguments
std::scoped_lock lock(from.mtx, to.mtx);
if (from.balance >= amount) {
from.balance -= amount;
to.balance += amount;
std::println("{} → {} : {:.2f}€", from.name, to.name, amount);
}
}
int main() {
Account alice{"Alice", 1000.0};
Account bob{"Bob", 1000.0};
Account charlie{"Charlie", 1000.0};
std::vector<std::thread> threads;
// Transferts croisés simultanés — aucun deadlock possible
for (int i = 0; i < 100; ++i) {
threads.emplace_back(transfer, std::ref(alice), std::ref(bob), 10.0);
threads.emplace_back(transfer, std::ref(bob), std::ref(alice), 10.0);
threads.emplace_back(transfer, std::ref(bob), std::ref(charlie), 5.0);
threads.emplace_back(transfer, std::ref(charlie), std::ref(alice), 5.0);
}
for (auto& t : threads) {
t.join();
}
double total = alice.balance + bob.balance + charlie.balance;
std::println("Soldes : Alice={:.2f}, Bob={:.2f}, Charlie={:.2f}",
alice.balance, bob.balance, charlie.balance);
std::println("Total = {:.2f} (attendu : 3000.00)", total);
}Quatre types de transferts croisés s'exécutent simultanément. Sans scoped_lock, il faudrait définir un ordre global de verrouillage (par exemple par adresse mémoire du mutex) et le respecter scrupuleusement dans chaque appel. Avec scoped_lock, l'algorithme s'en charge, et le code reste naturel.
Un autre scénario classique qui nécessite le verrouillage simultané de deux mutex — l'échange de contenu entre deux objets protégés :
#include <mutex>
#include <vector>
#include <algorithm>
class ProtectedBuffer {
mutable std::mutex mtx_;
std::vector<int> data_;
public:
void push(int value) {
std::scoped_lock lock(mtx_);
data_.push_back(value);
}
friend void swap(ProtectedBuffer& a, ProtectedBuffer& b) {
if (&a == &b) return; // Pas de self-swap
std::scoped_lock lock(a.mtx_, b.mtx_);
std::swap(a.data_, b.data_);
}
};💡 La vérification
&a == &best importante. Si les deux références pointent vers le même objet,scoped_locktenterait de verrouiller deux fois le même mutex. Avec unstd::mutexnon-récursif, c'est un comportement indéfini. Vérifiez toujours l'identité quand les deux arguments peuvent désigner le même objet.
std::scoped_lock accepte des mutex de types différents grâce à son template variadique :
#include <mutex>
#include <shared_mutex>
std::mutex regular_mtx;
std::shared_mutex rw_mtx;
std::recursive_mutex rec_mtx;
void heterogeneous_locking() {
// Verrouille trois mutex de types différents simultanément
std::scoped_lock lock(regular_mtx, rw_mtx, rec_mtx);
// regular_mtx : lock exclusif
// rw_mtx : lock exclusif (pas shared)
// rec_mtx : lock exclusif
}Quand std::scoped_lock verrouille un std::shared_mutex, il acquiert toujours le verrou exclusif (écriture). Si vous avez besoin d'un verrou partagé (lecture), utilisez std::shared_lock séparément.
Comme lock_guard, scoped_lock verrouille du constructeur au destructeur, sans possibilité de relâcher manuellement :
void no_early_release() {
std::scoped_lock lock(mtx1, mtx2);
// Impossible de relâcher mtx2 ici tout en gardant mtx1
}Si vous avez besoin de déverrouiller un mutex avant l'autre, utilisez des unique_lock séparés avec std::lock().
std::scoped_lock n'est ni copiable, ni déplaçable. Il est ancré dans son scope, comme lock_guard. Si vous avez besoin de transférer la propriété d'un verrou, utilisez std::unique_lock.
Comme lock_guard, scoped_lock n'est pas compatible avec std::condition_variable::wait(), qui exige un std::unique_lock<std::mutex>.
scoped_lock n'expose pas de tentative non-bloquante ni de timeout au niveau de son interface. La construction verrouille toujours les mutex de façon bloquante (même si l'algorithme interne utilise try_lock pour éviter les deadlocks). Si vous avez besoin d'un verrouillage conditionnel, utilisez std::unique_lock avec std::defer_lock et try_lock().
Après avoir couvert les trois wrappers RAII, voici le guide de décision complet :
Besoin de verrouiller plusieurs mutex ?
├── Oui → std::scoped_lock
└── Non
├── Besoin de condition_variable, unlock anticipé,
│ déplacement, ou timeout ?
│ ├── Oui → std::unique_lock
│ └── Non → std::lock_guard ou std::scoped_lock
└── (les deux sont équivalents pour un seul mutex)
| Critère | lock_guard |
unique_lock |
scoped_lock |
|---|---|---|---|
| Standard | C++11 | C++11 | C++17 |
| Mutex multiples | ✗ | Via std::lock() |
✓ natif |
| Déverrouillage anticipé | ✗ | ✓ | ✗ |
| Déplaçable | ✗ | ✓ | ✗ |
condition_variable |
✗ | ✓ | ✗ |
| Timeout | ✗ | ✓ | ✗ |
try_to_lock |
✗ | ✓ | ✗ |
| Surcoût vs lock/unlock | Zéro | Minimal | Zéro (1 mutex) |
| Intention exprimée | Verrou simple | Verrou flexible | Verrou multi-mutex |
En C++17 et au-delà, le choix se résume souvent à :
std::scoped_lockcomme choix par défaut pour tout verrouillage simple ou multi-mutex.std::unique_lockdès que vous avez besoin de flexibilité (condition variables, déverrouillage anticipé, déplacement, timeout).
std::lock_guard reste parfaitement valide mais n'offre rien de plus que std::scoped_lock avec un seul mutex. Le conserver dans du code existant est tout à fait acceptable ; dans du nouveau code, scoped_lock est préférable par sa généralité.
| Aspect | Détail |
|---|---|
| Header | <mutex> |
| Standard | C++17 |
| Construction | Verrouille N mutex simultanément avec évitement de deadlock |
| Destruction | Déverrouille tous les mutex (ordre inverse) |
| Nombre de mutex | 0, 1, 2, ... N (template variadique) |
| Types hétérogènes | Oui (std::mutex, std::shared_mutex, etc.) |
| Copiable / Déplaçable | Non / Non |
| Déverrouillage anticipé | Non |
condition_variable |
Non compatible |
| Avec 1 mutex | Identique à lock_guard (aucun surcoût) |
| Avec 0 mutex | No-op (utile en code générique) |
À suivre : maintenant que les données partagées sont protégées, la section 21.3 aborde un problème complémentaire — comment un thread peut-il attendre efficacement qu'une condition soit remplie par un autre thread ? C'est le rôle de
std::condition_variable.