JavaRush /Курсы /C++ SELF /Поток выполнения и риски конкурентности

Поток выполнения и риски конкурентности

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

1. Что меняется, когда потоков становится два

Начнём с мотивации, потому что иначе многопоточность выглядит как странная игра «давайте усложним жизнь». Реальные программы часто делают несколько дел одновременно: читают данные, считают что-то тяжёлое, показывают прогресс, реагируют на пользователя, пишут логи. Если всё делать в одном потоке, то программа может «замирать» и казаться зависшей — даже если она честно работает.

Представим наше учебное консольное приложение (пусть это будет простой менеджер задач TaskBook). У него есть команды вроде add, list, done, и иногда — тяжёлая операция: например, пересчитать статистику по большим данным или импортировать список задач. В идеале хочется, чтобы во время тяжёлой операции интерфейс не «умирал», а мог хотя бы печатать прогресс.

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

Поток выполнения и общая память

Когда мы говорим «поток выполнения», мы имеем в виду отдельную линию исполнения инструкций внутри одного процесса. У процесса может быть несколько потоков, и они обычно разделяют одну и ту же память: глобальные переменные, данные в куче, объекты в main(), контейнеры и так далее. То есть «живут в одной квартире», только каждый ходит по комнатам в своём темпе.

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

Полезно держать в голове такую схему:

flowchart TD
    A["Один процесс (ваша программа)"] --> B["Поток main"]
    A --> C["Поток worker"]
    B --> D["Общая память: переменные, контейнеры, heap"]
    C --> D

Оба потока могут обращаться к общей памяти. И если вы не договорились «кто когда что трогает», начинаются гонки.

Недиетерминизм: один и тот же код — разный порядок событий

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

Мини-демонстрация (здесь мы используем std::thread, но пока без углубления — просто как «способ запустить вторую линию выполнения»):

#include <iostream>
#include <thread>

int main() {
    std::thread t([] { std::cout << "worker\n"; });
    std::cout << "main\n";
    t.join();
}

Один запуск может вывести:

// main
// worker

Другой запуск может вывести:

// worker
// main

И оба варианта правильные. Суть: между потоками нет обещания «кто первый», пока вы сами не построили такое правило.

Отдельный нюанс: std::cout — это тоже общий ресурс. Даже если строки выводятся «примерно правильно», они могут перемешиваться, если печатать по кусочкам. Сегодня это будет для нас «симптомом конкурентности»: увидели кашу в консоли — вспомнили, что потоки перемешиваются.

Race condition: логическая гонка

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

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

Сделаем пример ближе к нашему TaskBook. Допустим, мы хотим обновлять строку статуса: один поток пишет «Импорт…», другой поток пишет «Готово». Если мы рассчитываем, что «Готово» будет всегда последним — мы уже в зоне риска.

#include <iostream>
#include <string>
#include <thread>

int main() {
    std::string status = "starting";

    std::thread t1([&] { status = "importing"; });
    std::thread t2([&] { status = "done"; });

    t1.join();
    t2.join();

    std::cout << status << '\n'; // importing ИЛИ done
}

Даже если программа не падает, результат недиетерминирован: вы не можете гарантировать, что увидите done. Логика «ну t2 же ниже в коде, значит он позже» — больше не работает. Потоки не читают ваш код как книгу.

Чтобы чётче зафиксировать разницу терминов, удобно сравнить их в таблице:

Понятие Что это Главный симптом «Насколько плохо»
Race condition Ошибка логики из‑за непредсказуемого порядка событий Иногда «не тот результат», иногда «не та ветка сценария» Плохо: баг плавающий и трудно воспроизводимый
Data race Некорректный конкурентный доступ к памяти (есть запись, нет согласованного правила) Может «работать», может ломаться, может давать странные значения Очень плохо: UB, то есть программа формально некорректна

До data race мы дойдём через пару минут — и станет понятно, почему это отдельная категория «ужасов».

Data race: конкурентный доступ к памяти + запись → Undefined Behavior

Если race condition — это «логика поехала», то data race — это «мы нарушили правила языка». В C++ data race возникает, когда два потока одновременно обращаются к одной и той же области памяти, и при этом хотя бы один поток пишет, а между этими обращениями нет согласованного правила доступа.

Самая коварная часть: многие думают, что ++counter — это «одно действие». На уровне текста — да. На уровне выполнения — нет. Обычно это минимум три шага: прочитать, увеличить, записать. И если два потока делают это одновременно — они могут перетереть изменения друг друга.

Вот классический пример (и да, он намеренно «плохой»):

#include <iostream>
#include <thread>

int main() {
    int counter = 0;

    auto inc = [&] {
        for (int i = 0; i < 100000; ++i) {
            ++counter; // НЕКОРРЕКТНО: data race (UB)
        }
    };

    std::thread t1(inc);
    std::thread t2(inc);

    t1.join();
    t2.join();

    std::cout << counter << '\n'; // не обязано быть 200000
}

Вы можете ожидать 200000. Иногда вы увидите 200000. Иногда — меньше. А иногда (в особенно «удачный» день) программа может повести себя совсем странно, потому что Undefined Behavior означает: стандарт языка не обязан объяснять, что будет.

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

Кстати, сам факт того, что слово race воспринимается как серьёзная проблема, виден даже по формулировкам в материалах по стандартной библиотеке: встречаются пункты вида «promise::set_value() and promise::get_future() should not race». Это хороший маркер: гонки — не «учебная страшилка», а реальная инженерная боль, из-за которой правят стандарты и спецификации.

Минимальная «безопасная дисциплина» на сегодня

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

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

Есть два безопасных паттерна, которые уже доступны на нашем уровне.

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

#include <iostream>
#include <thread>

int main() {
    int result = 0;

    std::thread t([&] {
        result = 42; // запись в worker
    });

    t.join();                    // дождались конца worker
    std::cout << result << '\n';  // 42
}

Здесь нет одновременного доступа к result: пока поток работает, main() не читает переменную. А когда main() читает — поток уже завершён.

Второй паттерн — «каждому потоку своё». То есть вместо одной общей переменной делаем две независимые, и каждый поток пишет только в свою. Потом, когда оба завершились, складываем.

#include <iostream>
#include <thread>

int main() {
    int a = 0;
    int b = 0;

    std::thread t1([&] { a = 10; });
    std::thread t2([&] { b = 32; });

    t1.join();
    t2.join();

    std::cout << (a + b) << '\n'; // 42
}

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

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

Где именно прячется гонка

Полезно уметь «видеть» гонку глазами. Вот типичная картина для ++counter, если разложить её на шаги:

Поток 1: read(counter) -> tmp1
Поток 1: tmp1 = tmp1 + 1
Поток 1: write(counter, tmp1)

Поток 2: read(counter) -> tmp2
Поток 2: tmp2 = tmp2 + 1
Поток 2: write(counter, tmp2)

А теперь представьте, что эти шаги перемешались:

counter = 5
t1: read -> tmp1 = 5
t2: read -> tmp2 = 5
t1: write 6
t2: write 6   (перетёр результат t1)

В итоге два увеличения дали +1 вместо +2. Это уже «видимая» проблема. Но в C++ неприятность глубже: компилятор может оптимизировать код, исходя из предположения, что data race нет (потому что так написано в правилах языка). И тогда поведение может стать ещё более неожиданным.

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

Ошибка №1: думать, что потоки выполняются «как строки кода».
Очень человеческая привычка: если в main() сначала создаётся поток t1, потом t2, значит t1 «раньше», а t2 «позже». В реальности оба потока могут стартовать почти одновременно, а кто из них реально выполнит свой код первым — решает планировщик ОС. Поэтому любые рассуждения «я поставлю это ниже — и оно будет позже» в многопоточности не работают.

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

Ошибка №3: считать ++counter атомарной операцией.
Психологически ++ выглядит как «один оператор», значит «один шаг». Но на практике это read-modify-write. В одном потоке это безопасно, в нескольких — нет, если вы не построили правило доступа. Именно поэтому счётчики — самый частый учебный пример гонок: они обманывают глаз своей простотой.

Ошибка №4: путать race condition и data race.
Race condition — это про логику и порядок событий: «кто успел первым» и почему из‑за этого результат меняется. Data race — это про память и правила языка: «одновременный доступ с записью без согласованного протокола», что приводит к Undefined Behavior. Можно написать программу без data race, но с race condition (логика всё равно зависит от порядка). И наоборот, можно получить data race там, где логика кажется «очевидной».

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

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