Многопоточность в C++. Управление потоками

Добавлено 21 декабря 2021 в 01:22

У каждой программы на C++ есть как минимум один поток, запускаемый средой выполнения C++, – поток, выполняющий функцию main(). Затем программа может запустить дополнительные потоки, точкой входа в которые служит другая функция. После чего эти потоки и начальный поток выполняются одновременно. Аналогично завершению программы при выходе из main() поток завершается при возвращении из функции, указанной в качестве точки входа.

std::thread

Основной класс для создания новых потоков в C++ – это std::thread.

Кратко:

  • Объект класса представляет собой один поток выполнения.
  • Новый поток начинает выполнение сразу же после построения объекта std::thread. Выполнение начинается с функции верхнего уровня, которая передаётся в качестве аргумента в конструктор std::thread.
  • Возвращаемое значение этой функции игнорируется, а если в ней будет брошено исключение, которое не будет обработано в этом же потоке, то вызовется std::terminate.
  • Передать возвращаемое значение или исключение из нового потока наружу можно через std::promise или через глобальные переменные (работа с которыми потребует синхронизации, см. std::mutex и std::atomic).
  • Объекты std::thread также могут быть не связаны ни с каким потоком (после default construction, move from, detach или join), и поток выполнения может быть не связан ни с каким объектом std::thread (после detach).
  • Никакие два объекта std::thread не могут представлять один и тот же поток выполнения; std::thread нельзя копировать (не является CopyConstructible или CopyAssignable), но можно перемещать (является MoveConstructible и MoveAssignable).

Потоки запускаются созданием объекта std::thread, в котором определяется выполняемая в потоке задача. В простейшем случае эта задача представляет собой обычную функцию. Эта функция выполняется в собственном потоке, пока не вернет управление, после чего поток останавливается. Что бы ни собирался делать поток, и откуда бы он ни запускался, его запуск с использованием стандартной библиотеки C++ всегда сводится к созданию объекта std::thread:

void do_some_work();
std::thread my_thread(do_some_work);

std::thread работает с любым вызываемым типом, поэтому конструктору std::thread можно также передать экземпляр класса с оператором вызова функции:

class background_task{
public:
    void operator()() const {
        do_something();
        do_something_else();
    }
};
background_task f;
std::thread my_thread(f);

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

С помощью лямбда-выражения предыдущий пример можно записать следующим образом:

std::thread my_thread([]{
    do_something();
    do_something_else();
});

После запуска потока, нужно принять однозначное решение, ждать ли его завершения (join) или пустить его на самотек (detach). Если не принять решение до уничтожения объекта std::thread, то программа завершится (деструктор std::thread вызовет std::terminate()). Решение нужно принимать до того, как объект std::thread будет уничтожен. Сам же поток вполне мог бы завершиться задолго до его присоединения или отсоединения. Если его отсоединить, то при условии, что он всё еще выполняется, он и будет выполняться, и этот процесс может продолжаться еще долго и после уничтожения объекта std::thread. Выполнение будет прекращено, только когда в конце концов произойдет возвращение из функции потока. Если не дожидаться завершения потока, необходимо убедиться, что данные, к которым он обращается, будут действительны, пока он не закончит работать с ними.

Дождаться завершения потока можно, вызвав join() для связанного экземпляра std::thread. Вызов join() приводит к очистке объекта std::thread, поэтому объект std::thread больше не связан с завершенным потоком. Мало того, он не связан ни с одним потоком. Это означает, что join() можно вызвать для конкретного потока только один раз: как только вызван метод join(), объект std::thread утрачивает возможность присоединения, а метод joinable() вернет значение false.

Вызов метода detach() для объекта std::thread позволяет потоку выполняться в фоновом режиме, непосредственное взаимодействие с ним не требуется. Возможность дождаться завершения этого потока исчезает: если поток отсоединяется, получить ссылающийся на него объект std::thread невозможно, поэтому такой поток больше нельзя присоединить. Отсоединенные потоки фактически выполняются в фоновом режиме, владение и управление ими передаются в библиотеку среды выполнения C++, которая гарантирует правильное высвобождение ресурсов, связанных с потоком, при выходе из него. Как правило, такие потоки являются весьма продолжительными, работая в течение практически всего времени жизни приложения и выполняя фоновую задачу, например отслеживая состояние файловой системы, удаляя неиспользуемые записи из кэш-памяти объектов или оптимизируя структуры данных. Метод detach() нельзя вызывать для объекта std::thread, не имеющего связанного с ним потока выполнения. Это требование аналогично тому, которое предъявляется к вызову метода join(), и проверку можно провести точно таким же образом – вызывать для объекта t типа std::thread метод t.detach() возможно, только если метод t.joinable() вернет значение true.

Передача аргументов вызываемому объекту или функции сводится к простой передаче дополнительных аргументов конструктору std::thread. Но важно учесть, что по умолчанию аргументы копируются во внутреннее хранилище, где к ним может получить доступ вновь созданный поток выполнения, а затем передаются вызываемому объекту или функции как r-значения (rvalues), как будто они временные. Так делается, даже если соответствующий параметр в функции ожидает ссылку. Рассмотрим пример:

void f(int i,std::string const& s);
std::thread t(f,3,"hello");

В результате создается новый поток выполнения, связанный с t, который вызывает функцию f(3,"hello"). Обратите внимание: даже если f в качестве второго параметра принимает std::string, строковый литерал передается как char const* и преобразуется в std::string только в контексте нового потока. Это становится особенно важным, когда, как показано далее, предоставленный аргумент является указателем на локальную переменную:

void f(int i,std::string const& s);
void oops(int some_param) {
    char buffer[1024];
    sprintf(buffer, "%i",some_param);
    std::thread t(f,3,buffer);
    t.detach();
}

Здесь это указатель на буфер локальной переменной, который передается в новый поток. И высока вероятность того, что выход из функции oops произойдет, прежде чем буфер будет в новом потоке преобразован в std::string, что вызовет неопределенное поведение. Решением является приведение к типу std::string перед передачей буфера в конструктор std::thread:

void f(int i,std::string const& s);
void oops(int some_param) {
    char buffer[1024];
    sprintf(buffer, "%i", some_param);
    std::thread t(f, 3, std::string(buffer));
    t.detach();
}
void update_data_for_widget(widget_id w, widget_data& data);
void oops_again(widget_id w){    
    widget_data data;
    std::thread t(update_data_for_widget,w,data);
    display_status();
    t.join();
    process_widget_data(data);
}

Хотя update_data_for_widget ожидает, что второй параметр будет передан по ссылке, конструктор std::thread не знает об этом, он не обращает внимания на типы аргументов, которые ожидает функция, и слепо копирует предоставленные значения. Но внутренний код передает скопированные аргументы в качестве r-значений, чтобы работать с типами, предназначенными только для перемещений, и пытается таким образом вызвать update_data_for_widget с r-значением. Этот код не скомпилируется, так как нельзя передать r-значение функции, ожидающей не-const-ссылку. Для тех, кто знаком с std::bind, решение будет очевидным: аргументы, которые должны быть ссылками, следует заключать в std::ref. В этом случае при изменении вызова потока на:

std::thread t(update_data_for_widget,w,std::ref(data));

update_data_for_widget будет корректно передана ссылка на данные, а не временная копия данных, и код успешно скомпилируется. Если работать с std::bind уже приходилось, то в семантике передачи параметров не будет ничего нового, поскольку и операция конструктора std::thread, и операция std::bind определены в рамках одного и того же механизма.

Чтобы вызвать в отдельном потоке метод какого-ибо объекта, нужно передать указатель на объект в качестве первого аргумента этого метода:

class X {
public:
    void do_lengthy_work();
};
X my_x;
std::thread t(&X::do_lengthy_work, &my_x);

Этот код вызовет my_x.do_lengthy_work() в новом потоке, поскольку в качестве указателя на объект предоставляется адрес my_x.

Еще один интересный сценарий предоставления аргументов применяется, когда аргументы нельзя скопировать, а можно только переместить. Примером может послужить тип std::unique_ptr, обеспечивающий автоматическое управление памятью для динамически выделяемых объектов. В одно и то же время на данный объект может указывать только один экземпляр std::unique_ptr, и когда этот экземпляр уничтожается, объект, на который он указывал, удаляется. Перемещающий конструктор и перемещающий оператор присваивания позволяют передавать права владения объектом между экземплярами std::unique_ptr. В результате этого исходный объект остается с нулевым указателем. Такое перемещение значений позволяет принимать объекты данного типа в качестве параметров функции или возвращать их из функций. Если исходный объект временный, перемещение выполняется автоматически, но если источником является именованное значение, передача должна быть запрошена напрямую путем вызова метода std::move(). В следующем примере показано использование std::move для передачи потоку права владения динамическим объектом:

void process_big_object(std::unique_ptr<big_object>);
std::unique_ptr<big_object> p(new big_object);
p->prepare_data(42);
std::thread t(process_big_object,std::move(p));

Поскольку при вызове конструктора std::thread указан метод std::move(p), право владения big_object сначала передается внутреннему хранилищу вновь созданного потока, а затем переходит к process_big_object.

Мы разобрали основы использования класса std::thread для создания потоков. У объектов std::thread есть ещё пара полезных методов:

  • std::thread::get_id() возвращает id потока. Можно использовать для логирования или в качестве ключа ассоциативного контейнера потоков.
  • std::thread::native_handle() возвращает специфичный для операционной системы handle потока, который можно передавать в методы WinAPI или pthreads для более гибкого управления потоками.

Выбор количества потоков в ходе выполнения программы

Одна из функций стандартной библиотеки C++, помогающая решить данную задачу, – std::thread::hardware_concurrency(). Она возвращает то количество потоков, которые действительно могут работать одновременно в ходе выполнения программы. Например, в многоядерной системе оно может быть увязано с числом ядер центрального процессора. Функция дает всего лишь подсказку и может вернуть 0, если информация недоступна, но ее данные способны принести пользу при разбиении задачи на потоки.

std::jthread

В С++20 появился новый класс для создания потоков и управления ими std::jthread.

Класс jthread представляет собой один поток выполнения. Он имеет то же поведение, что и std::thread, за исключением того, что jthread автоматически join'ится при уничтожении и предлагает интерфейс для остановки потока.

В отличие от std::thread, jthread содержит внутренний закрытый член типа std::stop_source, который хранит stop-state. Конструктор jthread принимает функцию, которая принимает std::stop_token в качестве своего первого аргумента. Этот аргумент передаётся в функцию из stop_source, и позволяет функции проверить, была ли запрошена остановка во время ее выполнения, и завершиться при необходимости.

Управление текущим потоком

Стандартная библиотека предоставляет несколько методов для управления текущим потоком. Все они находятся в пространстве имён std::this_thread:

  • std::this_thread::yield() подсказывает планировщику потоков перепланировать выполнение, приостановив текущий поток и отдав преимущество другим потокам. Точное поведение этой функции зависит от реализации, в частности от механики используемого планировщика ОС и состояния системы. Например, планировщик реального времени first-in-first-out (SCHED_FIFO в Linux) приостанавливает текущий поток и помещает его в конец очереди потоков с одинаковым приоритетом, готовых к запуску (если нет других потоков с таким же приоритетом, yield не делает ничего).
  • std::this_thread::get_id() работает аналогично std::thread::get_id().
  • std::this_thread::sleep_for(sleep_duration) блокирует выполнение текущего потока на время sleep_duration.
  • std::this_thread::sleep_until(sleep_time) блокирует выполнение текущего потока до наступления момента времени sleep_time.

Теги

C++ / Cppstd::threadSTL / Standard Template Library / Стандартная библиотека шаблоновМногопоточность

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.