Aller au contenu

Mutex, verrous et sections critiques

Maîtrisez les primitives de synchronisation : mutex, lock_guard, unique_lock, scoped_lock, shared_mutex. Apprenez à protéger les données partagées, à éviter les interblocages et à distinguer data race et condition de concurrence.

Pourquoi la synchronisation est importante

Lorsque plusieurs threads accèdent à la même mémoire et qu'au moins l'un d'eux écrit, vous avez une data race — une forme de comportement indéfini en C++. La norme ne se contente pas de dire « le résultat est imprévisible ». Elle dit que *tout peut arriver* : données corrompues, plantages, failles de sécurité, ou comportement apparemment correct qui casse dès que vous changez les options du compilateur.

Un mutex (exclusion mutuelle) est la primitive de synchronisation fondamentale. Un seul thread à la fois peut détenir le verrou d'un mutex. Tout autre thread qui tente de le verrouiller se bloque jusqu'à ce que le mutex soit libéré. En protégeant les données partagées avec un mutex, vous sérialisez les accès et éliminez les data races.

std::mutex et lock_guard

Le modèle le plus simple et le plus sûr est std::lock_guard — un wrapper RAII qui verrouille un mutex à sa construction et le déverrouille à sa destruction, même si une exception est levée.

lock_guard_demo.cpp
#include <iostream>
#include <thread>
#include <mutex>
#include <vector>

std::mutex mtx;
int shared_counter = 0;

void increment(int times) {
    for (int i = 0; i < times; ++i) {
        std::lock_guard<std::mutex> lock(mtx);  // RAII lock
        ++shared_counter;  // safe: only one thread at a time
    }  // lock released here automatically
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 10; ++i) {
        threads.emplace_back(increment, 10000);
    }
    for (auto& t : threads) {
        t.join();
    }
    std::cout << "Counter: " << shared_counter << "\n";
    // Always prints 100000 — no data race
    return 0;
}

unique_lock — un verrouillage flexible

std::unique_lock est plus flexible que lock_guard. Il permet le verrouillage différé, le verrouillage avec délai, le verrouillage/déverrouillage manuel, et il est requis par std::condition_variable.

unique_lock.cpp
#include <iostream>
#include <thread>
#include <mutex>
#include <chrono>

std::mutex mtx;

void try_lock_example() {
    // Deferred locking — don't lock yet
    std::unique_lock<std::mutex> lock(mtx, std::defer_lock);

    // Try to lock with a timeout
    if (lock.try_lock_for(std::chrono::milliseconds(100))) {
        std::cout << "Acquired lock\n";
        // ... do work ...
    } else {
        std::cout << "Could not acquire lock within 100ms\n";
    }
    // Automatically unlocks when lock goes out of scope
}

void manual_control() {
    std::unique_lock<std::mutex> lock(mtx);
    // ... critical section ...
    lock.unlock();  // release early if needed
    // ... non-critical work ...
    lock.lock();    // re-acquire
    // ... another critical section ...
}

scoped_lock — verrouiller plusieurs mutex sans interblocage (C++17)

Lorsque vous devez verrouiller plusieurs mutex simultanément, std::scoped_lock (C++17) utilise en interne un algorithme d'évitement des interblocages. Il remplace l'usage manuel de std::lock, source d'erreurs.

scoped_lock_bank.cpp
#include <mutex>
#include <thread>
#include <iostream>

struct BankAccount {
    std::mutex mtx;
    double balance = 1000.0;
};

void transfer(BankAccount& from, BankAccount& to, double amount) {
    // Lock both mutexes without risk of deadlock
    std::scoped_lock lock(from.mtx, to.mtx);

    if (from.balance >= amount) {
        from.balance -= amount;
        to.balance += amount;
        std::cout << "Transferred " << amount << "\n";
    }
}  // both mutexes released

int main() {
    BankAccount alice, bob;

    // These two transfers lock accounts in different order,
    // but scoped_lock prevents deadlock.
    std::thread t1(transfer, std::ref(alice), std::ref(bob), 100.0);
    std::thread t2(transfer, std::ref(bob), std::ref(alice), 50.0);

    t1.join();
    t2.join();

    std::cout << "Alice: " << alice.balance
              << ", Bob: " << bob.balance << "\n";
    return 0;
}

shared_mutex — verrou lecteurs-rédacteur (C++17)

Lorsque les lectures sont bien plus nombreuses que les écritures, std::shared_mutex autorise plusieurs lecteurs simultanés mais un accès exclusif pour l'écriture. Utilisez std::shared_lock pour les lecteurs et std::unique_lock pour les rédacteurs.

shared_mutex_cache.cpp
#include <shared_mutex>
#include <mutex>
#include <thread>
#include <iostream>
#include <map>
#include <string>

class ThreadSafeCache {
    mutable std::shared_mutex smtx_;
    std::map<std::string, int> data_;
public:
    int read(const std::string& key) const {
        std::shared_lock lock(smtx_);  // multiple readers OK
        auto it = data_.find(key);
        return it != data_.end() ? it->second : -1;
    }

    void write(const std::string& key, int value) {
        std::unique_lock lock(smtx_);  // exclusive access
        data_[key] = value;
    }
};
Piège

Une data race se produit lorsque deux threads accèdent simultanément au même emplacement mémoire, qu'au moins l'un d'eux écrit, et qu'il n'y a aucune synchronisation. En C++, c'est un comportement indéfini — pas simplement une « sortie non déterministe », mais un UB où tout peut arriver.

Une condition de concurrence (race condition) est un bogue logique de plus haut niveau, où la justesse du programme dépend du minutage. Même un code exempt de data races peut comporter des conditions de concurrence (par exemple, un « vérifier puis agir » sur une map concurrente). Les mutex éliminent les data races ; éliminer les conditions de concurrence exige une conception soignée de vos algorithmes concurrents.

Bonne pratique

Utilisez toujours des wrappers de verrou RAII (lock_guard, scoped_lock, unique_lock) plutôt que des appels bruts à mutex::lock()/unlock(). Gardez les sections critiques aussi courtes que possible — si vous pouvez l'éviter, n'effectuez jamais d'E/S, d'allocations ou d'appels bloquants en détenant un verrou. Lorsque vous avez besoin de plusieurs mutex, utilisez std::scoped_lock pour éviter les interblocages. Si vous devez verrouiller manuellement, verrouillez toujours dans un ordre global cohérent.

Points clés
  • Une data race est un comportement indéfini — utilisez un mutex pour protéger chaque variable modifiable partagée
  • std::lock_guard est le verrou RAII le plus simple ; préférez-le pour les sections critiques sans subtilité
  • std::unique_lock apporte de la flexibilité : verrouillage différé, verrouillage avec délai et verrouillage/déverrouillage manuel
  • std::scoped_lock (C++17) verrouille plusieurs mutex simultanément sans interblocage
  • std::shared_mutex (C++17) autorise plusieurs lecteurs simultanés mais des rédacteurs exclusifs
  • Gardez les sections critiques courtes et ne détenez jamais un verrou pendant que vous attendez un autre thread

Quiz — Testez vos connaissances

(20 XP)

1. Quel est le principal avantage de `std::scoped_lock` par rapport à l'appel manuel de `lock()` sur plusieurs mutex ?

2. Quel type de verrou faut-il utiliser avec `std::condition_variable` ?

3. En C++, quelle est la conséquence d'une data race ?