1. Введение
Когда вы впервые слышите «общие данные», в голове обычно всплывает одна картинка: «две функции трогают одну переменную». Это правда, но слишком узко.
На практике общий ресурс — это всё, что может быть использовано несколькими потоками и при этом имеет состояние или наблюдаемое поведение (например, вывод в консоль). И вот тут начинаются сюрпризы: общий ресурс — это не только int counter, но и std::cout, и файл на диске, и сокет, и даже «общий порядок действий».
Важно поймать интуицию: если два потока могут воздействовать на один и тот же «объект мира» (память/файл/консоль/устройство), то вам нужен ответ на вопрос: по каким правилам мы этим делимся? Если ответа нет, у вас не «чуть рискованно», а «рано или поздно будет больно».
Категории общих ресурсов
Когда мы говорим «ресурс», полезно разложить всё по полочкам, иначе вы будете искать data race только в counter, а он окажется, например, в логировании. Давайте посмотрим на практичную карту общих ресурсов.
Ниже таблица, которая помогает быстро классифицировать, что именно у вас «общее», и почему оно может ломаться.
| Категория общего ресурса | Пример | Что может пойти не так | Типичная «первая помощь» (без mutex-ов) |
|---|---|---|---|
| Общая память (данные в RAM) | |
Data race, UB, «иногда работает» | Не трогать одновременно; обмениваться данными только на границе (после join()) |
| Вывод (I/O) | |
Перемешанные строки, потеря частей сообщения | Собирать сообщение в строку и печатать одним куском; печатать из одного потока |
| Файлы на диске | |
Повреждённый файл, «каша» из строк | Запись только из одного потока (временно) |
| Внешние сервисы | База данных, API | Состояние меняется неожиданно, конфликт операций | Сводить изменения к одному «владельцу» (один поток решает) |
| Время и порядок событий | «сначала посчитай, потом покажи» | Race condition: «показали» раньше, чем «посчитали» | Явно задавать точку «готово» (join()) и не полагаться на задержки |
Заметьте важный психологический момент: std::cout — это тоже общий ресурс. И это не «какая-то мелочь»: очень многие впервые видят конкурентность именно потому, что строки в консоли внезапно перемешались.
Отдельно интересно, что сама стандартная библиотека всерьёз относится к теме «избегания data race» вокруг контейнеров — даже формулировки и требования к контейнерам обсуждаются через призму data race avoidance.
Почему общий ресурс требует дизайна, а не пары костылей
Очень хочется сделать так: «О, у меня гонка — сейчас вставлю sleep_for(10ms) (или “просто подожду”), и всё станет стабильно». Увы, это классический путь в мир багов, которые появляются только по пятницам вечером, когда вы уже мысленно дома.
Проблема в том, что в многопоточности вам мешает не только скорость, а неопределённость планировщика. Потоки могут переключаться между инструкциями в любой момент. Поэтому:
Если вы не договорились, кто и когда трогает общий ресурс, вы получаете race condition: «иногда успели правильно, иногда нет».
Если вы одновременно читаете/пишете одну и ту же память без протокола — вы получаете data race. В C++ это особенно жёстко: формально программа уходит в UB, то есть компилятор и процессор могут вести себя так, как вам не понравится.
Важно прочувствовать разницу между «логика сломалась» и «язык сказал: это вне правил». Race condition часто можно «поймать» тестами и отладкой. Data race может не ловиться неделями, а потом превратиться в странный краш в месте, которое «вообще не связано».
2. Синхронизация начинается с границ владения
Перед тем как в будущем мы возьмём в руки mutex, надо усвоить более фундаментальную мысль: синхронизация — это не инструмент, а договор. Инструменты (мьютексы, атомики, условные переменные) — это уже «как обеспечить договор технически». А сначала нужно понять «о чём договор».
Хороший дизайн многопоточного кода почти всегда отвечает на три вопроса.
Первый вопрос — кто владелец данных. Владелец — это тот, кто имеет право изменять состояние. Если «владельцев» два, то вы обязаны заранее придумать протокол, иначе вы просто открыли дверь data race.
Второй вопрос — кто читатель. Чтение тоже бывает опасным, если рядом идёт запись. Поэтому часто проще договориться, что читатель получает «снимок» (копию) или читает только после завершения работы писателя.
Третий вопрос — где граница обмена. Самая простая граница, доступная нам уже сейчас — это join(): пока поток не завершился, другой поток не трогает его результаты. С точки зрения начинающего — это почти как «сдать работу преподавателю и только после этого получить оценку». Никаких «я подсмотрю, что он там уже написал на половине решения».
Нарисуем это как схему владения:
flowchart LR
M[main thread<br/>владелец tasks] -->|делает снимок| S[local snapshot<br/>копия tasks]
S --> W[worker thread<br/>работает только с копией]
W -->|формирует результат| R[report string]
R -->|после join| M
Идея проста: общий ресурс (tasks) не становится «общим» только потому, что мы запустили второй поток. Мы специально организуем дизайн так, чтобы tasks оставался под контролем одного владельца.
4. Стратегии без mutex-ов: не делиться изменяемым
На этом этапе курса у нас ещё нет полноценного «арсенала синхронизации» (он будет дальше). Но хорошая новость: многие задачи можно сделать безопасно, вообще не входя в драку за общую память.
Thread confinement: данные живут в одном потоке
Самая простая стратегия звучит почти слишком по-детски: «пусть это делает один поток». И внезапно она очень рабочая.
Если у вас есть структура данных (например, std::vector<Task> tasks), вы можете договориться, что только main-поток её меняет. Фоновый поток либо не трогает tasks вообще, либо получает копию (снимок). Это называется «изоляция данных потоком»: данные как бы «закрыты» внутри одного потока.
Плюс подхода в том, что он прост и почти не ломается. Минус в том, что иногда хочется фоновой обработкой менять состояние. Но до тех пор, пока у нас нет мьютексов и очередей, это честный и безопасный вариант.
Snapshot: копия вместо общего доступа
Если потоку нужно что-то прочитать, пусть он читает копию. Да, копирование — это работа. Но на первых шагах многопоточности копирование часто дешевле, чем отладка data race (и уж точно дешевле, чем ваша нервная система).
Мини-идея на коде: мы берём tasks, копируем в snapshot, и работаем с ним в потоке.
#include <vector>
#include <string>
struct Task {
int id{};
std::string text;
bool done{};
};
std::vector<Task> make_snapshot(const std::vector<Task>& tasks) {
return tasks; // копия: snapshot живёт отдельно
}
Здесь важно, что snapshot больше не зависит от того, что происходит с tasks в main-потоке.
Обмен результатом после завершения: join как граница
Ещё одна «железобетонная» стратегия: поток считает результат, записывает его в переменную, а другой поток читает только после join().
Это не магия, это дисциплина: пока поток работает, его результат считается «черновиком», и мы его не трогаем.
Кооперативная остановка: почему stop_token сделан правильно
std::stop_token интересен тем, что это пример «общего механизма», который изначально спроектирован под многопоточность. Внешний код может запросить остановку, внутренний — проверить, запрошена ли она.
То есть stop_token — это не «мы взяли bool cancel и расшарили». Это специальный тип, который живёт по правилам многопоточности и задуман как канал кооперативной отмены. В стандарте даже правят и уточняют разделы, связанные со stop token/stopsource, потому что это реально используемый механизм конкурентности.
Практическая мораль: если вам кажется, что «нужно просто расшарить флажок», остановитесь и подумайте. Очень часто правильный путь — использовать механизм, который уже имеет корректную модель многопоточности, а не изобретать «общий bool».
5. Пример: фоновой отчёт в TaskBoard
Представим, что по ходу курса мы писали консольное приложение TaskBoard: задачи хранятся в std::vector<Task>, мы умеем добавлять задачу, помечать выполненной и печатать список. Теперь захотелось сделать «отчёт» (например, «выполнено 7 из 10»), и пусть он считается в отдельном потоке, потому что в реальной жизни отчёт может быть тяжёлым (чтение из файла, агрегация, парсинг и т.д.).
База: модель Task и функция генерации отчёта
Начнём с маленькой функции, которая из списка задач делает строку отчёта. Пока без потоков.
#include <string>
#include <vector>
struct Task {
int id{};
std::string text;
bool done{};
};
std::string build_report(const std::vector<Task>& tasks) {
int done_count = 0;
for (const auto& t : tasks) if (t.done) ++done_count;
return "Done: " + std::to_string(done_count) + "/" + std::to_string(tasks.size());
}
Обратите внимание: функция принимает const std::vector<Task>&. Мы явно говорим: «я читаю, но не меняю». Это ещё не «потокобезопасность», но это уже аккуратный контракт.
Плохой вариант: расшарить tasks и «пусть читает»
Теперь давайте сделаем то, что мозг новичка предлагает первым: запустим поток, который читает tasks, пока main-поток продолжает их менять.
#include <iostream>
#include <thread>
#include <vector>
int main() {
std::vector<Task> tasks; // main может менять tasks
std::thread t([&] {
std::cout << build_report(tasks) << '\n'; // ОПАСНО: tasks общий и изменяемый
});
tasks.push_back(Task{1, "Learn threads", false}); // main меняет tasks
t.join();
}
Снаружи выглядит невинно: «ну поток просто печатает». А по сути мы сделали общий изменяемый ресурс без протокола. Поток читает tasks, а main в это время делает push_back. Это классический путь к data race и к совсем странным эффектам.
И здесь важный факт: стандартная библиотека не обещает вам, что контейнер типа std::vector будет счастлив, если один поток меняет его размер, а другой читает в этот же момент. Даже обсуждения про требования и «data race avoidance» в стандарте вокруг контейнеров возникают не просто так.
Хороший вариант: snapshot и работа только с копией
Исправим дизайн: main-поток остаётся владельцем tasks, а worker получает копию snapshot и строит отчёт из неё.
#include <iostream>
#include <thread>
#include <vector>
int main() {
std::vector<Task> tasks;
tasks.push_back(Task{1, "Learn threads", true});
const std::vector<Task> snapshot = tasks; // копия
std::thread t([snapshot] {
std::cout << build_report(snapshot) << '\n'; // Done: 1/1
});
tasks.push_back(Task{2, "Write report", false}); // меняем оригинал, но поток его не видит
t.join();
}
Тут есть лёгкая цена: копирование. Но есть огромный плюс: вы больше не зависите от порядка переключений потоков. Поток работает с «замороженной фотографией» данных.
Хороший вариант: печать отчёта после join
Ещё один аккуратный способ: поток формирует строку отчёта, main ждёт завершения, потом печатает. Смысл в том, что нет одновременного доступа: пока поток пишет report, main не читает report.
#include <iostream>
#include <thread>
#include <vector>
int main() {
std::vector<Task> tasks;
tasks.push_back(Task{1, "Learn join()", true});
std::string report;
std::thread t([&] {
report = build_report(tasks); // ОК, если main не трогает tasks и report параллельно
});
t.join();
std::cout << report << '\n'; // Done: 1/1
}
Но обратите внимание на тонкость: этот вариант безопасен только если main не меняет tasks, пока поток строит отчёт. То есть фактически мы снова упираемся в «договор»: либо делаем snapshot, либо замораживаем изменения на время работы потока.
В реальном приложении «замораживать» часто неудобно — поэтому снимок обычно практичнее.
Про прогресс: это уже следующий уровень
В этот момент обычно возникает желание: «Пусть worker обновляет progress, а main показывает его в процентах!». И вот здесь почти всегда появляется shared int progress, который два потока трогают одновременно.
Плохая новость: если progress — обычный int, то одновременные чтения/записи без протокола снова приводят к data race. Хорошая новость: вы правильно чувствуете потребность в синхронизации — просто инструменты для корректного прогресса мы будем разбирать позже (и это будет отдельная тема).
На сегодня лучше принять честное ограничение: либо поток печатает прогресс сам (и да, вывод может перемешаться), либо мы показываем прогресс после завершения. Некрасиво, зато корректно.
6. Типичные ошибки
Ошибка №1: считать общими данными только переменные, а std::cout не считать.
Новички часто ищут проблемы только в памяти, забывая, что вывод — это тоже общий ресурс. В итоге получают мешанину строк и начинают «лечить» это задержками. Гораздо полезнее сразу признать: вывод общий, и если несколько потоков пишут одновременно, порядок строк не гарантирован.
Ошибка №2: «я же только читаю, значит безопасно».
Чтение безопасно только тогда, когда никто рядом не пишет. Если один поток делает push_back в std::vector, а другой одновременно читает элементы, это уже не «один читает, другой пишет — нормально», а потенциальная data race. Логика «чтение не меняет» здесь не спасает, потому что контейнер может менять внутреннюю память.
Ошибка №3: передать в поток ссылки на объекты без явной гарантии времени жизни.
Даже если вы делаете join(), легко случайно захватить по ссылке что-то, что выйдет из scope раньше, чем поток закончит работу (особенно если вы когда-то использовали detach()). Правило, которое спасает начинающих: по умолчанию передавайте данные в поток по значению или делайте копию-снимок.
Ошибка №4: пытаться синхронизироваться через sleep_for.
Задержка не задаёт правил доступа к данным. Она лишь меняет вероятность, что баг проявится. Иногда даже хуже: баг становится «редким» и начинает жить своей жизнью. Если вам нужен порядок «сначала сделай, потом используй», честный способ для текущего уровня — поставить явную границу (join()) или передавать копию.
Ошибка №5: «быстро сделать общий флаг bool cancel».
Почти всегда это приводит к одновременному чтению/записи этого флага и к data race. Если вам нужна кооперативная остановка — используйте std::stop_token, который для этого и существует. Если вам нужна более сложная координация — это уже тема следующих лекций про синхронизацию.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ