Перейти к содержимому
Теория · Многопоточность: обзор · урок 1 из 12

Потоки: зачем и как

Чему вы научитесь

Чему вы научитесь:

  • запускать функцию в отдельном потоке и дожидаться его через join();
  • объяснять, что такое гонка данных, и защищать общие данные мьютексом;
  • выбирать между мьютексом и std::atomic;
  • забирать результат из другого потока через std::async и считать время типами из <chrono>.
Два повара у одного стола работают одновременно по карточкам main и worker, у них общий холодильник; шаг join() на карточке main ждёт салат

Представьте кухню ресторана. Повар готовит суп по карточке рецепта: шаг за шагом, по одной строке. Пока он режет овощи, салат никто не делает, хотя рядом есть свободное место у стола. Второй повар мог бы встать туда и делать салат одновременно с супом. У каждого повара своя карточка, а холодильник у них общий.

Программа устроена так же. Она выполняет инструкции по одной: пока функция считает, остальная работа ждёт. При этом в процессоре обычно несколько ядер — это рабочие места у стола. Каждое ядро выполняет свою последовательность инструкций одновременно с остальными.

Поток — это повар со своей карточкой: последовательность инструкций, которая выполняется независимо от других. У каждой программы есть хотя бы один поток — тот, что выполняет main. Чтобы две задачи шли одновременно, нужен второй поток. Его запускает объект std::thread из заголовка <thread>:

C++
#include <iostream>
#include <thread>

void print_squares() {
    for (int i = 1; i <= 3; ++i) {
        std::cout << i * i << ' ';  // 1 4 9
    }
}

int main() {
    std::thread worker(print_squares);
    worker.join();
    std::cout << "done\n";  // done
}

Здесь аналогия с местами у стола заканчивается: потоков можно запустить больше, чем ядер. Тогда ядро быстро переключается между потоками, и снаружи их работа всё равно выглядит одновременной.

Запуск и ожидание

Давайте разберём main по строкам:

  1. std::thread worker(print_squares); создаёт поток и сразу запускает в нём print_squares. Функция main при этом не ждёт: следующая строка выполняется в главном потоке одновременно с новым.
  2. worker.join(); останавливает main, пока поток worker не закончит работу. Так главный повар ждёт, пока второй доделает салат.
  3. std::cout << "done\n"; выполняется только после этого, поэтому done всегда печатается после квадратов.

Если бы main печатала done до join(), вывод двух потоков мог бы перемешаться. Порядок, в котором потоки выполняют свои инструкции, заранее неизвестен.

Аргументы и общая память

Аргументы для функции передают в конструктор после её имени: std::thread t(f, 1, 2) вызовет в новом потоке f(1, 2). Вместо функции можно передать и лямбду.

Все потоки программы работают с общей памятью, как повара с общим холодильником: глобальная переменная у них одна на всех.

В задачах курса потоков нет: код с std::thread или std::async там не заработает, поэтому примеры этой главы запускайте на своём компьютере.

detach и jthread

Вместо join() можно вызвать detach(): поток продолжит работать сам по себе, но дождаться его будет уже нельзя. Это рискованно. Если такой поток читает локальные переменные функции, которая его запустила, он может обратиться к ним после их уничтожения. Это неопределённое поведение.

В C++20 появился std::jthread. Запускают его так же, как std::thread, но деструктор std::jthread сам вызывает join(). Поэтому забыть дождаться такого потока нельзя.

Частая ошибка

Поток запустили, но не дождались:

C++
int main() {
    std::thread worker(print_squares);
    std::cout << "done\n";
}  // ✗
TEXT
terminate called without an active exception

Программа собирается без предупреждений. Но при выходе из main объект worker уничтожается, хотя для него не вызван ни join(), ни detach(). В таком случае деструктор std::thread вызывает std::terminate, и программа завершается аварийно. Вызовите join() до конца области видимости или используйте std::jthread.

Главное

  • Поток — последовательность инструкций, которая выполняется одновременно с другими; std::thread запускает функцию в новом потоке.
  • join() ждёт, пока поток закончит работу; порядок инструкций разных потоков заранее неизвестен.
  • Все потоки программы работают с одной общей памятью.
  • Объект std::thread без join() или detach() при уничтожении вызывает std::terminate; std::jthread вызывает join() сам.

Подпишись, чтобы пройти курс

Курс бесплатный. Войди через Telegram и подпишись на канал — откроются все уроки.

Войти другим способом