- PVSM.RU - https://www.pvsm.ru -
Статья рассматривает проблемы в std::thread, попутно разрешая древний спор на тему "что использовать: pthread_cancel, булев флаг или boost::thread::interrupt?"
У класса std::thread, который добавили в C++11, есть одна неприятная особенность — он не соответствует с идиоме RAII (Resource Acquisition Is Initialization) [1]. Выдержка из стандарта [2]:
30.3.1.3 thread destructor
~thread();
If joinable() then terminate(), otherwise no effects.
Чем нам грозит такой деструктор? Программист должен быть очень аккуратен, когда речь идёт об разрушении объекта std::thread
:
void dangerous_thread()
{
std::thread t([] { do_something(); });
do_another_thing(); // may throw - can cause termination!
t.join();
}
Если из функции do_another_thing
вылетит исключение, то деструктор std::thread
завершит всю программу, вызвав std::terminate
. Что с этим можно сделать? Давайте попробуем написать RAII-обёртку вокруг std::thread
и посмотрим, куда нас приведёт эта попытка.
class thread_wrapper
{
public:
// Constructors
~thread_wrapper()
{ reset(); }
void reset()
{
if (joinable())
{
// ???
}
}
// Other methods
private:
std::thread _impl;
};
thread_wrapper
копирует интерфейс std::thread
и реализует ещё одну дополнительную функцию — reset
. Эта функция должна перевести поток в non-joinable состояние. Деструктор вызывает эту функцию, так что после этого _impl
разрушится, не вызывая std::terminate
.
Для того, чтобы перевести _impl
в non-joinable состояние, у reset
есть два варианта: detach
или join
. Проблема с detach
в том, что поток продолжит выполняться, сея хаос и нарушая идиому RAII. Так что наш выбор — это join
:
thread_wrapper::reset()
{
if (joinable())
join();
}
К сожалению, такая реализация thread_wrapper
ничем не лучше, чем обычный std::thread
. Почему? Давайте рассмотрим следующий пример использования:
void use_thread()
{
std::atomic<bool> alive{true};
thread_wrapper t([&alive] { while(alive) do_something(); });
do_another_thing();
alive = false;
}
Если из do_another_thing
вылетит исключение, то аварийного завершения не произойдёт. Однако, вызов join
из деструктора thread_wrapper
зависнет навечно, потому что alive
никогда не примет значение false
, и поток никогда не завершится.
Всё дело в том, что у объекта thread_wrapper
нет способа повлиять на выполняемую функцию, для того чтобы "попросить" её завершиться. Ситуация усложняется ещё и тем, что в функции do_something
поток выполнения вполне может "уснуть" на условной переменной или в блокирующем вызове операционной системы.
Таким образом, для решения проблемы с деструктором std::thread
необходимо решить более серьёзную проблему:
Как прервать выполнение длительной функции, особенно если в этой функции поток выполнения может "уснуть" на условной переменной или в блокирующем вызове ОС?
Частный случай этой проблемы — это прерывание потока выполнения целиком. Давайте рассмотрим три существующих способа для прерывания потока выполнения: pthread_cancel
, boost::thread::interrupt
и булев флаг.
Отправляет выбранному потоку запрос на прерывание. Спецификация POSIX содержит особый список [4] прерываемых функций (read
, write
и т.д.). После вызова pthread_cancel
для какого-нибудь потока эти функции в данном потоке начинают кидать исключение особого типа. Это исключение нельзя проигнорировать — catch-блок, поймавший такое исключение, обязан кинуть его дальше, поэтому это исключение полностью разматывает стек потока и завершает его. Поток может на время запретить прерывание своих вызовов с помощью функции pthread_setcancelstate
(одно из возможных применений: чтобы избежать исключений из деструкторов, функций логгирования и т.п.).
Плюсы:
Минусы:
pthread_cancel
в Windows, он также отсутствует в некоторых реализациях libc (например, в bionic, который используется в Android)std::condition_variable::wait
в C++14 и более поздних стандартахclose
является прерываемой функцией)Проблемы с std::condition_variable::wait
появляются из-за того, что в C++14 std::condition_variable::wait
получил спецификацию noexcept
. Если разрешить прерывания с помощью pthread_setcancelstate
, то мы теряем возможность прерывать ожидание на условых переменных, а если прерывания будут разрешены, то у нас нет возможности соответствовать спецификации noexcept
, потому что мы не можем "проглотить" это особое исключение.
Библиотека Boost.Thread предоставляет опциональный механизм прерывания потоков, чем-то похожий на pthread_cancel
. Для того, чтобы прервать поток выполнения, достаточно позвать у соответствующего ему объекта boost::thread
метод interrupt
. Проверить состояния текущего потока можно с помощью функции boost::this_thread::interruption_point
: в прерванном потоке эта функция кидает исключение типа boost::thread_interrupted
. В случае, если использование исключений запрещено с помощью BOOST_NO_EXCEPTIONS, то для проверки состояния можно использовать boost::this_thread::interruption_requested
. Boost.Thread также позволяет прерывать ожидание в boost::condition_variable::wait
. Для реализации этого используется thread-local storage и дополнительный мьютекс внутри условной переменной.
Плюсы:
boost::condition_variable::wait
Минусы:
condition_variable
condition_variable::wait
Если почитать на StackOverflow вопросы про pthread_cancel
(1 [6], 2 [7], 3 [8], 4 [9]), то один из самых популярных ответов: "Используйте вместо pthread_cancel
булев флаг".
Атомарная переменная alive
в нашем примере с исключениями — это и есть булев флаг:
void use_thread()
{
std::atomic<bool> alive{true};
thread_wrapper t([&alive] { while(alive) do_something(); });
do_another_thing(); // may throw
alive = false;
}
Плюсы:
Минусы:
Что делать? Давайте возьмём за основу булев флаг и начнём решать связанные с ним проблемы. Дупликация кода? Отлично — давайте завернём булев флаг в отдельный класс. Назовём его cancellation_token
.
class cancellation_token
{
public:
explicit operator bool() const
{ return !_cancelled; }
void cancel()
{ _cancelled = true; }
private:
std::atomic<bool> _cancelled;
};
Теперь можно положить cancellation_token
в наш thread_wrapper
:
class thread_wrapper
{
public:
// Constructors
~thread_wrapper()
{ reset(); }
void reset()
{
if (joinable())
{
_token.cancel();
_impl.join();
}
}
// Other methods
private:
std::thread _impl;
cancellation_token _token;
};
Отлично, теперь осталось только передать ссылку на токен в ту функцию, которая исполняется в отдельном потоке:
template<class Function, class... Args>
thread_wrapper(Function&& f, Args&&... args)
{ _impl = std::thread(f, args..., std::ref(_token)); }
Так как thread_wrapper
мы пишем для иллюстративных целей, то можно пока не использовать std::forward
и, заодно, проигнорировать те проблемы, которые возникнут в с move-конструктором и функцией swap
.
Настало время вспомнить пример с use_thread
и исключениями:
void use_thread()
{
std::atomic<bool> alive{true};
thread_wrapper t([&alive] { while(alive) do_something(); });
do_another_thing();
alive = false;
}
Для того, чтобы добавить поддержку cancellation_token
, нам достаточно добавить правильный аргумент в лямбду и убрать alive
:
void use_thread()
{
thread_wrapper t([] (cancellation_token& token) { while(token) do_something(); });
do_another_thing();
}
Замечательно! Даже если из do_another_thing
вылетит исключение — деструктор thread_wrapper
всё равно вызовёт cancellation_token::cancel
и поток завершит своё выполнение. Кроме того, убрав код булева флага в cancellation_token
, мы значительно сократили количество кода в нашем примере.
Настало время научить наши токены прерывать блокирующие вызова, например — ожидание на условных переменных. Чтобы абстрагироваться от конкретных механизмов прерывания, нам понадобится интерфейс cancellation_handler
:
struct cancellation_handler
{
virtual void cancel() = 0;
};
Хэндлер для прервания ожидания на условной переменной выглядит примерно так:
class cv_handler : public cancellation_handler
{
public:
cv_handler(std::condition_variable& condition, std::unique_lock<mutex>& lock) :
_condition(condition), _lock(lock)
{ }
virtual void cancel()
{
unique_lock l(_lock.get_mutex());
_condition.notify_all();
}
private:
std::condition_variable& _condition;
std::unique_lock<mutex>& _lock;
};
Теперь достаточно положить указатель на cancellation_handler
в наш cancellation_handler
и вызвать cancellation_handler::cancel
из cancellation_token::cancel
:
class cancellation_token
{
std::mutex _mutex;
std::atomic<bool> _cancelled;
cancellation_handler* _handler;
public:
explicit operator bool() const
{ return !_cancelled; }
void cancel()
{
std::unique_lock<mutex> l(_mutex);
if (_handler)
_handler->cancel();
_cancelled = true;
}
void set_handler(cancellation_handler* handler)
{
std::unique_lock<mutex> l(_mutex);
_handler = handler;
}
};
Прерываемая версия ожидания на условной переменной выглядит примерно так:
void cancellable_wait(std::condition_variable& cv, std::unique_lock<mutex>& l, cancellation_token& t)
{
cv_handler handler(cv, l); // implements cancel()
t.set_handler(&handler);
cv.wait(l);
t.set_handler(nullptr);
}
Внимание! Приведённая реализация небезопасна как с точки зрения исключений и потокобезопасности. Она здесь только для того, чтобы проиллюстрировать механизм работы cancellation_handler
. Ссылки на правильную реализацию можно найти в конце статьи.
Реализовав соответствующий cancellation_handler
, можно научить токен прерывать блокирующие вызовы ОС и блокирующие функции из других библиотек (если у этих функций есть хотя бы какой-нибудь механизм для прерывания ожидания).
Описанные токены, хэндлеры и потоки реализованы в виде open-source библиотеки: https://github.com/bo-on-software/rethread [10], с документацией [11] (на английском), тестами и бенчмарками [12].
Вот список главных отличий приведённого кода от того, что реализовано в библиотеке:
cancellation_token
— это интерфейс с несколькими реализациями. Прерываемые функции получают cancellation_token
по константной ссылке.rethread::thread
Что есть в библиотеке:
std::condition_variable
poll
— это позволяет реализовать прерываемые версии многих блокирующих POSIX вызовов (read
, write
, и т.д.)Измерения проводились на ноутбуке с процессором Intel Core i7-3630QM @ 2.4GHz.
Ниже приведены результаты бенчмарков токенов из rethread
.
Измерялась производительность следующих операций:
cancellation_token::is_cancelled()
(или эквивалентное этому контекстное приведение к булеву типу)standalone_cancellation_token
Процессорное время, нс | |
---|---|
Проверка состояния токена | 1.7 |
Вызов прерываемой функции | 15.0 |
Создание токена | 21.3 |
Процессорное время, нс | |
---|---|
Проверка состояния токена | 2.8 |
Вызов прерываемой функции | 17.0 |
Создание токена | 33.0 |
Столь низкие накладные расходы на прерываемость создают интересный эффект:
В некоторых ситуациях прерываемая функция работает быстрее, чем "обычный" подход.
В коде без использования токенов блокирующие функции не могут блокироваться навечно — тогда не получится достичь "нормального" завершения приложения (извращения вроде exit(1);
нельзя считать нормой). Для того, чтобы избежать вечной блокировки и регулярно проверять состояние, нам нужен таймаут. Например, такой:
while (alive)
{
_condition.wait_for(lock, std::chrono::milliseconds(100));
// ...
}
Во-первых, такой код будет просыпаться каждые 100 миллисекунд только для того, чтобы проверить флаг (значение таймаута можно увеличить, но оно ограниченно сверху "разумным" временем завершения приложения).
Во-вторых, этот код неоптимален даже без таких бессмысленных пробуждений. Дело в том, что вызов condition_variable::wait_for(...)
менее эффективен, чем condition_variable::wait(...)
: как минимум, ему нужно получить текущее время, посчитать время пробуждения, и т.д.
Для доказательства этого утверждения в rethread_testing были написаны два синтетических бенчмарка, в которых сравнивались две примитивных реализации многопоточной очереди: "обычная" (с таймаутом) и прерываемая (с токенами). Измерялось процессорное время, затраченное на то, чтобы дождаться появления в очереди одного объекта.
Процессорное время, нс | |
---|---|
Ubuntu 16.04 & g++ 5.3.1 ("обычная" очередь) | 5913 |
Ubuntu 16.04 & g++ 5.3.1 (прерываемая очередь) | 5824 |
Windows 10 & MSVS 2015 ("обычная" очередь) | 2467 |
Windows 10 & MSVS 2015 (прерываемая очередь) | 1729 |
Итак, на MSVS 2015 прерываемая версия работает в 1.4 быстрее, чем "обычная" версия с таймаутами. На Ubuntu 16.04 разница не столь заметна, но даже там прерываемая версия явно выигрывает у "обычной".
Это не единственное возможное решение изложенной проблемы. Наиболее заманчивая альтернатива — положить токен в thread-local storage и кидать исключение при прерывании. Поведение будет похоже на boost::thread::interrupt
, но без дополнительного мьютекса в каждой условной переменной и со значительно меньшими накладными расходами. Основной недостаток такого подхода — уже упомянутое нарушение философии исключений и неочевидность точек прерывания.
Важное достоинство подхода с токенами состоит в том, что можно прерывать не потоки целиком, а отдельные задачи, а если использовать реализованный в библиотеке cancellation_token_source
— то и несколько задач одновременно.
Почти весь свои "хотелки" в библиотеке я реализовал. На мой взгляд — не хватает интеграции с блокирующими вызовами системы вроде работы с файлами или сокетами. Написать прерываемые версии для read
, write
, connect
, accept
и т.д. не составит особого труда, основные проблемы — нежелание совать токены в стандартные iostream'ы и отсутствие общепринятой альтернативы.
Автор: bo-on-software
Источник [13]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/c-3/161934
Ссылки в тексте:
[1] RAII (Resource Acquisition Is Initialization): https://ru.wikipedia.org/wiki/%D0%9F%D0%BE%D0%BB%D1%83%D1%87%D0%B5%D0%BD%D0%B8%D0%B5_%D1%80%D0%B5%D1%81%D1%83%D1%80%D1%81%D0%B0_%D0%B5%D1%81%D1%82%D1%8C_%D0%B8%D0%BD%D0%B8%D1%86%D0%B8%D0%B0%D0%BB%D0%B8%D0%B7%D0%B0%D1%86%D0%B8%D1%8F
[2] стандарта: http://www.open-std.org/jtc1/sc22/wg21/docs/papers/2012/n3337.pdf
[3] pthread_cancel: http://pubs.opengroup.org/onlinepubs/009695399/functions/pthread_cancel.html
[4] особый список: http://pubs.opengroup.org/onlinepubs/009695399/functions/xsh_chap02_09.html
[5] boost::thread::interrupt: http://www.boost.org/doc/libs/1_61_0/doc/html/thread/thread_management.html#thread.thread_management.tutorial.interruption
[6] 1: http://stackoverflow.com/questions/4760687/cancelling-a-thread-using-pthread-cancel-good-practice-or-bad
[7] 2: http://stackoverflow.com/questions/2084830/kill-thread-in-pthread-library
[8] 3: http://stackoverflow.com/questions/7961029/how-can-i-kill-a-pthread-that-is-in-an-infinite-loop-from-outside-that-loop
[9] 4: http://stackoverflow.com/questions/3822674/for-pthread-how-to-kill-child-thread-from-the-main-thread
[10] https://github.com/bo-on-software/rethread: https://github.com/bo-on-software/rethread
[11] документацией: https://github.com/bo-on-software/rethread/blob/master/docs/Primer.md
[12] тестами и бенчмарками: https://github.com/bo-on-software/rethread_testing
[13] Источник: https://habrahabr.ru/post/306332/?utm_source=habrahabr&utm_medium=rss&utm_campaign=sandbox
Нажмите здесь для печати.