JavaRush /Курсы /C++ SELF /Инкапсуляция синхронизации: “mutex внутри класса”

Инкапсуляция синхронизации: “mutex внутри класса”

C++ SELF
69 уровень , 4 лекция
Открыта

1. Мьютекс внутрь класса

Если вы только что научились пользоваться std::lock_guard, возникает естественное желание: «Окей, я просто добавлю std::mutex рядом с данными и буду его лочить где надо». Проблема в том, что «где надо» — это не география, а дисциплина. Когда мьютекс живёт снаружи, доступ к данным расползается по проекту, и в какой-то момент кто-то (часто вы же, но через неделю) забудет взять lock. Сегодня мы строим привычку: синхронизация — это часть реализации класса, а не договорённость между людьми.

Представьте реальный проект: несколько функций, несколько файлов, кто-то добавил «маленькую оптимизацию», кто-то сделал «быстрый доступ к вектору» через ссылку… и вот у вас гонка данных, которая проявляется только по пятницам после обеда. Мьютекс снаружи почти всегда повышает шанс такого сценария.

Антипример: данные отдельно, мьютекс отдельно

В качестве анти-примера возьмём ситуацию, которая выглядит невинно. У нас есть хранилище задач (пусть это будет часть нашего учебного консольного приложения), и несколько потоков добавляют туда задачи. Новичок часто делает так: данные — отдельно, мьютекс — отдельно, а доступ к данным «вроде бы» под мьютексом.

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

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

На первый взгляд, всё неплохо. Но теперь любая функция в проекте видит g_tasks. И даже если вы клялись себе «всегда лочить», компилятор вашу клятву не проверит. Следующий код — типичная причина «плавающих» багов:

#include <string>
#include <vector>

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

void debug_print_first() {
    if (!g_tasks.empty()) {                 // ОПАСНО: чтение без синхронизации
        // ...
    }
}

Здесь нет ни одного lock_guard. И проблема даже не в том, что автор плохой. Проблема в том, что интерфейс плохой: он позволяет ошибаться слишком легко.

С std::mutex в целом всё как с ремнём безопасности: он работает лучше, когда встроен в конструкцию, а не лежит рядом «на всякий случай». В стандартной библиотеке сам факт наличия mutex::lock() — это низкоуровневый строительный блок, но то, как мы его применяем в прикладном коде, должно быть дисциплинированным (и лучше — самодисциплинирующимся).

2. Инкапсуляция: “данные + мьютекс = один объект”

Ключевая мысль сегодняшней лекции очень простая (что редко и приятно): если класс владеет данными, он должен владеть и правилом доступа к ним. А правило доступа в многопоточности — это и есть синхронизация.

Удобно нарисовать это как маленькую схему:

flowchart LR
    A[Внешний код] -->|вызывает методы| B[Класс]
    B -->|внутри берёт lock| M[(mutex)]
    B -->|защищает| D[(данные)]

Внешний код не должен «держать в голове», когда лочить. Он должен просто пользоваться методами класса. Тогда:

  1. класс гарантирует свои инварианты,
  2. все доступы идут по одному протоколу,
  3. вероятность «случайно забыть lock» падает резко.

4. Мини-компонент: потокобезопасный TaskLog

Давайте развивать единый учебный контекст: у нас есть консольное приложение, и мы хотим писать туда события из нескольких потоков (например, «поток №1 добавил задачу», «поток №2 обработал задачу»). Вывод в std::cout мы уже защищали мьютексом в прошлых лекциях, но теперь сделаем более полезное: будем складывать сообщения в память, а потом печатать одним махом.

Начнём с модели: просто строка. Мы пока не усложняем struct, чтобы фокус был на синхронизации.

Версия “как не надо”: класс отдаёт внутренности наружу

Сначала специально сделаем плохо, чтобы мозг почувствовал разницу.

#include <string>
#include <vector>

class BadTaskLog {
public:
    std::vector<std::string>& data() { return data_; } // утечка внутренностей
private:
    std::vector<std::string> data_;
};

С виду удобно: «хочешь — добавляй, хочешь — сортируй». Но в многопоточном мире это ровно то же самое, что оставить дверь квартиры открытой и написать на бумажке «пожалуйста, не заходите». Формально просьба есть, но защита отсутствует.

Как только внешний код получил std::vector<std::string>&, класс больше не контролирует:

  • кто и когда меняет data_,
  • под каким мьютексом (если под каким-то вообще),
  • не держит ли кто-то ссылку на элемент, который потом инвалидируется при push_back.

Версия “как надо”: TaskLog сам синхронизирует операции

Теперь сделаем правильно: мьютекс — private, операции — public, все изменения и чтения идут через методы.

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

Обратите внимание: метод маленький, блокировка держится ровно на время push_back. Внешний код больше не может «случайно» править data_ напрямую.

И вот здесь появляется следующий вопрос (очень правильный): «А как читать лог?»

Чтение: snapshot() и зачем здесь mutable

Новички часто искренне удивляются: «Я же просто читаю! Я ничего не меняю!» Проблема в том, что в многопоточном коде чтение участвует в гонке данных точно так же, как и запись, если другой поток в этот момент пишет.

Поэтому нам нужен метод чтения. Но мы не хотим возвращать ссылку. Что делать? Самый простой и безопасный путь для учебного кода — вернуть копию (снимок).

#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_; // копия
    }

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

Здесь сразу два важных момента.

Первый: метод snapshot()const, потому что логически он не меняет «данные лога» (сообщения). Но он берёт блокировку, а блокировка меняет состояние m_. Поэтому мы написали mutable std::mutex m_;.

Второй: да, копирование std::vector<std::string> — это не бесплатно. Но это честная цена за простой и безопасный интерфейс. В реальном проекте вы будете думать о частоте вызовов и объёмах данных, но архитектурная идея останется: не отдавайте наружу доступ к внутреннему состоянию, которое вы обязаны защищать.

Слово mutable звучит так, будто вы хакнули компилятор и теперь правила const не работают. На самом деле идея тоньше: const в C++ — это контракт о логическом состоянии объекта, а не о каждом бите памяти.

Мьютекс — это механизм синхронизации, он не относится к «смысловым данным» (сообщениям лога, балансу аккаунта, списку задач). Поэтому в const-методе мы вправе синхронизироваться: мы не меняем лог «как лог», мы лишь обеспечиваем корректный доступ.

Важное правило хорошего вкуса: mutable в многопоточном коде чаще всего встречается именно у мьютекса, а не у «полезных» полей. Если вы начали делать mutable у данных, чтобы менять их в const-методах, это уже похоже на архитектурный запах.

5. Почему мьютекс снаружи ломает инварианты класса

Допустим, кто-то предлагает альтернативу: «А давайте сделаем так: TaskLog хранит данные, а мьютекс будет снаружи, и пользователь будет лочить сам». На первый взгляд, это «гибко». На практике — это почти всегда означает, что инварианты класса перестают быть доказуемыми.

Инвариант — это то, что класс обещает держать истинным. Например, «вектор сообщений не читается одновременно с изменением» — это тоже инвариант, просто многопоточный.

Когда мьютекс снаружи, у вас появляется ситуация:

  • метод add() может быть вызван без lock (потому что класс сам не лочит),
  • метод snapshot() может быть вызван без lock,
  • а ещё кто-то может взять ссылку на данные и обходить вообще все правила.

То есть класс больше не «владеет» своим состоянием. Он превращается в пакет полей, вокруг которых должна существовать магическая дисциплина в головах разработчиков. Магия, как известно, плохо дебажится.

Составные операции: не заставляйте пользователя “лочить снаружи”

Теперь тонкий момент. Иногда пользователю действительно нужна операция «сразу несколько действий атомарно по смыслу». Например: «добавь сообщение и верни текущий размер лога, но так, чтобы между этими двумя действиями никто не вклинился».

Если мы запрещаем внешний lock (а мы его запрещаем), то такие операции должны быть методами класса.

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

Метод короткий, но очень важный по смыслу: он «склеивает» два действия в одну критическую секцию. И главное — пользователь не может сделать «половину правильно».

6. Несколько мьютексов внутри класса: осторожно

Иногда возникает искушение: «Сделаю два мьютекса: один на data_, другой на что-то ещё». Это может быть оправдано, но для новичка почти всегда усложняет жизнь сильнее, чем ускоряет программу.

Почему? Потому что два мьютекса сразу поднимают риск:

  • перепутать порядок захвата,
  • случайно вызвать метод, который берёт второй мьютекс, находясь под первым,
  • получить deadlock внутри одного класса (да, так тоже бывает, и это особенно обидно).

Поэтому практическое правило для начала карьеры звучит так: один объект — один мьютекс, пока вы не можете чётко объяснить, зачем вам второй.

7. Мини-демо: несколько потоков пишут в TaskLog

Мы не будем строить сегодня «идеальную» очередь задач (для этого потребовались бы механизмы ожидания, которые мы сознательно не трогаем). Но мы можем показать самое главное: несколько потоков безопасно вызывают методы одного объекта, не зная ничего о мьютексах.

#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';
    }
}

Здесь важное ощущение: потокам вообще не нужно знать, что внутри TaskLog есть std::mutex. Они просто пользуются классом как обычной библиотекой. Это и есть цель инкапсуляции.

8. Памятка: “как было / как стало” и почему нельзя отдавать ссылки

Иногда полезно увидеть разницу в одном экране — это как «до/после» в рекламе шампуня, только шампунь у нас для багов.

Архитектура Как выглядит Что идёт не так
Мьютекс снаружи data глобально/публично, mutex рядом Легко забыть lock, тяжело доказать корректность, внешний код может обходить правила
Мьютекс внутри класса private mutex, методы сами лочат Дисциплина доступа встроена, инварианты защищены, пользователь класса не может «случайно» нарушить протокол

Когда вы возвращаете ссылку/указатель на внутренние данные, вы отдаёте наружу доступ, который живёт дольше, чем ваш lock_guard.

В этом и беда: блокировка — это всегда временная гарантия, ограниченная scope’ом. А ссылка — это обещание «можешь пользоваться объектом где угодно». Эти обещания конфликтуют.

Если вы хотите дать доступ к данным, у вас обычно есть три честных варианта.

Первый вариант — «снимок» (как snapshot()), то есть копирование данных.

Второй вариант — отдельные методы-операции: add, erase, size, contains и так далее, чтобы пользователь делал то, что ему надо, не получая прямой доступ к контейнеру.

Третий вариант — выполнить кусок работы «под замком» через callback. Но это опаснее для новичка, потому что вы можете случайно вызвать «чужой» код под lock, а это увеличивает шанс deadlock и долгого удержания блокировки. Поэтому в учебном стиле мы обычно ограничимся первыми двумя.

9. Типичные ошибки

Ошибка №1: делать std::mutex публичным полем “для удобства”.
Так вы превращаете синхронизацию в внешнюю договорённость. Внешний код может забыть взять lock, взять не тот lock, удерживать lock слишком долго, или вообще использовать разные мьютексы для одних и тех же данных. В итоге корректность класса перестаёт быть свойством класса — она становится свойством настроения программиста.

Ошибка №2: возвращать наружу ссылки/указатели на внутренние контейнеры.
Это ломает инкапсуляцию сильнее, чем если бы вы просто написали «делайте что хотите». Ссылка живёт дольше блокировки, и внешний код может менять данные без синхронизации, хранить «привязки» к элементам, которые инвалидируются, и вызывать методы контейнера одновременно из нескольких потоков.

Ошибка №3: не синхронизировать чтение (“я же не меняю”).
Чтение общего состояния без синхронизации может участвовать в гонке данных, если другой поток пишет. «Только читаю» безопасно лишь тогда, когда гарантировано нет конкурентной записи. В реальном коде такие гарантии должны быть частью дизайна, а не надеждой.

Ошибка №4: забыть про mutable и пытаться “лечить” это отказом от const.
Иногда студент видит, что snapshot() const не компилируется, и делает метод не-const, чтобы «можно было взять lock». Так постепенно разрушается const-корректность интерфейса, и класс становится менее понятным. Правильнее признать: мьютекс — это техническое поле, и для него уместен mutable.

Ошибка №5: держать блокировку слишком долго внутри методов.
Когда вы «прячете» мьютекс внутрь класса, появляется обратный соблазн: «Ну раз я внутри, я всё сделаю под lock». Если вы под lock делаете длинные операции (особенно ввод/вывод, форматирование больших строк, сложные вычисления), вы резко снижаете параллелизм. Хороший стиль — вычисления делать снаружи, а под lock оставлять только минимальные действия, которые реально трогают защищаемые данные.

1
Задача
C++ SELF, 69 уровень, 4 лекция
Недоступна
Сейф счётчика
Сейф счётчика
1
Задача
C++ SELF, 69 уровень, 4 лекция
Недоступна
Снимок журнала
Снимок журнала
1
Задача
C++ SELF, 69 уровень, 4 лекция
Недоступна
Копилка с замком
Копилка с замком
1
Задача
C++ SELF, 69 уровень, 4 лекция
Недоступна
Сортировка снимка
Сортировка снимка
1
Опрос
mutex и блокировки, 69 уровень, 4 лекция
Недоступен
mutex и блокировки
mutex и блокировки
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ