Многопоточность в C++. Управление потоками
У каждой программы на 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
, и позволяет функции проверить, была ли запрошена остановка во время ее выполнения, и завершиться при необходимости.
- Подробнее о
jthread
. - Также существует возможность связать callback функции с событием остановки потока.
Управление текущим потоком
Стандартная библиотека предоставляет несколько методов для управления текущим потоком. Все они находятся в пространстве имён 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
.