CodeGym /Cours /C++ SELF /Encapsulation de la synchronisation : « mutex à l'intérie...

Encapsulation de la synchronisation : « mutex à l'intérieur de la classe »

C++ SELF
Niveau 69, Leçon 4
Disponible

1. Mutex à l'intérieur de la classe

Si vous venez d'apprendre à utiliser std::lock_guard, un désir naturel apparaît : « OK, je vais juste ajouter un std::mutex à côté des données et le verrouiller quand il faut ». Le problème, c'est que « quand il faut » n'est pas une géographie mais une discipline. Quand le mutex vit à l'extérieur, l'accès aux données se disperse dans le projet, et à un moment donné quelqu'un (souvent vous, mais dans une semaine) oubliera de prendre le lock. Aujourd'hui nous construisons une habitude : la synchronisation fait partie de l'implémentation de la classe, pas d'un accord tacite entre personnes.

Imaginez un vrai projet : plusieurs fonctions, plusieurs fichiers, quelqu'un a ajouté une « petite optimisation », quelqu'un a fait un « accès rapide au vecteur » via une référence… et voilà une race de données qui n'apparaît que le vendredi après-midi. Un mutex externe augmente presque toujours la probabilité d'un tel scénario.

Anti-exemple : données séparées, mutex séparé

Comme anti-exemple prenons une situation qui a l'air innocente. Nous avons un stockage de tâches (partie de notre application console pédagogique), et plusieurs threads ajoutent des tâches. Le novice fait souvent ceci : données séparées, mutex séparé, et l'accès aux données est « apparemment » sous mutex.

#include <mutex>
#include <string>
#include <vector>

std::vector<std::string> g_tasks;
std::mutex g_tasks_mutex;

À première vue, tout va bien. Mais désormais n'importe quelle fonction du projet voit g_tasks. Et même si vous avez juré de « toujours verrouiller », le compilateur ne vérifiera pas votre serment. Le code suivant est une cause typique de bugs intermittents :

#include <string>
#include <vector>

extern std::vector<std::string> g_tasks;

void debug_print_first() {
    if (!g_tasks.empty()) {                 // DANGEREUX : lecture sans synchronisation
        // ...
    }
}

Il n'y a aucun lock_guard ici. Et le problème n'est même pas que l'auteur soit mauvais. Le problème est que l'interface est mauvaise : elle permet de se tromper trop facilement.

Avec std::mutex c'est un peu comme la ceinture de sécurité : elle fonctionne mieux quand elle est intégrée à la structure plutôt que posée à côté « au cas où ». Dans la bibliothèque standard le fait qu'il existe mutex::lock() est un petit bloc de construction bas-niveau, mais la façon dont nous l'utilisons en code applicatif doit être disciplinée (et de préférence auto-disciplinante).

2. Encapsulation : « données + mutex = un objet »

L'idée clé de cette leçon est très simple (ce qui est rare et agréable) : si une classe possède des données, elle doit aussi posséder la règle d'accès à ces données. Et la règle d'accès en multithreading, c'est la synchronisation.

On peut schématiser cela ainsi :

flowchart LR
    A[Code externe] -->|appelle des méthodes| B[Classe]
    B -->|prend le lock à l'intérieur| M[(mutex)]
    B -->|protège| D[(données)]

Le code externe ne doit pas « se souvenir » de quand verrouiller. Il doit simplement utiliser les méthodes de la classe. Alors :

  1. la classe garantit ses invariants,
  2. tous les accès suivent un protocole unique,
  3. la probabilité d'« oublier le lock » diminue fortement.

4. Mini-composant : TaskLog thread-safe

Développons un contexte pédagogique unique : nous avons une application console, et nous voulons écrire des événements depuis plusieurs threads (par exemple « le thread n°1 a ajouté une tâche », « le thread n°2 a traité une tâche »). La sortie sur std::cout a déjà été protégée par un mutex dans les leçons précédentes, mais maintenant faisons quelque chose de plus utile : on stocke les messages en mémoire, puis on les affiche en une fois.

Commençons par le modèle : juste une chaîne. Nous ne compliquons pas la struct pour que le focus reste sur la synchronisation.

Version « comme il ne faut pas » : la classe livre son intérieur

D'abord faisons mal exprès, pour que l'esprit ressente la différence.

#include <string>
#include <vector>

class BadTaskLog {
public:
    std::vector<std::string>& data() { return data_; } // fuite de l'intérieur
private:
    std::vector<std::string> data_;
};

Ça a l'air pratique : « tu veux — ajoute, tu veux — trie ». Mais en multithreading c'est exactement comme laisser la porte de l'appartement ouverte en collant un papier « merci de ne pas entrer ». Formellement il y a une demande, mais il n'y a pas de protection.

Dès que le code externe obtient un std::vector<std::string>&, la classe ne contrôle plus :

  • qui et quand modifie data_,
  • sous quel mutex (si tant est qu'il y en ait un),
  • si quelqu'un ne garde pas une référence vers un élément qui sera invalidé par un push_back.

Version « comme il faut » : TaskLog synchronise lui-même

Faisons maintenant correctement : le mutex est private, les opérations sont public, toutes les modifications et lectures passent par des méthodes.

#include <mutex>
#include <string>
#include <vector>

class TaskLog {
public:
    void add(std::string msg) {
        std::lock_guard<std::mutex> lock(m_);
        data_.push_back(std::move(msg));
    }

private:
    std::mutex m_;
    std::vector<std::string> data_;
};

Remarquez : la méthode est courte, le verrou est tenu exactement le temps de push_back. Le code externe ne peut plus « accidentellement » modifier data_ directement.

Et ici se pose la question suivante (très pertinente) : « Comment lire le log ? »

Lecture : snapshot() et pourquoi mutable est nécessaire ici

Les débutants sont souvent sincèrement surpris : « Je lis juste ! Je ne modifie rien ! » Le problème est que en multithreading la lecture participe aux races de données tout autant que l'écriture, si un autre thread écrit en même temps.

Il nous faut donc une méthode de lecture. Mais nous ne voulons pas retourner une référence. Que faire ? Le moyen le plus simple et sûr pour du code pédagogique est de retourner une copie (un snapshot).

#include <mutex>
#include <string>
#include <vector>

class TaskLog {
public:
    void add(std::string msg) {
        std::lock_guard<std::mutex> lock(m_);
        data_.push_back(std::move(msg));
    }

    std::vector<std::string> snapshot() const {
        std::lock_guard<std::mutex> lock(m_);
        return data_; // copie
    }

private:
    mutable std::mutex m_;
    std::vector<std::string> data_;
};

Il y a immédiatement deux points importants.

Premier point : la méthode snapshot() est const, parce que logiquement elle ne modifie pas « l'état logique du log » (les messages). Mais elle prend un verrou, et le verrou modifie l'état de m_. C'est pourquoi nous avons déclaré mutable std::mutex m_;.

Deuxième point : oui, copier un std::vector<std::string> n'est pas gratuit. Mais c'est le prix honnête d'une interface simple et sûre. Dans un projet réel vous réfléchirez à la fréquence des appels et aux volumes de données, mais l'idée architecturale reste : ne donnez pas l'accès externe à l'état interne que vous devez protéger.

Le mot mutable sonne comme si vous aviez contourné le compilateur et que les règles du const ne s'appliquaient plus. En réalité l'idée est plus subtile : le const en C++ est un contrat sur l'état logique de l'objet, pas sur chaque bit de mémoire.

Le mutex est un mécanisme de synchronisation qui ne fait pas partie des « données sémantiques » (messages du log, solde d'un compte, liste de tâches). Donc dans une méthode const nous avons le droit de nous synchroniser : nous ne changeons pas le log « en tant que log », nous assurons juste un accès correct.

Règle de bon goût : mutable en code multithread apparaît le plus souvent sur des mutex, et non sur des champs « utiles ». Si vous commencez à rendre des données mutable pour les modifier dans des méthodes const, c'est un signe de mauvaise architecture.

5. Pourquoi un mutex externe casse les invariants de la classe

Supposons que quelqu'un propose l'alternative : « Faisons en sorte que TaskLog stocke les données, et que le mutex soit à l'extérieur, et que l'utilisateur verrouille lui-même ». À première vue, c'est « flexible ». En pratique, cela signifie presque toujours que les invariants de la classe ne sont plus démontrables.

Un invariant est ce que la classe promet de maintenir vrai. Par exemple, « le vecteur de messages n'est pas lu en même temps qu'il est modifié » est aussi un invariant, simplement multithread.

Quand le mutex est externe, vous vous retrouvez avec la situation suivante :

  • la méthode add() peut être appelée sans lock (parce que la classe ne locke pas),
  • la méthode snapshot() peut être appelée sans lock,
  • et quelqu'un peut prendre une référence vers les données et contourner toutes les règles.

Autrement dit la classe ne « possède » plus son état. Elle devient un paquet de champs autour desquels doit exister une discipline magique dans la tête des développeurs. La magie, comme on le sait, est difficile à déboguer.

Opérations composées : n'obligez pas l'utilisateur à « verrouiller à l'extérieur »

Point subtil maintenant. Parfois l'utilisateur a vraiment besoin d'une opération « plusieurs actions atomiques du point de vue métier ». Par exemple : « ajoute un message et retourne la taille actuelle du log, mais de sorte qu'entre ces deux actions personne ne puisse s'intercaler ».

Si nous interdisons le verrou externe (et nous l'interdisons), ces opérations doivent être des méthodes de la classe.

#include <mutex>
#include <string>
#include <vector>

class TaskLog {
public:
    std::size_t add_and_size(std::string msg) {
        std::lock_guard<std::mutex> lock(m_);
        data_.push_back(std::move(msg));
        return data_.size();
    }

private:
    std::mutex m_;
    std::vector<std::string> data_;
};

La méthode est courte mais très importante conceptuellement : elle « colle » deux actions dans une seule section critique. Et surtout : l'utilisateur ne peut pas faire « la moitié bien ».

6. Plusieurs mutex dans une classe : prudence

Parfois la tentation apparaît : « Je vais mettre deux mutex : un pour data_, un pour autre chose ». Cela peut être justifié, mais pour un débutant cela complique la vie bien plus que cela n'accélère le programme.

Pourquoi ? Parce que deux mutex augmentent immédiatement le risque :

  • se tromper dans l'ordre d'acquisition,
  • appeler accidentellement une méthode qui prend le deuxième mutex alors qu'on tient le premier,
  • obtenir un deadlock à l'intérieur d'une même classe (oui, ça arrive, et c'est d'autant plus frustrant).

Règle pratique pour commencer : un objet — un mutex, tant que vous ne pouvez pas expliquer clairement pourquoi vous avez besoin d'un second.

7. Mini-demo : plusieurs threads écrivent dans TaskLog

Nous n'allons pas construire aujourd'hui une « file de tâches parfaite » (cela nécessiterait des mécanismes d'attente que nous évitons volontairement). Mais nous pouvons montrer l'essentiel : plusieurs threads appellent en toute sécurité les méthodes d'un même objet, sans rien savoir des mutex internes.

#include <thread>
#include <iostream>
#include <string>

int main() {
    TaskLog log;

    auto worker = [&](int id) {
        for (int i = 0; i < 3; ++i) {
            log.add("thread " + std::to_string(id) + ": step " + std::to_string(i));
        }
    };

    std::thread t1(worker, 1);
    std::thread t2(worker, 2);
    t1.join();
    t2.join();

    for (const auto& s : log.snapshot()) {
        std::cout << s << '\n';
    }
}

La sensation importante ici : les threads n'ont strictement pas besoin de savoir qu'il y a un std::mutex dans TaskLog. Ils utilisent simplement la classe comme une bibliothèque ordinaire. Voilà l'objectif de l'encapsulation.

8. Rappel : « avant / après » et pourquoi on ne donne pas de références

Il est parfois utile de voir la différence côte à côte — c'est comme un « avant/après » en publicité pour shampooing, sauf que notre shampooing est pour les bugs.

Architecture À quoi ça ressemble Ce qui tourne mal
Mutex externe data global/public, mutex à côté Facile d'oublier le lock, difficile de prouver la correction, le code externe peut contourner les règles
Mutex à l'intérieur de la classe private mutex, les méthodes verrouillent elles-mêmes La discipline d'accès est incorporée, les invariants sont protégés, l'utilisateur ne peut pas « accidentellement » violer le protocole

Quand vous retournez une référence/pointeur vers des données internes, vous donnez un accès qui vit plus longtemps que votre lock_guard.

C'est le problème : le verrouillage est toujours une garantie temporaire, limitée au scope. Une référence est une promesse « tu peux utiliser l'objet où tu veux ». Ces promesses sont en conflit.

Si vous voulez donner accès aux données, vous avez généralement trois options honnêtes.

Première option : le « snapshot » (comme snapshot()) — c'est-à-dire copier les données.

Deuxième option : fournir des méthodes opérationnelles séparées : add, erase, size, contains, etc., pour que l'utilisateur fasse ce dont il a besoin sans accès direct au conteneur.

Troisième option : exécuter un morceau de travail « sous le verrou » via un callback. Mais c'est plus dangereux pour un débutant, car on peut accidentellement appeler du code « étranger » sous lock, ce qui augmente le risque de deadlock et la durée de maintien du verrou. C'est pourquoi dans un style pédagogique nous nous limitons généralement aux deux premières options.

9. Erreurs typiques

Erreur n°1 : rendre std::mutex champ public « pour être pratique ».
Ainsi vous transformez la synchronisation en une convention externe. Le code externe peut oublier de prendre le lock, prendre le mauvais lock, garder le lock trop longtemps, ou utiliser des mutex différents pour les mêmes données. Au final, la correction de la classe cesse d'être une propriété de la classe — elle devient une propriété de l'humeur du programmeur.

Erreur n°2 : retourner des références/pointeurs vers des conteneurs internes.
Cela casse l'encapsulation plus fortement que de dire simplement « faites ce que vous voulez ». Une référence vit plus longtemps que le verrouillage, le code externe peut modifier les données sans synchronisation, garder des « liaisons » vers des éléments qui seront invalidés, et invoquer des méthodes du conteneur simultanément depuis plusieurs threads.

Erreur n°3 : ne pas synchroniser la lecture (« je ne modifie pas »).
Lire l'état partagé sans synchronisation peut participer à une race si un autre thread écrit. « Je lis seulement » n'est sûr que lorsqu'il est garanti qu'il n'y a pas d'écriture concurrente. Dans du vrai code ces garanties doivent faire partie du design, pas d'un espoir.

Erreur n°4 : oublier le rôle de mutable et « réparer » en supprimant le const.
Parfois un étudiant voit que snapshot() const ne compile pas, et rend la méthode non-const pour « pouvoir prendre le lock ». Progressivement la const-correctness de l'interface se détruit et la classe devient moins claire. Il est plus correct de reconnaître que le mutex est un champ technique et que mutable y est approprié.

Erreur n°5 : garder le verrou trop longtemps dans les méthodes.
Quand vous « cachez » le mutex à l'intérieur de la classe, la tentation inverse apparaît : « puisque je suis à l'intérieur, je vais tout faire sous lock ». Si sous lock vous effectuez des opérations longues (I/O, formatage de longues chaînes, calculs lourds), vous réduisez fortement le parallélisme. Bonne pratique : faire les calculs en dehors, et ne garder sous lock que les actions minimales qui touchent réellement les données protégées.

1
Mission
C++ SELF, niveau 69, leçon 4
Bloqué
Coffre-fort pour le compteur
Coffre-fort pour le compteur
1
Mission
C++ SELF, niveau 69, leçon 4
Bloqué
Instantané du journal
Instantané du journal
1
Mission
C++ SELF, niveau 69, leçon 4
Bloqué
Tirelire verrouillée
Tirelire verrouillée
1
Mission
C++ SELF, niveau 69, leçon 4
Bloqué
Tri d'un instantané
Tri d'un instantané
1
Étude/Quiz
mutex et verrous, niveau 69, leçon 4
Indisponible
mutex et verrous
mutex et verrous
Commentaires
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION