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(), і намагайтеся робити переміщення потоків рідкісними та помітними.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ