воскресенье, 3 июля 2022 г.

[prog.c++] Сперва набросал черновик класса с фоновой рабочей нитью, а потом понял, что не все так просто

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

Но на следующий день, на свежую голову, пришел к выводу, что с получившимся черновиком не все так хорошо, как хотелось бы. Попробую пояснить что к чему.

Итак, было предложено что-то вроде вот этого:

class background_task_owner {
   ... // Какое-то актуальное наполнение класса.

   bool shutdown_{false};
   std::mutex shutdown_lock_;
   std::condition_variable shutdown_cv_;

   std::thread background_thread_;

public:
   background_task_owner(/* Какие-то прикладные параметры*/)
      : ... // Инициализация нужных классу полей.
      // Сразу же запускаем фоновую задачу.
      , background_thread_{ [this]{ background_thread_body(); } }
   {}

   ~background_task_owner()
   {
      {
         std::lock_guard<std::mutex> lock{shutdown_lock_};
         shutdown_ = true;
         shutdown_cv_.notify_one();
      }

      background_thread_.join();
   }

private:
   void background_thread_body()
   {
      std::unique_lock<std::mutex> lock{shutdown_lock_};
      while(!shutdown_)
      {
         lock.unlock();

         ... // Какие-то действия.

         lock.lock();
         if(!shutdown_)
         {
            // Нужно заснуть на какое-то время.
            // Но проснуться нужно если shutdown_ выставят раньше.
            shutdown_cv_.wait_for(lock, std::chrono::minutes{20},
                  [this]{ return shutdown_; });
         }
      }
   }
};

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

Изначально я здесь проблем не видел, но затем подумалось, что такое простое решение не обеспечивает должной безопасности исключений.

Допустим, что конструктор background_task_owner со временем стал сложнее и в нем появились какие-то дополнительные действия после запуска фоновой рабочей нити. И какое-то из этих действий может привести к выбросу исключения.

В таком случае у нас проблема: фоновой нити никто не даст сигнала на завершение работы. Соответственно, никто не будет делать для нее join. Соответственно, когда дойдет дело до разрушения объекта std::thread, связанного с фоновой нитью, программа тупо заломается.

Как мне кажется, нормального решения у этой проблемы нет. Но есть два ключевых момента, которые могут облегчить нам жизнь.

Во-первых, я бы создал вспомогательный объект, который бы отвечал за останов фоновой рабочей нити. Чтобы не приходилось делать это вручную в деструкторе background_task_owner.

Во-вторых, я не стал запускать фоновую нить прямо в списке инициализации экземпляра background_task_owner. Нуждо делать это либо уже в теле конструктора background_task_owner, либо вообще отдельным методом, который пользователь должен вызвать уже после того, как background_task_owner сконструирован. Да, здесь получается что-то вроде two-phase init, что не есть хорошо в общем случае, но конкретно здесь two-phase init может быть уместен.

В общем, с учетом вышесказанного я бы переписал свой черновик хотя бы вот так:

namespace details {

struct shutdown_control {
   bool shutdown_{false};
   std::mutex shutdown_lock_;
   std::condition_variable shutdown_cv_;
};

class thread_shutdowner {
   shutdown_control & control_;
   std::thread & target_thread_;

public:
   thread_shutdowner(
      shutdown_control & control,
      std::thread & target_thread)
      : control_{control}
      , target_thread_{target_thread}
   {}
   ~thread_shutdowner() {
      if(target_thread_.joinable()) {
         {
            std::lock_guard<std::mutex> lock{control_.shutdown_lock_};
            control_.shutdown_ = true;
            control_.shutdown_cv_.notify_one();
         }
         target_thread_.join();
      }
   }

   // Этот тип нельзя ни копировать, ни перемещать.
   thread_shutdowner(const thread_shutdowner &) = delete;
   thread_shutdowner(thread_shutdowner &&) = delete;

   thread_shutdowner & operator=(const thread_shutdowner &) = delete;
   thread_shutdowner & operator=(thread_shutdowner &&) = delete;
};

/* namespace details */

class background_task_owner {
   ... // Какое-то актуальное наполнение класса.


   // Порядок следования этих членов класса принципиально важен,
   // т.к. деструкторы будут вызываться строго в обратном порядке.
   details::shutdown_control shutdown_control_;
   std::thread background_thread_;
   details::thread_shutdowner background_thread_shutdowner_;

public:
   background_task_owner(/* Какие-то прикладные параметры*/)
      : ... // Инициализация нужных классу полей.
      // Фоновую задачу сразу не запускаем, но зато сразу
      // инициализируем background_thread_shutdowner_.
      , background_thread_shutdowner_{shutdown_control_, background_thread_}
      // Далее может быть инициализация остальных членов класса.
   {
      // Вот теперь мы можем запустить фоновую нить.
      background_thread_ = std::thread{ [this]{ background_thread_body(); } };
   }

   // Собственный деструктор нам теперь не нужен.

private:
   void background_thread_body()
   {
      std::unique_lock<std::mutex> lock{shutdown_control_.shutdown_lock_};
      while(!shutdown_control_.shutdown_)
      {
         lock.unlock();

         ... // Какие-то действия.

         lock.lock();
         if(!shutdown_control_.shutdown_)
         {
            // Нужно заснуть на какое-то время.
            // Но проснуться нужно если shutdown_ выставят раньше.
            shutdown_control_.shutdown_cv_.wait_for(
                  lock, std::chrono::minutes{20},
                  [this]{ return shutdown_control_.shutdown_; });
         }
      }
   }
};

Морали не будет. Разве что "быстро только кошки родяться", а придумать сходу хорошее и надежное решение даже для простенькой с виду задачки может быть не так просто, как кажется.

4 комментария:

NN​ комментирует...

Когда уже в плюсах будут фреймворки, чтобы не писать низкоуровневой многопоточный код :)

eao197 комментирует...

И не говори! Доколе?

NickViz комментирует...

кому реально надо часто многопоточку делать - уже постоянно используют TBB / SObjectizer (sic!) / свой велосипед. И соотвественно держат всё сложность в уме.
тем кому это надо редко - получают удовольствие от решения задачи. Ну согласитесь же - когда красиво и правильно написали код - приятно же! даже больше чем от применения фреймворка. который ещё подключить надо (подключение сторонних библиотек - вот адище в плюсах. возможно самый большой недостаток языка для меня).

NickViz комментирует...

и да - таймаут я бы не 20 минут, а 100мс поставил. сталкивался с весьма странными проблемами когда под виндой _иногда_ не просыпался wait. а после таймаута при заходе в wait срабатывал. с чем связано - так и не понял, но теперь стараюсь бесконечность или длинные таймауты не использовать. VS2019.