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(), і намагайтеся робити переміщення потоків рідкісними та помітними.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ