1. Навіщо потрібен std::jthread
Коли ви вперше пишете багатопотоковий код, він часто виглядає невинно: «запущу потік, він щось зробить, а далі якось розберуся». Проблема в тому, що std::thread дуже суворий: якщо обʼєкт потоку знищується, поки потік ще виконується, стандартна бібліотека воліє не вгадувати, що саме ви хотіли зробити, а завершує процес за аварійним сценарієм. Із погляду мови це чесніше, ніж підхід «ну, інколи працює».
std::jthread зʼявився як дружніший до новачків варіант: він намагається зробити безпечну поведінку сценарієм за замовчуванням. Ідея проста: якщо потік — це ресурс, то варто керувати ним як ресурсом у стилі RAII (так само, як ми робили це з файлами, контейнерами й розумними вказівниками): створили обʼєкт — ресурс захоплено, вийшли з області видимості — ресурс звільнено.
std::thread vs std::jthread: потік у стилі RAII
Якщо дивитися на std::thread і std::jthread як на «ручний» і «автоматичний» режими, то у std::thread ви самі маєте не забути вимкнути світло, зачинити двері й нагодувати кота (тобто викликати join() або свідомо detach()). У std::jthread кіт, звісно, усе ще ваш, але двері хоча б самі зачиняться: під час знищення обʼєкта std::jthread потік буде коректно завершено через join(), а додатково надійде запит на зупинку (про це ми поговоримо, коли дійдемо до stop_token).
Зведімо це в таблицю — так легше запамʼятати, що саме доводиться контролювати вручну.
| Властивість | |
|
|---|---|---|
| Що буде, якщо обʼєкт знищиться, поки потік працює | аварійне завершення процесу (неприємний сюрприз) | коректне очікування завершення (без сюрпризу, але очікування може бути довгим) |
| Чи потрібно вручну викликати join() | так, майже завжди | можна, але найчастіше не потрібно |
| Підтримка «ввічливого скасування» | вручну, власними механізмами | вбудована через stop_token |
| Копіювання | не можна (move-only) | не можна (move-only) |
| Головна ідея | «ви дорослі, тож відповідаєте самі» | «давайте зробимо безпечно за замовчуванням» |
Невеликий технічний — і трохи занудний, але корисний — факт: навіть у документах стандарту обговорюють тонкощі вимог до аргументів конструкторів thread/jthread (наприклад, що параметри мають бути move-constructible). Це добрий маркер того, що модель володіння й переміщення для потоків — не «деталь реалізації», а частина дизайну.
2. Запуск std::jthread: безпечне завершення під час виходу з області видимості
Перше знайомство зі std::jthread зазвичай викликає думку: «Та це ж майже std::thread». І це правда: ви так само передаєте функцію або лямбду й аргументи. Але ключова відмінність проявляється не в момент запуску, а в момент виходу з області видимості.
Найкоротший приклад: потік живе стільки ж, скільки змінна
#include <iostream>
#include <thread>
int main() {
{
std::jthread t([] {
std::cout << "worker: hi\n"; // worker: hi
});
std::cout << "main: inside scope\n"; // main: inside scope
} // t знищується -> потік гарантовано завершено
std::cout << "main: after scope\n"; // main: after scope
}
Зауважте: порядок рядків "worker" і "main" усередині області видимості все одно може змінюватися — недетермінізм нікуди не зник. Але після виходу з області видимості ви вже точно не отримаєте ситуацію на кшталт «потік ще працює, а ми вже пішли».
join() усе ще існує й інколи потрібен
RAII не забороняє вам явно сказати: «почекай на завершення ось тут». Іноді це корисно, бо робить код простішим для читання: одразу видно конкретну точку, де ми чекаємо.
#include <iostream>
#include <thread>
int main() {
std::jthread t([] {
std::cout << "worker: done\n"; // worker: done
});
t.join(); // явна точка очікування
std::cout << "main: continue\n"; // main: continue
}
4. std::stop_token: кооперативна зупинка потоку
Якщо частина зі RAII у std::jthread розвʼязує проблему «я забув завершити потік», то вона не розвʼязує іншу поширену проблему: «у мене потік узагалі-то нескінченний (або дуже довгий), і мені потрібно вміти попросити його зупинитися».
Тут важливе слово — «попросити». У стандартному C++ немає кнопки «безпечно й коректно вбити потік» на рівні мови. І це не тому, що розробники мови «не здогадалися». Примусова зупинка майже гарантовано ламає інваріанти: потік міг утримувати ресурс, бути посеред операції, записувати дані, і таке «вбивство» залишить програму в напівзламаному стані.
std::stop_token дає змогу реалізувати кооперативне скасування:
- хтось ззовні надсилає запит на зупинку,
- потік усередині періодично перевіряє: «а чи не попросили мене завершитися?»,
- і виходить у безпечній точці.
Мінісхема протоколу зупинки
flowchart TD
A[main: створюємо jthread] --> B[worker: цикл роботи]
B --> C{Чи запитано зупинку?}
C -- ні --> B
C -- так --> D[worker: акуратно завершуємо роботу]
A --> E["main: request_stop()"]
E --> C
Сенс цієї схеми в тому, що зупинка — це протокол, а не «магічна команда».
5. Як std::jthread передає stop_token у функцію потоку
Найпрактичніша частина: std::jthread уміє сам передавати std::stop_token у вашу функцію потоку, якщо вона вміє його приймати.
Є важлива деталь, яку новачкам легко пропустити: токен передається лише тоді, коли callable (функція або лямбда) має параметр std::stop_token (зазвичай перший). Якщо параметра немає, jthread запустить функцію як завжди, але «просити зупинитися» буде майже без сенсу: потік просто не перевіряє запит.
Потік, який уміє зупинятися
#include <iostream>
#include <stop_token>
#include <thread>
void worker(std::stop_token st) {
while (!st.stop_requested()) {
// імітуємо "роботу"
std::cout << "tick\n"; // tick
std::this_thread::sleep_for(std::chrono::milliseconds{50});
}
std::cout << "worker: stop observed\n"; // worker: stop observed
}
І ось як це використовувати:
#include <chrono>
#include <thread>
int main() {
std::jthread t(worker);
std::this_thread::sleep_for(std::chrono::milliseconds{130});
t.request_stop(); // попросили зупинитися
// join можна не писати: під час виходу з main RAII усе доробить
}
Потік, який не вміє зупинятися, і чому це небезпечно
Уявіть нескінченний цикл без перевірки токена. Тоді jthread під час руйнування зробить «за протоколом» request_stop() + join(), але join() чекатиме нескінченно, бо потік ніколи не завершиться. Це дуже типова пастка: «я поставив jthread, отже, тепер усе безпечно». Ні: jthread безпечніший, але не чарівник.
6. Практика: фоновий індикатор роботи в консольному застосунку
Щоб не залишатися на рівні абстрактних «tick», давайте вплетемо цю тему в невеликий, реалістичний фрагмент консольного застосунку, який ми розвивали раніше (CLI-утиліта з командами, логами тощо). Поширене завдання: є команда, яка виконується довго (мережа, файли, обчислення), і хочеться показати користувачеві, що програма жива.
Важливо: ми не використовуємо затримки як засіб синхронізації. sleep_for нижче — лише імітація тривалої роботи й спосіб зробити приклади наочнішими.
Фоновий «спінер», який можна зупинити
#include <chrono>
#include <iostream>
#include <stop_token>
#include <thread>
void spinner(std::stop_token st) {
const char frames[] = {'|', '/', '-', '\\'};
std::size_t i = 0;
while (!st.stop_requested()) {
std::cout << "\rworking... " << frames[i % 4] << std::flush;
++i;
std::this_thread::sleep_for(std::chrono::milliseconds{80});
}
std::cout << "\rworking... done\n"; // working... done
}
Тут є дві невеликі «хитрощі для початківців». По‑перше, "\r" повертає каретку на початок рядка, і ми перемальовуємо той самий рядок, щоб не засмічувати консоль сотнею рядків. По‑друге, std::flush змушує виведення одразу зʼявитися на екрані.
«Довга операція» в нашому застосунку
#include <chrono>
#include <thread>
void simulate_long_task() {
std::this_thread::sleep_for(std::chrono::milliseconds{600});
}
Склеюємо все разом: запускаємо спінер як jthread, виконуємо роботу, просимо зупинитися
#include <thread>
int main() {
std::jthread ui(spinner);
simulate_long_task();
ui.request_stop(); // попросили спінер завершитися
}
Зверніть увагу на красу — і водночас підступність — цього рішення. З одного боку, код вийшов коротким і приємним: спінер живе «поруч» з операцією. З іншого — це працює лише тому, що спінер написаний правильно: він регулярно перевіряє stop_requested().
Чому це саме «кероване завершення»
У цій лекції важливо закріпити головний сенс звʼязки jthread + stop_token: ми робимо завершення потоку керованим у двох вимірах.
Перший вимір — керування часом життя через RAII. Обʼєкт std::jthread живе в конкретній області видимості, і це стає наочною межею фонової активності. Це корисно не лише компʼютеру, а й людині: читаєте код і відразу бачите, де фон починається і де закінчується.
Другий вимір — керування зупинкою через протокол. Замість підходу «потік колись сам помре» у нас є чітка домовленість: зовнішній код може зробити request_stop(), а внутрішній код зобовʼязаний періодично перевіряти токен і завершуватися в безпечній точці.
Окремо важливо проговорити й обмеження: stop_token розвʼязує лише питання зупинки. Він не відповідає на запитання «як безпечно ділити дані між потоками». Якщо ви почнете одночасно читати й записувати спільну змінну без чітких правил, це вже інший клас проблем. Ми свідомо не заглиблюємося в нього тут, щоб не перетворювати лекцію на формат «трохи про mutex, трохи про atomics, а потім усі плачуть».
7. Типові помилки під час роботи з std::jthread і std::stop_token
Помилка № 1: думати, що std::jthread «сам зупинить» будь-який потік.
jthread справді зробить за вас важливу частину роботи під час виходу з області видимості, але він не вміє читати ваші думки. Якщо функція потоку — це нескінченний цикл без перевірок токена, jthread чесно чекатиме його завершення. У найкращому разі ви отримаєте «зависання під час виходу», у найгіршому — довго шукатимете, чому програма не закривається.
Помилка № 2: викликати request_stop() й очікувати миттєвої зупинки.
request_stop() — це не «вбивство потоку». Це запит. Потік побачить його лише тоді, коли дійде до перевірки stop_requested(). Якщо перевірка стоїть раз на 10 секунд, то й реакція на зупинку може тривати до 10 секунд. Це нормально: ви самі обираєте частоту перевірок і тим самим визначаєте баланс між чутливістю та простотою коду.
Помилка № 3: використовувати sleep_for як «синхронізацію».
Іноді хочеться зробити так: «я посплю 100 мс, і потік точно встигне оновити змінну». Це дуже слизька доріжка: на іншому компʼютері, під іншим навантаженням або просто в іншій фазі планувальника все розвалиться. sleep_for годиться для імітації роботи й демонстрацій, але не задає правил доступу до даних і не робить програму коректною.
Помилка № 4: запускати потік, який використовує посилання на локальні змінні, не думаючи про час життя.
Навіть із jthread можна написати небезпечний код, якщо потік тримає посилання на обʼєкт, який зникне раніше, ніж завершиться потік. У цій лекції ми свідомо намагалися писати приклади так, щоб потік жив або «всередині тієї самої області видимості», або використовував дані, що існують достатньо довго. Щойно ви виходите за ці межі, треба зупинитися й дуже чесно відповісти собі: «А хто володіє даними і хто гарантує, що вони ще живі?»
Помилка № 5: перетворювати stop_token на «універсальний інструмент багатопотоковості».
stop_token розвʼязує одне завдання: кооперативне скасування. Він не робить виведення в консоль захищеним від перемішування, не захищає контейнери від конкурентного доступу й не замінює архітектуру. Якщо тримати це в голові, jthread + stop_token стають чудовим, простим і безпечним інструментом для «фонової активності з чіткими межами життя».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ