JavaRush /Курсы /C++ SELF /Data race и UB: краткий мост к модели гонок

Data race и UB: краткий мост к модели гонок

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

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: “Я поставлю атомик на флаг, а остальные данные пусть будут обычными — и всё будет честно”.
Атомик решает проблему для самой переменной-атомика. Но если у вас есть набор связанных данных, то один атомик «рядом» не превращает весь остальной код в потокобезопасный. Для связанных инвариантов обычно проще начать с мьютекса, а уже потом оптимизировать, когда точно понимаете протокол и границы ответственности.

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