Вирішуємо проблеми з RAII у std::thread: cancellation_token як альтернатива pthread_cancel і boost::thread::interrupt

Стаття розглядає проблеми у std::thread, попутно вирішуючи давній спір на тему "що використовувати: pthread_cancel, булев прапор або boost::thread::interrupt?"
Проблема
У класу std::thread, який додали в C++11, є одна неприємна особливість — він не відповідає з ідіомі RAII (Resource Acquisition Is Initialization). Витяг з стандарту:
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
і подивимося, куди приведе нас ця спроба.
Додаємо 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
і булев прапор.
Існуючі рішення
pthread_cancel
Відправляє обраному потоку запит на переривання. Специфікації POSIX містить особливий список перериваються функцій (
read
,
write
і т. д.). Після виклику
pthread_cancel
для якого-небудь потоку ці функції в даному потоці починають кидати виняток особливого типу. Це виключення не можна проігнорувати — catch-блок, зловив таке виключення, зобов'язаний кинути його далі, тому це виняток повністю розмотує стек потоку і завершує його. Потік може тимчасово заборонити переривання своїх дзвінки за допомогою функції
pthread_setcancelstate
(одне з можливих застосувань: щоб уникнути винятків з деструкторів, функцій логгирования тощо).
Плюси:
  • Можна перервати очікування на умовних змінних
  • Можна перервати блокуючі виклики ОС
  • Складно проігнорувати запит на переривання
Мінуси:
  • Великі проблеми з переносимістю: крім очевидного відсутності
    pthread_cancel
    Windows, він також відсутня у деяких реалізаціях libc (наприклад, в bionic, який використовується в Android)
  • Проблеми з
    std::condition_variable::wait
    в C++14 і більш пізніх стандартах
  • Може викликати проблеми в коді C, який використовує прерываемые функції (ймовірний список спецефектів: витоку ресурсів, не розблоковані вчасно м'ютекси і т. д.)
  • Прерываемые функції в деструкторе вимагають особливих запобіжних заходів (наприклад,
    close
    є переривається функцією)
  • не Можна використовувати в середовищі без винятків
  • не Можна застосувати для переривання окремих функцій або завдань
Проблеми з
std::condition_variable::wait
з'являються з-за того, що в C++14
std::condition_variable::wait
отримав специфікацію
noexcept
. Якщо дозволити переривання за допомогою
pthread_setcancelstate
, то ми втрачаємо можливість переривати очікування на условых змінних, а якщо переривання дозволені, то у нас немає можливості відповідати специфікації
noexcept
, тому що ми не можемо "проковтнути" це особливу виняток.
boost::thread::interrupt
Бібліотека 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
  • Можна використовувати в середовищі без винятків
Мінуси:
  • Прив'язка до Boost.Thread — даний механізм переривання не можна використовувати зі стандартними умовними змінними або потоками
  • Вимагає додаткового м'ютексу всередині
    condition_variable
  • Накладні витрати: додає дві додаткових блокування/розблокування м'ютексів в кожний
    condition_variable::wait
  • не Можна перервати блокуючі виклики ОС
  • Проблематично застосувати для переривання окремих функцій або завдань (судячи з кодом, це можна зробити тільки при використанні винятків)
  • Незначне порушення філософії винятків — переривання потоку не є винятковою ситуацією в життєвому циклі програми
Булев прапор
Якщо почитати на StackOverflow питання про
pthread_cancel
(1 2, 3 4)один з найпопулярніших відповідей: "Використовуйте замість
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
Що робити? Давайте візьмемо за основу булев прапор і почнемо вирішувати пов'язані з ним проблеми. Дупликация коду? Відмінно — давайте заглянемо булев прапор в окремий клас. Назвемо його
cancellation_token
.
class cancellation_token
{
public:
explicit bool operator() 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 bool operator() 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
, можна навчити токен переривати блокуючі виклики ОС і блокуючі функції з інших бібліотек (якщо в цих функцій є хоча б якийсь механізм для переривання очікування).
Бібліотека rethread
Описані токени, хендлер і потоки реалізовані у вигляді open-source бібліотеки: https://github.com/bo-on-software/rethread, документацією (англійською), тестами та бенчмарками.
Ось список головних відмінностей наведеного коду від того, що реалізовано в бібліотеці:
  • cancellation_token
    — це інтерфейс з декількома реалізаціями. Прерываемые функції отримують
    cancellation_token
    константною посиланню.
  • Токен використовує атомики замість м'ютексів для часто використовуваних операцій
  • Обгортка над потоком називається
    rethread::thread
Що є в бібліотеці:
  • Токени
  • RAII-сумісні потоки
  • Переривається очікування на будь-яких умовних змінних, сумісних по інтерфейсу з
    std::condition_variable
  • Переривається очікування
    poll
    — це дозволяє реалізувати прерываемые версії багатьох блокуючих POSIX викликів (
    read
    ,
    write
    , і т. д.)

Продуктивність

Вимірювання проводилися на ноутбуці з процесором Intel Core i7-3630QM @ 2.4 GHz.
Нижче наведено результати бенчмарків токенів з
rethread
.
Вимірювалася продуктивність наступних операцій:
  • Перевірка стану — це ціна виклику функції
    cancellation_token::is_cancelled()
    (або еквівалентне цього контекстне приведення до булеву типу)
  • Виклик переривається функції — це накладні витрати на одну прерываемую блокує функцію: реєстрація хендлер у токені перед викликом і "розреєстрація" після завершення
  • Створення одного
    standalone_cancellation_token
Ubuntu 16.04






Процесорний час, нс Перевірка стану сертифіката 1.7 Виклик переривається функції 15.0 Створення сертифіката 21.3
Windows 10






Процесорний час, нс Перевірка стану сертифіката 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'и і відсутність загальноприйнятої альтернативи.
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.