Skip to content

Latest commit

 

History

History
422 lines (298 loc) · 15.3 KB

File metadata and controls

422 lines (298 loc) · 15.3 KB

🔝 Retour au Sommaire

21.2.1 std::mutex

Le verrou fondamental

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.


Interface de base

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 sinon

lock()

Appeler 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()

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()

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...
    }
}

Utilisation directe : pourquoi c'est risqué

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 jamais lock() et unlock() directement sur un mutex. Utilisez toujours un wrapper RAII (std::lock_guard, std::unique_lock, ou std::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.


Résoudre le problème du compteur

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.


Propriétés fondamentales de std::mutex

Non-récursif

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.

Non-copiable, non-déplaçable

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 compilation

Consé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).

Pas de notion de propriétaire

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.


Variantes de mutex

Le standard fournit des variantes adaptées à des besoins spécifiques. Elles partagent la même philosophie mais ajoutent des capacités supplémentaires.

std::recursive_mutex

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_mutex a un coût supérieur à std::mutex car 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.

std::timed_mutex

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.

std::shared_mutex (C++17)

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 mutable sur 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 qualifier mutable permet aux méthodes const (les lectures) d'acquérir le verrou partagé.


Associer un mutex à ses données

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.

Convention de nommage

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_;
};

Pattern struct protégée

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.


Coût d'un mutex

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 lock ou perf stat), c'est un signal pour repenser l'architecture : réduire le partage, passer à des structures lock-free, ou partitionner les données.

Erreurs classiques

Oublier de déverrouiller

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.

Verrouiller deux fois le même mutex

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.

Protéger l'accès mais pas la portée

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.


Résumé

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.

⏭️ std::lock_guard