JavaRush /Курсы /C++ SELF /std::thread — запуск потока, аргументы, join

std::thread — запуск потока, аргументы, join

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

1. Минимальный запуск потока: функция и лямбда

Когда вы впервые слышите «создадим поток», кажется, что это что-то нематериальное: ну типа «пусть где-то параллельно покрутится код». На практике в C++ поток — это ресурс операционной системы, и стандартная библиотека даёт вам объект, который этим ресурсом владеет. Как и с std::unique_ptr: владелец есть — ответственность есть.

std::thread — это объект, который при создании запускает новый поток выполнения и «держит за него ответственность». С этого момента у вас два независимых маршрута выполнения внутри одного процесса: main продолжает жить своей жизнью, а новый поток начинает выполнять то, что вы ему дали. И вот тут важно: поток — не магия, а дисциплина. Если вы создали поток, вы обязаны корректно дождаться его завершения через join() (по крайней мере на уровне сегодняшней лекции).

Самый простой способ почувствовать std::thread — запустить что-нибудь короткое и увидеть, что порядок действий может «плавать». Для работы нам нужен заголовок <thread>. Печатать будем через <iostream>.

Запуск потока с обычной функцией

Поток стартует прямо в конструкторе std::thread: вы передаёте callable (например, функцию), и он начинает выполняться.

#include <iostream>
#include <thread>

void say_hello() {
    std::cout << "hello from worker\n";
}

int main() {
    std::thread t(say_hello); // поток стартует здесь
    t.join();                 // ждём завершение
    std::cout << "done\n";    // done
}

Обратите внимание на мысль: join() — это не «опция», а часть контракта владения. Мы создали поток — мы обязаны дождаться, пока он закончится.

Запуск потока лямбдой

Лямбда удобнее, потому что не нужно отдельно объявлять функцию, особенно если работа маленькая.

#include <iostream>
#include <thread>

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

    std::cout << "main: hi\n"; // может напечататься до или после worker
    t.join();
}

Тут мы снова видим недетерминизм: строка "main: hi" может оказаться как первой, так и второй. Это не «глюк компилятора», это нормальная жизнь потоков.

3. Аргументы потока: копии, ссылки и перемещение

Когда поток запускает функцию, почти всегда нужно передать данные: строку, число, контейнер, настройки. И тут у новичков случается классический сюрприз: «Я передал переменную, но в потоке она почему-то не изменилась». Обычно причина простая: вы передали копию, а ожидали «работу с оригиналом».

Важно запомнить модель: std::thread хранит внутри копии/перемещённые значения аргументов (это часто описывают как decay-copy). В стандарте вокруг этого даже были обсуждения про «лишний decay» в thread (и родственных механизмах), что намекает: поведение с преобразованием/копированием аргументов — значимая часть дизайна.

Передача по значению

Передадим строку и число:

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

void repeat(std::string text, int n) {
    for (int i = 0; i < n; ++i) {
        std::cout << text << '\n';
    }
}

int main() {
    std::thread t(repeat, std::string{"Hi"}, 2);
    t.join();
}

Здесь text — копия строки (или перемещённая строка, в зависимости от того, что вы передали). Это удобно: поток не зависит от времени жизни переменной в main.

«Почему изменения не вернулись?» — потому что была копия

#include <iostream>
#include <thread>

void inc(int x) {
    ++x; // увеличиваем копию
}

int main() {
    int value = 10;
    std::thread t(inc, value);
    t.join();

    std::cout << value << '\n'; // 10
}

Логика простая: value копируется в аргумент x. Поток честно увеличил x, но value остался прежним.

Передача по ссылке: std::ref и std::cref

Если вы действительно хотите, чтобы функция в потоке работала с оригинальной переменной, вам нужно явно сказать: «передай ссылку». Для этого обычно используют std::ref (для изменяемой ссылки) и std::cref (для const ссылки). Они лежат в <functional>.

Очень важно: передача по ссылке почти автоматически повышает риск ошибок времени жизни и гонок. Поэтому на сегодняшнем уровне мы делаем так: поток что-то записал → мы сделали join() → только после этого читаем результат.

#include <functional>
#include <iostream>
#include <thread>

void inc_ref(int& x) {
    ++x;
}

int main() {
    int value = 10;

    std::thread t(inc_ref, std::ref(value)); // ВАЖНО: std::ref
    t.join();

    std::cout << value << '\n'; // 11
}

Если убрать std::ref, снова будет копия, и value не изменится.

Мини‑таблица: как передаются аргументы

Что вы пишете при запуске потока Что реально получает функция Типичный эффект
t(f, x)
копию x поток не меняет оригинал
t(f, std::ref(x))
ссылку на x поток может менять оригинал
t(f, std::cref(x))
const-ссылку на x поток читает без копий

Эту таблицу полезно держать в голове как «первую помощь», когда вы видите странное поведение.

Передача move-only и «дорогих» объектов: используем std::move

Иногда вы хотите передать в поток большой std::vector, или объект, который нельзя копировать. Тогда нужно перемещение.

#include <iostream>
#include <thread>
#include <utility>
#include <vector>

void process(std::vector<int> v) {
    std::cout << "size = " << v.size() << '\n'; // size = 3
}

int main() {
    std::vector<int> data{1, 2, 3};

    std::thread t(process, std::move(data)); // перемещаем в поток
    t.join();

    std::cout << "after move, data.size() = " << data.size() << '\n';
}

После std::move объект в main остаётся валидным, но его состояние «после перемещения» не стоит использовать как будто там всё на месте. Для вектора часто это будет size() == 0, но полагаться на это как на строгую гарантию не надо.

4. join(): где ставить и что он гарантирует

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

join() делает две практичные вещи: во-первых, блокирует текущий поток (обычно main) до завершения рабочего потока. Во-вторых, превращает объект std::thread в «уже не владеющий активным потоком». Это важно, потому что join() нельзя вызвать дважды на одном и том же потоке.

Блок‑схема жизненного цикла std::thread

flowchart TD
    A["Создали std::thread t(...)"] --> B["t стал joinable()"]
    B --> C["Рабочий поток выполняет функцию"]
    C --> D["main вызывает t.join()"]
    D --> E["main ждёт завершения потока"]
    E --> F["Поток завершён, t больше не joinable()"]

Смысл диаграммы в том, что joinable — это состояние ответственности: пока поток joinable, его нельзя просто так «бросить».

joinable() как страховка от двойного join()

Иногда вы пишете более сложную логику (например, ранний return), и хочется безопасно «проверить перед join».

#include <iostream>
#include <thread>

int main() {
    std::thread t([] { /* work */ });

    if (t.joinable()) {
        t.join();
    }

    std::cout << "ok\n"; // ok
}

Если попытаться вызвать join() ещё раз после этого — будет ошибка времени выполнения (обычно исключение std::system_error). На практическом уровне: «join — один раз».

Что будет, если забыть join()

Если объект std::thread уничтожится, пока он всё ещё владеет выполняющимся потоком, стандарт требует аварийного завершения программы через std::terminate. Это сделано специально: чтобы вы не писали «фоновые потоки без ответственности» случайно.

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

std::thread нельзя копировать, но можно перемещать

В какой-то момент вы попробуете сделать так:

std::thread t1([]{});
std::thread t2 = t1; // так нельзя

И компилятор скажет: «копирование удалено». Это логично: поток — это владение. Если бы копирование было разрешено, то два объекта «владели» бы одним и тем же потоком, а в конце оба попытались бы завершить его «по контракту» — и началась бы трагикомедия.

Поэтому std::thread — move-only: его можно перемещать (как std::unique_ptr).

#include <iostream>
#include <thread>
#include <utility>

int main() {
    std::thread t1([] { /* work */ });

    std::thread t2 = std::move(t1);

    std::cout << std::boolalpha
              << "t1.joinable(): " << t1.joinable() << '\n'
              << "t2.joinable(): " << t2.joinable() << '\n';

    t2.join();
}

После перемещения ответственность «переехала» в t2. А t1 стал пустым (не joinable). И это ещё одна причина любить joinable(): он помогает не перепутать владельца.

5. Мини‑пример TextStats: считаем статистику текста

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

Модель данных: struct Stats

#include <cstddef>

struct Stats {
    std::size_t chars = 0;
    std::size_t words = 0;
};

Ничего хитрого: это просто контейнер для результата.

Подсчёт слов: простая функция

Чтобы не утонуть в тонкостях Unicode и пунктуации, считаем слова как «последовательности непробельных символов».

#include <string_view>

std::size_t count_words(std::string_view s) {
    std::size_t words = 0;
    bool in_word = false;

    for (char c : s) {
        const bool is_space = (c == ' ' || c == '\t' || c == '\n');
        if (!is_space && !in_word) { ++words; in_word = true; }
        if (is_space) { in_word = false; }
    }
    return words;
}

Да, это «игрушечная» токенизация, но она идеальна для сегодняшней цели: дать потоку понятную работу.

Рабочая функция для потока: пишет результат в Stats

Здесь мы используем ссылку на Stats, поэтому понадобится std::ref при запуске потока.

#include <string>
#include <string_view>

void compute_stats(std::string_view text, Stats& out) {
    out.chars = text.size();
    out.words = count_words(text);
}

main: запускаем поток и делаем join()

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

int main() {
    std::cout << "Enter a line:\n";

    std::string line;
    std::getline(std::cin, line);

    Stats stats;

    std::thread worker(compute_stats, std::string_view{line}, std::ref(stats));

    std::cout << "Computing...\n"; // Computing...
    worker.join();                 // ждём

    std::cout << "chars = " << stats.chars << '\n';
    std::cout << "words = " << stats.words << '\n';
}

Обратите внимание на аккуратный «контракт времени жизни»: line живёт до конца main, а мы делаем join() до того, как line исчезнет. Значит, std::string_view{line} внутри потока не станет висячим. На сегодняшнем уровне это отличный «правильный» шаблон: передали ссылку/представление → гарантировали, что владелец живёт достаточно долго → дождались join().

Два потока, два результата: делим работу без общей записи

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

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

Функция «посчитать слова на диапазоне» и вернуть число

Для простоты мы посчитаем слова на string_view и вернём результат как число, но вернуть из потока напрямую мы пока не будем (механизмы «вернуть результат позже» — отдельная тема). Поэтому используем выходной параметр.

#include <cstddef>
#include <string_view>

void count_words_into(std::string_view s, std::size_t& out) {
    out = count_words(s);
}

Запускаем два потока и складываем

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

int main() {
    std::string line;
    std::getline(std::cin, line);

    const std::size_t mid = line.size() / 2;

    std::size_t w1 = 0;
    std::size_t w2 = 0;

    std::thread t1(count_words_into, std::string_view{line}.substr(0, mid), std::ref(w1));
    std::thread t2(count_words_into, std::string_view{line}.substr(mid),     std::ref(w2));

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

    std::cout << "approx words = " << (w1 + w2) << '\n';
}

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

6. Типичные ошибки при работе с std::thread, аргументами и join()

Ошибка №1: поток создали, а join() забыли.
Это самый частый сценарий у новичков: «я просто хотел попробовать» — и внезапно программа аварийно завершается. Тут важно принять философию C++: если объект владеет ресурсом, то у вас должен быть явный, читаемый путь освобождения/завершения этого ресурса. Для std::thread сегодня это означает только одно: поток обязан дожить до join().

Ошибка №2: ожидают, что t(f, x) передаст ссылку, а не копию.
Очень легко написать std::thread(inc_ref, value) и ждать, что value увеличится. Но по умолчанию аргументы «упаковываются» в поток как отдельные значения, и вы работаете с копией. Если вам нужна ссылка, используйте std::ref(value) (или std::cref, если чтение). Это не «прихоть библиотеки», а защита от случайных висячих ссылок и от неявного разделения изменяемых данных.

Ошибка №3: передают ссылку на объект, который не доживает до завершения потока.
Лямбды с захватом по ссылке, ссылки в аргументах потока, string_view на локальную строку — всё это прекрасно работает ровно до момента, пока объект-владелец не выйдет из области видимости. Поэтому базовое правило выживания: если передали ссылку/представление, убедитесь, что владелец живёт дольше, чем поток, и что join() случится до конца жизни владельца.

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

Ошибка №5: вызывают join() дважды или вызывают join() не на том объекте после std::move.
join() — одноразовое действие. После него поток уже завершён, а объект перестаёт быть joinable(). Отдельно неприятная ситуация — переместили std::thread в другой объект и по привычке вызвали join() на старом. Решение простое и практичное: после перемещения всегда проверяйте, кто joinable(), и держите перемещение потоков редким и заметным (чтобы глаз цеплялся).

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