1.4.1. Здравствуй, параллельный мир
Начнем с классического примерапрограммы, которая печатает фразу «Здравствуй, мир». Ниже приведена тривиальная однопоточная программа такого сорта, от нее мы будем отталкиваться при переходе к нескольким потокам.
#include <iostream>
int main() {
std::cout << "Здравствуй, мир\n";
}
Эта программа всего лишь выводит строку Здравствуй мир в стандартный поток вывода. Сравним ее с простой программой «Здравствуй, параллельный мир», показанной в листинге 1.1,в ней для вывода сообщения запускается отдельный поток.
#include <iostream>
#include <thread>
(1)
void hello()
(2)
{
std::cout << "Здравствуй, параллельный мир\n";
}
int
main() {
std::thread t(hello);
(3)
t.join();
(4)
}
Прежде всего, отметим наличие дополнительной директивы #include <thread>
(1). Все объявления, необходимые для поддержки многопоточности, помещены в новые заголовочные файлы; функции и классы для управления потоками объявлены в файле <thread>
, а те, что нужны для защиты разделяемых данных,в других заголовках.
Далее, код вывода сообщения перемещен в отдельную функцию (2). Это объясняется тем, что в каждом потоке должна быть начальная функция, в которой начинается исполнение потока. Для первого потока в приложении таковой является main()
, а для всех остальных задается в конструкторе объекта std::thread
. В данном случае в качестве начальной функции объекта типа std::thread
, названного t
(3), выступает функция hello()
.
Есть и еще одно отличие вместо того, чтобы сразу писать на стандартный вывод или вызывать hello()
из main()
, эта программа запускает новый поток, так что теперь общее число потоков равно двум: главный, с начальной функцией main()
, и дополнительный, начинающий работу в функции hello()
.
После запуска нового потока (3) начальный поток продолжает работать. Если бы он не ждал завершения нового потока, то просто дошел бы до конца main()
, после чего исполнение программы закончилась бы быть может, еще до того, как у нового потока появился шанс начать работу. Чтобы предотвратить такое развитие событие, мы добавили обращение к функции join()
(4); в главе 2 объясняется, что это заставляет вызывающий поток (main()
) ждать завершения потока, ассоциированного с объектом std::thread
,в данном случае t
.
Если вам показалось, что для элементарного вывода сообщения на стандартный вывод работы слишком много, то так оно и есть,в разделе 1.2.3 выше мы говорили, что обычно для решения такой простой задачи не имеет смысла создавать несколько потоков, особенно если главному потоку в это время нечего делать. Но далее мы встретимся с примерами, когда запуск нескольких потоков дает очевидный выигрыш.
1.5. Резюме
В этой главе мы говорили о том, что такое параллелизм и многопоточность и почему стоит (или не стоит) использовать их в программах. Мы также рассмотрели историю многопоточности в С++от полного отсутствия поддержки в стандарте 1998 года через различные платформенно-зависимые расширения к полноценной поддержке в новом стандарте С++11. Эта поддержка, появившаяся очень вовремя, дает программистам возможность воспользоваться преимуществами аппаратного параллелизма, которые стали доступны в современных процессорах, поскольку их производители пошли но пути наращивания мощности за счет реализации нескольких ядер, а не увеличения быстродействия одного ядра.
Мы также видели (пример в разделе 1.4), как просто использовать классы и функции из стандартной библиотеки С++. В С++ использование нескольких потоков само по себе несложно сложно спроектировать программу так, чтобы она вела себя, как задумано.
Закусив примерами из раздела 1.4, пора приступить к чему-нибудь более питательному. В главе 1 мы рассмотрим классы и функции для управления потоками.
Глава 2.Управление потоками
В этой главе:
Запуск потоков и различные способы задания кода, исполняемого в новом потоке.
Ждать завершения потока или позволить ему работать независимо?
Уникальные идентификаторы потоков.
Итак, вы решили написать параллельную программу, а конкретноиспользовать несколько потоков. И что теперь? Как запустить потоки, как узнать, что поток завершился, и как отслеживать их выполнение? Средства, имеющиеся в стандартной библиотеке, позволяют относительно просто решить большинство задач управления потоками. Как мы увидим, почти все делается с помощью объекта std::thread
, ассоциированного с потоком. Для более сложных задач библиотека позволяет построить то, что нужно, из простейших кирпичиком.
Мы начнем эту главу с рассмотрения базовых операций: запуск потока, ожидание его завершения, исполнение в фоновом режиме. Затем мы поговорим о передаче дополнительных параметров функции потока в момент запуска и о том, как передать владение потока от одного объекта std::thread
другому. Наконец, мы обсудим вопрос о том, сколько запускать потоков и как идентифицировать отдельный поток.
2.1. Базовые операции управления потоками
В каждой программе на С++ имеется по меньшей мере один поток, запускаемый средой исполнения С++: тот, в котором исполняется функция main()
. Затем программа может запускать дополнительные потоки с другими функциями в качестве точки входа. Эти потоки работают параллельно друг с другом и с начальным потоком. Мы знаем, что программа завершает работу, когда main()
возвращает управление; точно так же, при возврате из точки входа в поток этот поток завершается. Ниже мы увидим, что, имея объект std::thread
для некоторого потока, мы можем дождаться завершения этого потока, но сначала посмотрим, как потоки запускаются.
2.1.1. Запуск потока
В главе 1 мы видели, что для запуска потока следует сконструировать объект std::thread
, который определяет, какая задача будет исполняться в потоке. В простейшем случае задача представляет собой обычную функцию без параметров, возвращающую void
. Эта функция работает в своем потоке, пока не вернет управление, и в этом момент поток завершается. С другой стороны, в роли задачи может выступать объект-функция, который принимает дополнительные параметры и выполняет ряд независимых операций, информацию о которых получает во время работы от той или иной системы передачи сообщений. И останавливается такой поток, когда получит соответствующий сигнал, опять же с помощью системы передачи сообщений. Вне зависимости от того, что поток будет делать и откуда он запускается, сам запуск потока в стандартном С++ всегда сводится к конструированию объекта std::thread
:
void do_some_work();
std::thread my_thread(do_some_work);
Как видите, все просто. Разумеется, как и во многих других случаях в стандартной библиотеке С++, класс std::thread
работает с любым типом, допускающим вызов (Callable), поэтому конструктору std::thread
можно передать экземпляр класса, в котором определен оператор вызова:
class background_task {
public:
void operator()() const {
do_something();
do_something_else();
}
};
background_task f;
std::thread my_thread(f);
В данном случае переданный объект-функция копируется в память, принадлежащую только что созданному потоку выполнения, и оттуда вызывается. Поэтому необходимо, чтобы с точки зрения поведения копия была эквивалентна оригиналу, иначе можно получить неожиданный результат.
При передаче объекта-функции конструктору потока нужно избегать феномена «самого досадного разбора в С++» (C++'s most vexing parse). Синтаксически передача конструктору временного объекта вместо именованной переменной выглядит так же, как объявление функции, и именно так компилятор и интерпретирует эту конструкцию. Например, в предложении
std::thread my_thread(background_task());
объявлена функция my_thread
, принимающая единственный параметр (типа указателя на функцию без параметров, которая возвращает объект background_task
) и возвращающая объект std::thread
. Никакой новый поток здесь не запускается. Решить эту проблему можно тремя способами: поименовать объект-функцию, как в примере выше; добавить лишнюю пару скобок или воспользоваться новым универсальным синтаксисом инициализации, например:
std::thread my_thread((background_task()));
(1)
std::thread my_thread{background_task()};
(2)
В случае (1) наличие дополнительных скобок не дает компилятору интерпретировать конструкцию как объявление функции, так что действительно объявляется переменная my_thread
типа std::thread
. В случае (2) использован новый универсальный синтаксис инициализации с фигурными, а не круглыми скобками, он тоже приводит к объявлению переменной.
В стандарте С++11 имеется новый тип допускающего вызов объекта, в котором описанная проблема не возникает,лямбда-выражение. Этот механизм позволяет написать локальную функцию, которая может захватывать некоторые локальные переменные, из-за чего передавать дополнительные аргументы просто не нужно (см. раздел 2.2). Подробная информация о лямбда-выражениях приведена в разделе А.5 приложения А. С помощью лямбда-выражений предыдущий пример можно записать в таком виде:
std::thread my_thread([](
do_something();
do_something_else();
});
После запуска потока необходимо явно решить, ждать его завершения (присоединившись к нему, см. раздел 2.1.2) или предоставить собственной судьбе (отсоединив его, см. раздел 2.1.3). Если это решение не будет принято к моменту уничтожения объекта std::thread
, то программа завершится (деструктор std::thread
вызовет функцию std::terminate()
). Поэтому вы обязаны гарантировать, что поток корректно присоединен либо отсоединен, даже если возможны исключения. Соответствующая техника программирования описана в разделе 2.1.3. Отметим, что это решение следует принять именно до уничтожения объекта std::thread
, к самому потоку оно не имеет отношения. Поток вполне может завершиться задолго до того, как программа присоединится к нему или отсоединит его. А отсоединенный поток может продолжать работу и после уничтожения объекта std::thread
.
Если вы не хотите дожидаться завершения потока, то должны гарантировать, что данные, к которым поток обращается, остаются действительными до тех пор, пока они могут ему понадобиться. Эта проблема не нова даже в однопоточной программа доступ к уже уничтоженному объекту считается неопределенным поведением, но при использовании потоков есть больше шансов столкнуться с проблемами, обусловленными временем жизни.
Например, такая проблема возникает, если функция потока хранит указатели или ссылки на локальные переменные, и поток еще не завершился, когда произошел выход из области видимости, где эти переменные определены. Соответствующий пример приведен в листинге 2.1.
Листинг 2.1. Функция возвращает управление, когда поток имеет доступ к определенным в ней локальным переменным
struct func {
int& i;
func(int& i_) : i(i_){}
void operator() () {
for(unsigned j = 0; j < 1000000; ++j) {
do_something(i);
Потенциальный доступ
}
(1) к висячей ссылке
}
};
void oops() {
int some_local_state = 0;
(2) He ждем завершения
func my_func(some_local_state);
потока
std::thread my_thread(my_func);
Новый поток, возможно,
my_thread.detach();
(3) еще работает
}
В данном случае вполне возможно, что новый поток, ассоциированный с объектом my_thread
, будет еще работать, когда функция oops
вернет управление (2), поскольку мы явно решили не дожидаться его завершения, вызвав detach()
(3). А если поток действительно работает, то при следующем вызове do_something(i)
(1) произойдет обращение к уже уничтоженной переменной. Точно так же происходит в обычном однопоточном кодесохранять указатель или ссылку на локальную переменную после выхода из функции всегда плохо, но в многопоточном коде такую ошибку сделать проще, потому что не сразу видно, что произошло.
Один из распространенных способов разрешить такую ситуациюсделать функцию потока замкнутой, то есть копировать в поток данные, а не разделять их. Если функция потока реализовала в виде вызываемого объекта, то сам этот объект копируется в поток, поэтому исходный объект можно сразу же уничтожить. Однако по-прежнему необходимо следить за тем, чтобы объект не содержал ссылок или указателей, как в листинге 2.1. В частности, не стоит создавать внутри функции поток, имеющий доступ к локальным переменным этой функции, если нет гарантии, что поток завершится до выхода из функции.
Есть и другой способявно гарантировать, что поток завершит исполнение до выхода из функции, присоединившись к нему.
2.1.2. Ожидание завершения потока
Чтобы дождаться завершения потока, следует вызвать функцию join()
ассоциированного объекта std::thread
. В листинге 2.1 мы можем заменить вызов my_thread.detach()
перед закрывающей скобкой тела функции вызовом my_thread.join()
, и тем самым гарантировать, что поток завершится до выхода из функции, то есть раньше, чем будут уничтожены локальные переменные. В данном случае это означает, что запускать функцию в отдельном потоке не имело смысла, так как первый поток в это время ничего не делает, по в реальной программе исходный поток мог бы либо сам делать что-то полезное, либо запустить несколько потоков параллельно, а потом дождаться их всех.
Функция join()
дает очень простую и прямолинейную альтернативулибо мы ждем завершения потока, либо нет. Если необходим более точный контроль над ожиданием потока, например если необходимо проверить, завершился ли поток, или ждать только ограниченное время, то следует прибегнуть к другим механизмам, таким, как условные переменные и будущие результаты, которые мы будем рассматривать в главе 4. Кроме тот, при вызове join()
очищается вся ассоциированная с потоком память, так что объект std::thread
более не связан с завершившимся потокомон вообще не связан ни с каким потоком. Это значит, что для каждого потока вызвать функцию join()
можно только один раз; после первого вызова объект std::thread
уже не допускает присоединения, и функция joinable()
возвращает false
.
2.1.3. Ожидание в случае исключения
Выше уже отмечалось, что функцию join()
или detach()
необходимо вызвать до уничтожения объекта std::thread
. Если вы хотите отсоединить поток, то обычно достаточно вызвать detach()
сразу после его запуска, так что здесь проблемы не возникает. Но если вы собираетесь дождаться завершения потока, то надо тщательно выбирать место, куда поместить вызов join()
. Важно, чтобы из-за исключения, произошедшего между запуском потока и вызовом join()
, не оказалось, что обращение к join()
вообще окажется пропущенным.
Чтобы приложение не завершилось аварийно при возникновении исключения, необходимо решить, что делать в этом случае. Вообще говоря, если вы намеревались вызвать функцию join()
при нормальном выполнении программы, то следует вызывать ее и в случае исключения, чтобы избежать проблем, связанных с временем жизни. В листинге 2.2 приведен простой способ решения этой задачи.