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 не изменится.
Мини‑таблица: как передаются аргументы
| Что вы пишете при запуске потока | Что реально получает функция | Типичный эффект |
|---|---|---|
|
копию x | поток не меняет оригинал |
|
ссылку на 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(), и держите перемещение потоков редким и заметным (чтобы глаз цеплялся).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ