JavaRush /Курсы /C++ SELF /Общие данные: что считать общим ресурсом и

Общие данные: что считать общим ресурсом и

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

1. Введение

Когда вы впервые слышите «общие данные», в голове обычно всплывает одна картинка: «две функции трогают одну переменную». Это правда, но слишком узко.

На практике общий ресурс — это всё, что может быть использовано несколькими потоками и при этом имеет состояние или наблюдаемое поведение (например, вывод в консоль). И вот тут начинаются сюрпризы: общий ресурс — это не только int counter, но и std::cout, и файл на диске, и сокет, и даже «общий порядок действий».

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

Категории общих ресурсов

Когда мы говорим «ресурс», полезно разложить всё по полочкам, иначе вы будете искать data race только в counter, а он окажется, например, в логировании. Давайте посмотрим на практичную карту общих ресурсов.

Ниже таблица, которая помогает быстро классифицировать, что именно у вас «общее», и почему оно может ломаться.

Категория общего ресурса Пример Что может пойти не так Типичная «первая помощь» (без mutex-ов)
Общая память (данные в RAM)
int, std::string, std::vector<Task>
Data race, UB, «иногда работает» Не трогать одновременно; обмениваться данными только на границе (после join())
Вывод (I/O)
std::cout, лог-файл
Перемешанные строки, потеря частей сообщения Собирать сообщение в строку и печатать одним куском; печатать из одного потока
Файлы на диске
std::ofstream в два потока
Повреждённый файл, «каша» из строк Запись только из одного потока (временно)
Внешние сервисы База данных, 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, который для этого и существует. Если вам нужна более сложная координация — это уже тема следующих лекций про синхронизацию.

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