1. Почему разговор о критических секциях начинается не с мьютекса
Когда начинаешь писать многопоточный код, очень хочется сразу схватить первый попавшийся std::mutex и обложить им всё, что движется, включая мысли о смысле жизни. Это понятный порыв: «заблокирую — и всё будет правильно». Проблема в том, что без понимания что именно мы защищаем, мы получаем либо код, который всё равно ломается, либо код, который «правильный», но работает как улитка в отпуске.
Сегодня мы делаем шаг назад и учимся сначала видеть картину: где у нас общий ресурс, какой у него инвариант, и где проходит граница критической секции. Мьютексы появятся в следующих лекциях дня — и тогда вы будете ставить блокировки не «на удачу», а по делу.
Как понять, что объект нужно защищать
Почти любая проблема в многопоточности начинается не с «плохого потока», а с того, что у потоков есть общий объект, к которому они обращаются. Причём общий ресурс — это не только глобальная переменная (хотя она сама просится в мемы). Это может быть поле объекта, контейнер, файл, поток вывода, да даже «очередь сообщений» (но до очередей мы сегодня не идём).
Давайте очень приземлённо: общий ресурс — это то, что видят два и более потока, и хотя бы один поток может это менять. Если объект доступен только внутри одного потока (например, локальная переменная внутри лямбды, которая не разделяется) — это не общий ресурс.
Небольшая табличка для интуиции:
| Пример | Это общий ресурс? | Почему |
|---|---|---|
внутри функции потока |
Нет | Каждый поток имеет свою копию в своём стеке |
| counter захвачен по ссылке [&] и меняется в двух потоках | Да | Оба потока пишут в одну и ту же память |
| std::vector<int> v; общий и оба потока делают push_back | Да | Контейнер меняется конкурентно |
| std::cout из двух потоков | Да | Поток вывода — общий объект, вывод может перемешаться |
Мини‑пример 1: «ну это же просто int…»
Перед этим примером важно проговорить одну вещь. Новички часто думают: «если тип маленький (например int), то всё безопасно». В многопоточности размер типа почти никогда не спасает, потому что проблема не в размере, а в том, что операция на самом деле состоит из нескольких шагов.
#include <iostream>
#include <thread>
int main() {
int counter = 0; // общий ресурс (виден двум потокам)
auto work = [&] {
for (int i = 0; i < 100000; ++i) {
++counter; // read-modify-write, конкурентная запись
}
};
std::thread t1(work);
std::thread t2(work);
t1.join();
t2.join();
std::cout << counter << '\n'; // часто будет НЕ 200000
}
В этом коде «опасное место» — не весь work, а конкретно операция ++counter, потому что по смыслу это «прочитал старое значение → прибавил → записал новое», и эти три шага могут перемешаться между потоками.
2. Инвариант: что мы сохраняем «правильным»
Перед тем как говорить «критическая секция», полезно ввести слово, которое звучит страшнее, чем оно есть: инвариант. Инвариант — это просто условие, которое должно быть истинным всегда (или, если точнее, после завершения каждой публичной операции над объектом).
Почему это важно? Потому что критическая секция — это не «кусок кода, который мне страшно». Критическая секция — это минимальный участок, внутри которого мы сохраняем инвариант общего ресурса.
Представьте банковский счёт:
- инвариант: баланс должен быть корректным числом, отражающим сумму операций;
- операция «внести деньги» по смыслу должна быть атомарной: нельзя, чтобы один поток увидел баланс «между» шагами изменения.
Мини‑пример 2: общий ресурс — поле структуры
Сейчас пример однопоточный на вид, но он важен как модель: мы уже видим, что защищать придётся не «функции», а состояние (balance).
struct Account {
int balance = 0; // общий ресурс, если Account общий для потоков
};
void deposit(Account& a, int amount) {
a.balance += amount; // read-modify-write по смыслу
}
void withdraw(Account& a, int amount) {
a.balance -= amount; // read-modify-write по смыслу
}
Если Account используется несколькими потоками, то инвариант (корректность баланса) требует дисциплины: доступ к balance должен быть согласован.
4. Критическая секция: атомарность по смыслу
Очень частая ошибка — воспринимать критическую секцию как «строчки, где стоит опасный оператор». В реальности критическая секция определяется не синтаксисом, а смыслом: какая последовательность действий должна быть неделимой для других потоков.
Классика: «прочитать → вычислить → записать». Иногда вычисление можно вынести наружу, а иногда нельзя. Критическая секция — это то место, где вы не имеете права позволить другим потокам вмешаться так, чтобы инвариант стал временно неверным снаружи.
Мини‑пример 3: вычисление — отдельно, обновление общего итога — отдельно
Сейчас будет важная мысль: критическая секция должна быть короткой по времени, но полной по смыслу. То есть мы стараемся вынести всё «долгое» наружу, но оставляем внутри всё, что нужно для согласованного обновления общего состояния.
int total = 0; // общий ресурс, если функция вызывается из разных потоков
void add_pair(int a, int b) {
int local = a + b; // локально, это НЕ общий ресурс
total += local; // критическая секция по смыслу: обновление total
}
Здесь local безопасен: он живёт внутри вызова функции (в стеке потока). А вот total — потенциально общий, и обновление должно быть атомарным по смыслу.
5. Границы критической секции: минимально, но достаточно
С границами критической секции у новичков обычно две крайности, и обе неприятные.
Первая крайность — «давайте защитим только запись». Например, «в total += local мы пишем, значит, защитим только эту строку». Иногда это действительно граница, но очень часто рядом есть чтение, которое тоже участвует в гонке. Важно помнить: чтение общего состояния без синхронизации тоже может быть проблемой, потому что data race возникает не только на записи, а на сочетании «кто-то пишет, кто-то читает/пишет».
Вторая крайность — «давайте защитим всю функцию». Это обычно «работает», но потом вы удивляетесь, почему программа стала медленнее, чем однопоточная. Причина проста: вы фактически запретили потокам работать параллельно, даже там, где они могли бы.
Хорошая граница критической секции обычно находится так:
- Сначала вы формулируете инвариант (что должно быть согласованным).
- Затем выделяете минимальную последовательность действий, которая этот инвариант меняет.
- Всё остальное, что не трогает общий ресурс, выносите наружу.
Можно представить это как «сэндвич»:
[долгая работа без общего ресурса]
↓
[критическая секция: быстро и атомарно по смыслу]
↓
[дальше снова без общего ресурса]
Синхронизация защищает данные, а не «места в коде»
Это правило звучит абстрактно, поэтому объясню по‑человечески. Вы можете поставить блокировку «в одном месте», но если в другом месте кто-то читает/пишет тот же объект без блокировки — вы не защитили данные. Вы просто добавили в программу новый ритуал, который успокаивает вас, но не успокаивает баги.
То есть дисциплина такая: если у нас есть общий ресурс X, то все пути доступа к X должны быть согласованы одним правилом. Обычно это означает «все доступы к X происходят внутри критической секции, защищённой одним и тем же механизмом». В следующих лекциях дня мы сделаем это через std::mutex, но сегодня нам важен именно принцип: защищаем состояние, а не строку кода.
6. Практический пример: статистика задач и её инварианты
Чтобы не жить только в примерах «counter++», давайте продолжим наше условное консольное приложение, которое мы развивали раньше: TaskTracker. Представим, что мы уже умеем хранить задачи и печатать их, а теперь решили добавить «живую статистику» — например, суммарное время выполнения задач.
Пусть у нас есть компонент TaskStats, который хранит длительности выполненных задач. Мы хотим поддерживать быстрый расчёт среднего, поэтому храним не только список, но и сумму.
Инвариант здесь очень конкретный и полезный:
total_seconds_ всегда равен сумме всех элементов durations_seconds_.
Если инвариант нарушится, среднее будет неверным, и вы будете искать баг «почему математика сломалась», хотя сломалась не математика, а многопоточность.
Мини‑пример 4: «опасная» операция — это две записи, которые должны быть одной
Сейчас код нарочно без синхронизации. Наша задача — увидеть границы критической секции.
#include <vector>
class TaskStats {
public:
void add_duration(int seconds) {
durations_seconds_.push_back(seconds); // (1) меняем контейнер
total_seconds_ += seconds; // (2) меняем сумму
// Эти две строки по смыслу должны быть одной критической секцией.
}
private:
std::vector<int> durations_seconds_;
long long total_seconds_ = 0;
};
Почему это критическая секция? Потому что другой поток может влезть «между» (1) и (2), и тогда:
- вектор уже увеличился,
- а сумма ещё не увеличилась,
и инвариант временно ложный. А если в этот момент кто-то читает статистику — он увидит несогласованное состояние.
А чтение тоже требует границ
Проверим метод для среднего значения. Даже если он «ничего не меняет», он читает два связанных поля, которые должны быть согласованы.
class TaskStats {
public:
double average() const {
if (durations_seconds_.empty()) return 0.0;
double sum = static_cast<double>(total_seconds_);
double n = static_cast<double>(durations_seconds_.size());
return sum / n;
// Чтение total_seconds_ и size() тоже должно быть согласованным.
}
private:
std::vector<int> durations_seconds_;
long long total_seconds_ = 0;
};
Здесь опасность в том, что один поток может менять durations_seconds_ и total_seconds_, а другой одновременно читает. Даже если вы «защитите только add_duration», но оставите average() без защиты — вы оставите дверь открытой.
Как рисовать критические секции: схема на салфетке
Когда код растёт, критические секции перестают быть очевидными. И тут помогает простой приём: рисовать схему данных и операций.
Вот упрощённая схема для TaskStats:
flowchart TD
T1["Поток A: add_duration()"] -->|пишет| V["durations_seconds_ (vector)"]
T1 -->|пишет| S["total_seconds_ (sum)"]
T2["Поток B: average()"] -->|читает| V
T2 -->|читает| S
note1["Инвариант: total_seconds_ == sum(durations_seconds_)"]
V --- note1
S --- note1
Граница критической секции здесь проходит вокруг «пакета согласованных операций»:
- в add_duration() это push_back + +=,
- в average() это чтение total_seconds_ + чтение size() (и проверка empty() тоже относится туда же, потому что это чтение контейнера).
Важно: мы пока не выбираем инструмент синхронизации. Мы просто научились видеть, какие действия должны выполняться взаимно исключающе.
8. Типичные ошибки при выделении критических секций
Ошибка №1: «Защищаю только запись, чтение не считается».
Такое мышление обычно появляется из логики «ломает же запись». Но data race возникает, когда есть конкурентный доступ и хотя бы один поток пишет. Если один поток пишет, а другой в это время читает без согласованного правила, это всё ещё некорректность. Более того, чтение часто превращается в «чтение нескольких связанных полей», и тогда вы получаете несогласованные снимки состояния.
Ошибка №2: «Критическая секция — это вся функция, на всякий случай».
Это работает как пластырь на всё: и на царапину, и на ноутбук. Программа действительно может перестать ломаться, но вы теряете параллелизм там, где он был возможен. Чаще всего правильнее выделить инвариант и защищать минимальную последовательность действий, которая этот инвариант меняет.
Ошибка №3: «Я поставлю защиту в одном месте, а в другом “быстренько” прочитаю без неё».
Так часто делают с методами вида size()/empty()/average(): кажется, что они безобидны. Но если они читают данные, которые другой поток меняет, то это уже часть конкурентного доступа. В результате у вас появляется код, который «почти всегда работает», а потом раз в неделю ломает тесты на CI и делает вид, что он ни при чём.
Ошибка №4: «Критическая секция — это конкретная строка, а не смысловая операция».
Если инвариант требует изменить два поля согласованно (как push_back и total +=), то защищать только одну строку бессмысленно. Критическая секция должна покрывать всю операцию обновления инварианта, иначе другой поток может увидеть состояние «между шагами», и это уже ошибка логики.
Ошибка №5: «Общий ресурс — только глобальные переменные».
Глобальные переменные действительно опасны, но общий ресурс может жить и в куче, и в объекте, и в контейнере, и даже быть «внешним»: файл, консоль, лог. Поэтому полезная привычка — при виде потока задавать себе вопрос: «Какие данные этот поток разделяет с другими потоками — явно или через ссылки/указатели/захваты?»
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ