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[(данные)]
Внешний код не должен «держать в голове», когда лочить. Он должен просто пользоваться методами класса. Тогда:
- класс гарантирует свои инварианты,
- все доступы идут по одному протоколу,
- вероятность «случайно забыть 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 оставлять только минимальные действия, которые реально трогают защищаемые данные.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ