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 как индикатор конкурентности: увидели хаос — вспомнили, что порядок не гарантирован.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ