1. Зачем вообще переживать из‑за гонок
Если вы когда-нибудь писали многопоточный код и думали «ну, я запустил два потока, они что-то посчитали, вроде вывело правильно — значит нормально», то поздравляю: вы прошли классический квест новичка. Проблема в том, что многопоточность умеет успешно притворяться, что всё в порядке, особенно на маленьких примерах и «на моём ноутбуке».
Главная причина, почему мы выделяем под это целую лекцию: ошибки многопоточности почти всегда живут в режиме «иногда». Они редко падают стабильно. Они любят появляться на проде в пятницу вечером. И самое неприятное: часть ошибок в C++ формально относится к Undefined Behavior — то есть компилятор имеет право «сделать что угодно», а вы не можете доказать корректность, даже если тысячу раз подряд выводится правильное значение.
Race condition и data race: в чём разница
Слова похожи, оба про «гонку», оба звучат страшно — неудивительно, что их путают. Но это две разные проблемы. И если вы их различаете, то половина многопоточных багов становится хотя бы объяснимой, а не мистической.
Логическая гонка
Race condition (логическая гонка) — это ситуация, когда результат зависит от того, в каком порядке выполняются действия потоков. При этом доступ к памяти может быть формально корректным (например, всё защищено мьютексами), но сценарий «кто первый успел» влияет на бизнес-логику.
Гонка данных
Data race (гонка данных) — это ситуация, когда два потока одновременно лезут к одной и той же области памяти, и хотя бы один из них пишет, а при этом нет корректной синхронизации. В C++ это особенно критично, потому что data race считается Undefined Behavior, то есть поведение программы не определено стандартом.
Для удобства — маленькая табличка (её не нужно зубрить, нужно «почувствовать»):
| Явление | Про что | Можно ли получить UB? | Как обычно чинят |
|---|---|---|---|
| Race condition | Про логику и порядок событий | Не обязательно | Дизайн протокола, очереди, состояния, иногда мьютексы/condition_variable |
| Data race | Про некорректный конкурентный доступ к памяти | Да, в C++ это UB | Мьютекс, атомики, правильная синхронизация |
Data race простыми словами
Давайте без академического языка (хотя стандарт его очень любит): data race возникает, когда два потока одновременно обращаются к одной и той же переменной, при этом хотя бы один делает запись, и вы не договорились о правилах доступа (не поставили мьютекс, не сделали переменную атомиком, не построили другой протокол синхронизации).
Представьте кухню. Два повара одновременно солят один и тот же суп, причём один уже мешает, второй ещё не пробовал, и никто не договорился, кто держит ложку. Иногда суп нормальный. Иногда пересол. Иногда ложка улетела в стену. Вот data race — это ситуация «ложка улетела в стену», а не «пересол».
Самый короткий пример, который выглядит «невинно», но на самом деле уже проблема:
#include <iostream>
#include <thread>
int main() {
int x = 0;
std::jthread t1([&] { ++x; });
std::jthread t2([&] { ++x; });
std::cout << x << '\n'; // может "2", может что угодно (формально UB)
}
Важно: даже если вы часто видите 2, этот код всё равно некорректен по стандарту, потому что два потока пишут в один int без синхронизации.
2. Почему data race в C++ — это UB
На этом месте обычно хочется спросить: «Ну почему так жёстко? Почему не сказать просто “результат непредсказуем”?» А вот потому что в C++ есть два мощных «зверя», которые оптимизируют выполнение: компилятор и процессор. И оба имеют право переставлять и упрощать операции, если по правилам языка это не меняет смысла в однопоточном мире.
Когда в коде появляется data race, вы по сути говорите компилятору: «Я нарушаю правила, но ты всё равно веди себя как будто правила не нарушены». Компилятор отвечает: «Окей, тогда я буду оптимизировать так, как мне выгодно. А ты потом не обижайся».
Классика: флажок остановки без синхронизации
Очень показательный пример — «флажок остановки» без синхронизации. Часто новички делают так: один поток крутится в цикле и ждёт, пока stop станет true, а другой поток его выставляет.
#include <thread>
int main() {
bool stop = false;
std::jthread worker([&] {
while (!stop) {
// "ждём"
}
});
std::jthread stopper([&] {
stop = true;
});
}
На человеческом уровне кажется: «ну stopper поставит stop = true, и worker выйдет». На практике компилятор может решить, что раз stop внутри worker нигде не меняется (он же не видит корректной синхронизации!), то while (!stop) можно трактовать как «вечный цикл». И это не «баг компилятора», это следствие того, что вы дали ему право так думать, потому что нарушили модель многопоточности языка.
Почему join() не «отменяет» гонку
Очень частая мысль звучит так: «Окей, я понял: одновременно нельзя. Но если я дождался потоки (join()) и только потом читаю результат — значит безопасно?». Увы, нет. join() (или RAII-ожидание в std::jthread) гарантирует, что поток закончился, но не отменяет факт, что во время работы два потока могли устроить data race.
Вот пример, который психологически «успокаивает», но юридически (по стандарту) всё ещё нарушает правила:
#include <iostream>
#include <thread>
int main() {
int x = 0;
std::jthread t1([&] { ++x; });
std::jthread t2([&] { ++x; });
// t1 и t2 точно завершатся до выхода из main (это делает jthread)
std::cout << x << '\n';
}
Проблема не в чтении x в конце. Проблема в том, что конкурентные записи уже произошли, пока потоки были живы.
Если проводить аналогию: «я подожду, пока два человека закончат одновременно редактировать один и тот же документ без конфликт-резолвера» — не значит «конфликтов не было». Это значит только «редактирование закончилось».
Почему sleep_for лечит только симптомы
Следующая популярная «таблетка от всего» — это sleep_for. Логика обычно такая: «Если потоки мешают друг другу, я их чуть разведу по времени». Это звучит разумно, но работает примерно как попытка чинить автомобиль, стуча по нему ключом: иногда правда заводится, но метод не рекомендуется автопроизводителем.
Пример «плохого успокоительного»:
#include <chrono>
#include <iostream>
#include <thread>
int main() {
int x = 0;
std::jthread t1([&] {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
++x;
});
std::jthread t2([&] {
std::this_thread::sleep_for(std::chrono::milliseconds(1));
++x;
});
std::cout << x << '\n'; // "чаще" 2, но это всё ещё UB
}
Да, задержки могут уменьшить шанс столкновения, но они не создают правил доступа к памяти. А значит, код остаётся формально некорректным. Более того, на другом компьютере, при другой нагрузке, при другой частоте CPU и другом планировщике потоков всё снова «всплывёт».
4. Как исправлять data race: mutex и atomic
Когда мы говорим «исправить data race», мы на самом деле говорим: «сделать так, чтобы доступы к общим данным стали согласованными». Базовых лекарства два: мьютекс и атомики.
Мьютекс хорош тем, что он «закрывает дверь»: пока один поток внутри критической секции, второй ждёт. Это удобно, когда у вас несколько связанных переменных, и важно сохранить инварианты вида «если изменили A, то обязаны изменить B».
Атомик хорош тем, что он делает операции над одной переменной корректными для многопоточности (например, счётчик, флаг). Но атомик — это не «магия на всё приложение», а точечный инструмент.
Интересно, что даже вокруг стандартной библиотеки есть отдельные обсуждения про то, как контейнеры должны помогать «избегать гонок данных» (встречается формулировка «Data race avoidance…»). Это не случайность: тема настолько базовая, что без неё невозможно нормально описать даже поведение контейнеров.
Один счётчик: две корректные починки
Версия с мьютексом:
#include <iostream>
#include <mutex>
#include <thread>
int main() {
int x = 0;
std::mutex m;
std::jthread t1([&] { std::lock_guard<std::mutex> lock(m); ++x; });
std::jthread t2([&] { std::lock_guard<std::mutex> lock(m); ++x; });
std::cout << x << '\n'; // 2
}
Версия с атомиком:
#include <atomic>
#include <iostream>
#include <thread>
int main() {
std::atomic<int> x{0};
std::jthread t1([&] { ++x; });
std::jthread t2([&] { ++x; });
std::cout << x.load() << '\n'; // 2
}
Обратите внимание: мы пока не обсуждаем «тонкие настройки» атомиков. Сегодня нам важно только железное правило: обычный int нельзя безопасно менять из нескольких потоков без синхронизации. А вот std::atomic<int> — можно.
Практический пример: мини‑TaskRunner и прогресс
Чтобы это не осталось абстракцией «про какие-то сферические потоки в вакууме», привяжем идею к нашему учебному приложению. Пусть у нас есть мини‑TaskRunner: он запускает несколько воркеров, каждый «обрабатывает задачу», а мы хотим считать, сколько задач сделано, и печатать прогресс.
Наивная реализация выглядит красиво… и ломается логически (а иногда и хуже — уходит в UB), потому что несколько потоков пишут в одну переменную.
#include <iostream>
#include <thread>
#include <vector>
int main() {
int processed = 0;
std::vector<std::jthread> workers;
for (int i = 0; i < 4; ++i) {
workers.emplace_back([&] { ++processed; });
}
std::cout << processed << '\n'; // "ожидали 4", но вообще UB
}
Почему это плохой код — теперь уже понятно: processed общий, и в него одновременно пишут.
Исправим «минимально» — сделаем счётчик атомарным. Это ровно тот сценарий, где атомик уместен: независимый счётчик прогресса, который не держит сложных инвариантов.
#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
int main() {
std::atomic<int> processed{0};
std::vector<std::jthread> workers;
for (int i = 0; i < 4; ++i) {
workers.emplace_back([&] { ++processed; });
}
std::cout << processed.load() << '\n'; // 4
}
А если бы у нас была связка из двух переменных, например processed и errors, и мы хотели печатать красивую статистику так, чтобы она была согласованной («сначала инкремент ошибки, потом инкремент обработанных»), то очень часто проще и понятнее использовать мьютекс и обновлять состояние одним «пакетом». Атомики тоже могут это решать, но это уже территория, где легко случайно построить протокол «на соплях».
5. Как научиться видеть data race и отличать её от «просто бага»
Самая неприятная особенность data race — в том, что она не всегда выглядит как «опасный участок». Иногда это банальный ++, иногда запись флага, иногда чтение «просто чтобы вывести в лог». Поэтому важнее всего не трюки, а привычка мыслить правильными вопросами.
Ментальная модель: три вопроса к общему объекту
Полезная ментальная модель такая: data race появляется там, где есть общий изменяемый объект. Значит, когда вы смотрите на код, вы ищете ответы на три вопроса: где объект живёт (кто его «видит»), кто его меняет, и по каким правилам мы разрешаем доступ.
Можно нарисовать это как маленькую блок‑схему, которую удобно держать в голове:
flowchart TD
A[Есть переменная/объект] --> B{Её видят 2+ потока?}
B -- нет --> C[Data race невозможна]
B -- да --> D{Есть запись хотя бы в одном потоке?}
D -- нет --> E[Обычно безопасно, но проверьте lifetime/инвалидацию]
D -- да --> F{Есть синхронизация? mutex/atomic/протокол}
F -- да --> G["Data race устранена (проверьте логику)"]
F -- нет --> H[Data race => UB]
Обратите внимание на ветку «только чтение»: она часто безопасна, но там всплывают другие проблемы (например, время жизни объекта, инвалидирование ссылок, и т.п.). Однако сегодня мы держим фокус именно на записи без синхронизации.
Мини‑диагностика: UB или детерминированный баг
Когда программа «иногда печатает 4, иногда 3», очень хочется сказать: «Ну это баг, я потом поправлю». Но в многопоточности полезнее другая дисциплина: сначала выяснить, это детерминированная логическая ошибка или нарушение правил памяти. Потому что первое вы лечите дизайном, а второе — синхронизацией.
В практическом смысле (без углубления в инструменты) вам помогают две вещи.
Первая — привычка ставить «предохранители» через assert, чтобы хотя бы ловить невозможные состояния. Например, если ваш TaskRunner ожидает, что processed никогда не будет отрицательным и не превысит число задач, можно временно проверять это прямо во время разработки. Да, assert не лечит гонки. Но он помогает поймать момент, когда состояние «поехало».
#include <atomic>
#include <cassert>
int main() {
std::atomic<int> processed{0};
++processed;
int v = processed.load();
assert(v >= 0);
}
Вторая — санитайзеры и инструменты диагностики (мы с ними уже знакомились ранее): они часто умеют ловить data race как класс проблемы. Но даже без инструментов сегодняшнее «оружие номер один» — корректная модель в голове: если есть общий int, который пишется из двух потоков без синхронизации, это не «может быть баг», это уже красный флаг.
6. Типичные ошибки
Ошибка №1: “Раз это int, значит безопасно — он же маленький”.
Размер типа не делает доступ потокобезопасным. Проблема не в том, «помещается ли в регистр», а в том, что два потока одновременно выполняют несогласованные операции чтения/записи. В результате вы нарушаете правила модели памяти C++ и попадаете в UB.
Ошибка №2: “Я прочитаю без мьютекса, а пишу с мьютексом — вроде норм”.
Это очень хитрая ловушка: кажется, что запись защищена, значит чтение увидит «что-нибудь адекватное». На практике чтение без синхронизации всё равно конфликтует с записью, и это может быть data race. Если защищаете объект мьютексом — защищайте им и чтения, и записи (или используйте другой корректный протокол).
Ошибка №3: “Сделаю sleep_for, и потоки перестанут сталкиваться”.
Задержка не является синхронизацией. Она не создаёт правила «кто когда имеет право писать», а лишь случайно меняет расписание. На одном компьютере станет «лучше», на другом — снова развалится. Самое обидное: так вы получаете баг, который почти невозможно воспроизвести.
Ошибка №4: “join() всё исправляет”.
join() гарантирует завершение потока, но не отменяет факт уже произошедшей гонки данных. Если два потока во время работы писали в одну переменную без синхронизации, то вы уже нарушили правила, и итог может быть любым, даже если после этого вы аккуратно «дождались всех».
Ошибка №5: “Я поставлю атомик на флаг, а остальные данные пусть будут обычными — и всё будет честно”.
Атомик решает проблему для самой переменной-атомика. Но если у вас есть набор связанных данных, то один атомик «рядом» не превращает весь остальной код в потокобезопасный. Для связанных инвариантов обычно проще начать с мьютекса, а уже потом оптимизировать, когда точно понимаете протокол и границы ответственности.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ