Статья рассматривает проблемы в 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 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_token и вызвать 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, можно научить токен прерывать блокирующие вызовы ОС и блокирующие функции из других библиотек (если у этих функций есть хотя бы какой-нибудь механизм для прерывания ожидания).


Библиотека 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.4GHz.


Ниже приведены результаты бенчмарков токенов из 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'ы и отсутствие общепринятой альтернативы.

Поделиться с друзьями
-->

Комментарии (17)


  1. AndreySu
    25.07.2016 14:10

    Может я не правильно понял, но объясните, как cancellation_handler решает проблему отмены ожидающих io операций?


    1. bo-on-software
      26.07.2016 16:24

      Для того, чтобы прерывать блокирующие IO-операции, необходимо реализовать соответствующий cancellation_handler. Для POSIX'а можно использовать такую схему — вместо того, чтобы ждать данных в read, места во write, подключения в accept и т.д. — можно всё ожидание делать в вызове poll. Тогда для отмены блокирующиего IO достаточно использовать прерываемую версию poll, которая уже есть в rethread: https://github.com/bo-on-software/rethread/blob/master/rethread/poll.hpp


  1. Wilk
    25.07.2016 15:22
    +1

    Здравствуйте.

    Хотелось бы добавить немного подробностей относительно pthread_cancel. Безусловно, использование данного механизма может приводить к проблемам с освобождением мьютексов и ресурсов. Однако, стоило также упомянуть про механизм pthread_cleanup_push() / pthread_cleanup_pop (), позволяющий устанавливать функции-обработчики для выполнения процедуры корректного освобождения ресурсов при завершении работы потока. Я сталкивался с тем, что отмена потока при помощи pthread_cancel () приводит к тому, что мьютекс остаётся не освобождённым. И добавление простейшего обработчика, который мьютекс освобождает, решило проблему. Безусловно, написание такого рода обработчиков достаточно трудоёмко. Тем не менее, упомянуть о таком варианте, на мой взгляд, стоило.

    Также хотелось бы уточнить, что означает фраза «Нельзя применить для прерывания отдельных функций или задач»? Меня она поставила в тупик)

    За статью большое спасибо, мне очень понравилось)


    1. bo-on-software
      26.07.2016 17:06

      Добрый день.


      pthread_cleanup_push/pthread_cleanup_pop я упоминать не стал, потому что плюсах им есть отличная альтернатива: RAII.


      Проблема с обработчиками прерывания в Си в том, что их не все пишут. Из-за этого минусы использования pthread_cancel могут проявиться не сразу, а с задержкой.


      Реальная история: в проекте появляется ещё одна 3-party библиотека, написанная на Си (обёртка над специфичным железом). Без исходного кода и связи с разработчиками библиотеки. Через несколько месяцев обнаруживаются странные дэдлоки, которых быть не должно. После нескольких дней дебага выясняется, что если вызвать pthread_cancel для потока в тот момент, когда этот поток находится внутри этой библиотеки — то он просто тихо умирает, даже не разматывая свой стек.


      Насчёт отдельных задач — я имел в виду отмену долгой задачи без прерывания всего потока. Например, в приложении-навигаторе может быть отдельный поток для того, чтобы рассчитывать маршрут. Рассчёт маршрута из точки А в точку Б — это отдельная задача. Если пользователь изменил начальную и конечную точки — расчёт можно отменить, но прерывать весь поток смысла нет.


      1. bo-on-software
        26.07.2016 17:14

        Извиняюсь за орфографию.


      1. Wilk
        26.07.2016 18:44

        Здравствуйте.

        Относительно RAII согласен, механизм прекрасный. Недавно открыл его для себя. Я имею в виде не теоритически, но на практике.

        Про отдельные задачи теперь понял, спасибо) В моём восприятии всё упёрлось в то, что отдельная задача выполняется целиком отдельным потоком, который целиком останавливается.


  1. gurux13
    25.07.2016 18:47
    -1

    Если я правильно понял проблему, RAII для потоков не прост, потому что непонятно, как корректно завершить поток.
    Так что мешает сделать что-нибудь в стиле

    struct cancel_aware_thread {
       template <class Function, class... Args>
       cancel_aware_thread(std::function<void(std::thread&)> && cancelFunction, Function && threadFunc, Args&& ... args) {
          _impl(threadFunc, args);
          cancellation = cancelFunction;
       }
       ~cancel_aware_thread() {
          cancellation();
       }
       private:
       std::thread _impl;
       std::function<void(std::thread &)> cancellation;
    }
    


    И пусть себе программист думает, как именно его поток должен завершаться. В частности, в первом коде выше будет
    void not_so_dangerous_thread()
    {
      cancel_aware_thread t([] (std::thread & t) {t.join();}, [] { do_something(); });
      do_another_thing(); // may throw - will not cause a termination
    }
    

    //пардон за ошибки синтаксиса, if any
    P.S. explicit operator bool() — зачем?


    1. bo-on-software
      26.07.2016 17:33
      +1

      Проблема в том, что сам по себе join() ситуацию не улучшит (это обсуждается в разделе "Серьёзная проблема").


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


      Explicit оператору приведения к булеану нужен для того, чтобы не компилировался следующий код:


      standalone_cancellation_token token;
      int i = token + 5;


      1. gurux13
        26.07.2016 18:20
        -1

        > Больше всего информации о том, как прервать какую-нибудь блокирующую функцию есть у автора этой функции.
        Ну, тогда нужно сделать класс cancelable_function<...>, который будет знать, что выполнять, и как это остановить. Тогда мой cancel_aware_thread будет принимать её в конструктор и запоминать, как её остановить.

        В Вашем коде, кажется, токен отмены пытается решить все проблемы — отмена ожиданий (системных и плюсовых), выставление флага. Тут тоже надо знать, что безопасно для той или иной функции в том или ином контексте. В частности, если функция, которая запускается в потоке, не известна программисту, нужно будет от автора получить или cancellation_handler, который потом использовать при создании токена, или просто мою cancelable_function. Кажется, второе нативнее. Код будет каким-то таким:

        cancel_aware_thread(get_cancelable_function());
        

        И это решит сразу все проблемы — и исключения в конструкторах, и перед деструктором.

        Архитектурно, токен (вернее, хендлер) — это разрыв логики работы функции и её прерывания, на мой взгляд.


        1. bo-on-software
          26.07.2016 21:00
          +1

          Ну, тогда нужно сделать класс cancelable_function<...>, который будет знать, что выполнять, и как это остановить

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


          Тут тоже надо знать, что безопасно для той или иной функции в том или ином контексте. В частности, если функция, которая запускается в потоке, не известна программисту, нужно будет от автора получить или cancellation_handler, который потом использовать при создании токена <...>

          Я не вполне понял, что вы имеете в виду под "известна программисту", но от автора ничего получать не надо. Токены не требуют для своего создания cancellation_handler, токен создаётся и живёт вместе с потоком. Когда в этом потоке вызывается прерываемая функция — она использует токен для того, чтобы проверять статус прерывания. Если эта функция вызывает другую прерываемую функцию — она передаёт ей ссылку на этот токен.
          Вот небольшой пример программы, которая обрабатывает задачи от пользователя в отдельном потоке:


          void do_subtask(const subtask_data& data, const cancellation_token& token)
          {
            // ...
          }
          
          void do_task(const task_data& data, const cancellation_token& token)
          {
            for (int i = 0; i < Subtasks && token; ++i)
              do_subtask(data.get_subtask(i), token);
          }
          
          void do_work(concurrent_queue<task_data>& tasks, const cancellation_token& token)
          {
            while(token)
            {
              auto data = tasks.pop(token);
              if (data)
                do_task(*data, token);
            }
          }
          
          int main()
          {
            concurrent_queue<task_data> tasks;
            rethread::thread t{ [&tasks] (const cancellation_token& token) { do_work(tasks, token); } };
            for (auto i = read_input(); i; i = read_input())
              tasks.push(*i);
            return 0;
          }

          То есть токен создаётся вместе с потоком, а потом передаётся в каждую блокирующую (или просто длительную) функцию.


  1. Videoman
    25.07.2016 21:35

    Интересно, а кто-нибудь может объяснить, почему в стандарте поведение деструктора std::thread именно такое, как описано в статье, почему нельзя было просто ждать на join() или ничего не делать, если для потока был вызван detach()? Ведь это, на мой взгляд, более ожидаемое поведение.


    1. JIghtuse
      26.07.2016 05:57
      +1

      У Мейерса в Effective Modern C++ об этом пишется (Item 37: Make std::threads unjoinable on all paths):

      Скрытый текст
      One reason a std::thread’s joinability is important is that if the destructor for a joinable thread is invoked, execution of the program is terminated. For example, suppose we have a function doWork that takes a filtering function, filter, and a maximum value, maxVal, as parameters. doWork checks to make sure that all conditions necessary for its computation are satisfied, then performs the computation with all the values between 0 and maxVal that pass the filter. If it’s time-consuming to do the filtering and it’s also time-consuming to determine whether doWork’s conditions are satisfied, it would be reasonable to do those two things concurrently.

      Our preference would be to employ a task-based design for this (see Item 35), but let’s assume we’d like to set the priority of the thread doing the filtering. Item 35 explains that that requires use of the thread’s native handle, and that’s accessible only through the std::thread API; the task-based API (i.e., futures) doesn’t provide it. Our approach will therefore be based on threads, not tasks.

      We could come up with code like this:

      constexpr auto tenMillion = 10000000;          // see Item 15
                                                     // for constexpr
      
      bool doWork(std::function<bool(int)> filter,   // returns whether
                  int maxVal = tenMillion)           // computation was
      {                                              // performed; see
                                                     // Item 2 for
                                                     // std::function
      
          std::vector<int> goodVals;                 // values that
                                                     // satisfy filter
      
          std::thread t([&filter, maxVal, &goodVals] // populate
                        {                            // goodVals
                          for (auto i = 0; i <= maxVal; ++i)
                          { if (filter(i)) goodVals.push_back(i); }
                        });
      
          auto nh = t.native_handle();               // use t's native
                                                     // handle to set
                                                     // t's priority
      
          ...
          if (conditionsAreSatisfied()) {
              t.join();                              // let t finish
              performComputation(goodVals);
              return true;                           // computation was
                                                     // performed
          }
          return false;                              // computation was
      }                                              // not performed
      


      Before I explain why this code is problematic, I’ll remark that tenMillion’s initializing value can be made more readable in C++14 by taking advantage of C++14’s ability to use an apostrophe as a digit separator:

      constexpr auto tenMillion = 10'000'000; // C++14
      


      I’ll also remark that setting t’s priority after it has started running is a bit like closing the proverbial barn door after the equally proverbial horse has bolted. A better design would be to start t in a suspended state (thus making it possible to adjust its priority before it does any computation), but I don’t want to distract you with that code. If you’re more distracted by the code’s absence, turn to Item 39, because it shows how to start threads suspended.

      But back to doWork. If conditionsAreSatisfied() returns true, all is well, but if it returns false or throws an exception, the std::thread object t will be joinable when its destructor is called at the end of doWork. That would cause program execution to be terminated.

      You might wonder why the std::thread destructor behaves this way. It’s because the two other obvious options are arguably worse. They are:

      • An implicit join. In this case, a std::thread’s destructor would wait for its underlying asynchronous thread of execution to complete. That sounds reasonable, but it could lead to performance anomalies that would be difficult to track down. For example, it would be counterintuitive that doWork would wait for its filter to be applied to all values if conditionsAreSatisfied() had already returned false.
      • An implicit detach. In this case, a std::thread’s destructor would sever the connection between the std::thread object and its underlying thread of execution. The underlying thread would continue to run. This sounds no less reasonable than the join approach, but the debugging problems it can lead to are worse. In doWork, for example, goodVals is a local variable that is captured by reference. It’s also modified inside the lambda (via the call to push_back). Suppose, then, that while the lambda is running asynchronously, conditionsAreSatisfied() returns false. In that case, doWork would return, and its local variables (including goodVals) would be destroyed. Its stack frame would be popped, and execution of its thread would continue at doWork’s call site. Statements following that call site would, at some point, make additional function calls, and at least one such call would probably end up using some or all of the memory that had once been occupied by the doWork stack frame. Let’s call such a function f. While f was running, the lambda that doWork initiated would still be running asynchronously. That lambda could call push_back on the stack memory that used to be goodVals but that is now somewhere inside f’s stack frame. Such a call would modify the memory that used to be goodVals, and that means that from f’s perspective, the content of memory in its stack frame could spontaneously change! Imagine the fun you’d have debugging that.

      The Standardization Committee decided that the consequences of destroying a joinable thread were sufficiently dire that they essentially banned it (by specifying that destruction of a joinable thread causes program termination).

      This puts the onus on you to ensure that if you use a std::thread object, it’s made unjoinable on every path out of the scope in which it’s defined. But covering every path can be complicated. It includes flowing off the end of the scope as well as jumping out via a return, continue, break, goto or exception. That can be a lot of paths.


      1. Videoman
        26.07.2016 11:57

        Честно, вот этот момент в книжке очень мутный и меня не убедил. Пример очень кривой. Тем более, что далее, в качестве рекомендации, приводится пример RAII обертки на потоком, который в зависимости от флага делает либо join() либо detach(). В подавляющем числе случаев реального использования потока, ему также передаются некоторые ресурсы, которые при любых путях исполнения не безопасно использовать после join(), т.е. не дождавшись реальной остановки потока. Вся «магия» по оповещению потока о необходимости остановки все-равно будет свалена на программиста и данная задача не решается, в общем случае, на уровне библиотеки.


    1. encyclopedist
      26.07.2016 12:19
      +1

      В статье этот вопрос рассмотрен, в разделе "Серьёзная проблема". Кратко — так слишком просто получить дедлоки.
      Ещё рекомендую посмотреть P0379R0 Why joining_thread from P0206 is a Bad Idea


      1. Videoman
        26.07.2016 12:39

        Дедлоки можно получить множеством разных способов. Используя С++ можно легко проехаться по памяти и вызвать неопределенное поведение программы. Это же низкоуровневые примитивы. Сложность нового стандарта уже давно вышла за уровень «новичок». Ведь, все-рано, 90% будут писать обертку вокруг и делать join(). Ну сделали бы флаги, в конце концов, которыми можно было бы задать поведение при разрушении.


  1. Jigglypuff
    26.07.2016 17:33

    Слушал этот доклад на C++ Russia в феврале. Очень интересно, спасибо.
    Если кому-то любопытно — видео можно посмотреть здесь: meetingcpp.ru/?page_id=1050


    1. bo-on-software
      26.07.2016 17:37

      Спасибо.


      С февраля библиотека обросла тестами, документацией и переехала на атомики вместо мьютексов, что отлично сказалось на производительности.