вторник, 27 марта 2018 г.

C++: std::condition_variable vs std::this_thread::sleep_for


Все видели конструкции, вынесенные в заголовок. std::this_thread::sleep_for используется для реализации всевозможных вариаций на тему поллинга и спинлоков в 9 случаях из 10.

Я не могу сказать, что std::this_thread::sleep_for - это всегда плохо. Нет, конечно. Если речь идет о каком-то второстепенном процессе, с большим интервалом выполнения - его можно использовать.

Но, в случае критичных по скорости участков кода - использование std::this_thread::sleep_for чревато, как минимум потерей латентности и, в целом, снижением масштабирования. А иногда и повышенным использованием CPU.

Давайте рассмотрим одну очень хорошую библиотеку thread pool. На мой взгляд, это лучшая из имеющихся на GitHub библиотек данного назначения.

Она всем хороша. Кроме одного. В ней в worker.hpp используется std::this_thread::sleep_for:
 template <typename Task, template<typename> class Queue>  
 inline void Worker<Task, Queue>::threadFunc(size_t id, Worker* steal_donor)  
 {  
   *detail::thread_id() = id;  
   
   Task handler;  
   
   while (m_running_flag.load(std::memory_order_relaxed))  
   {  
     if (m_queue.pop(handler) || steal_donor->steal(handler))  
     {  
       try  
       {  
         handler();  
       }  
       catch(...)  
       {  
         // suppress all exceptions  
       }  
     }  
     else  
     {  
       std::this_thread::sleep_for(std::chrono::milliseconds(1));  
     }  
   }  
 }  
   

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

Однако, все не так радужно.



Первое. Когда поток заданий иссякает - воркеры начинают с этой периодичностю тыкаться в очередь, что дает постоянную загрузку CPU от 0.04 до 0.3% (в зависимости от платформы).

Второе. Когда поток заданий неравномерный - латентность прыгает от 3 до 15 миллисекунд (зависит от платформы, ОС, нагрузки, числа ядер, режима планировщика итп.)

Кроме того, std::this_thread::sleep_for помещает поток не всегда в очередь спящих.

Попробуем это исправить.


 --- worker.hpp     Thu Mar 22 23:20:12 2018  
 +++ worker.hpp     Sat Mar 24 22:28:13 2018  
 @@ -2,6 +2,8 @@  
    
  #include <atomic>  
  #include <thread>  
 +#include <condition_variable>  
 +#include <mutex>  
    
  namespace tp  
  {  
 @@ -78,6 +80,8 @@  
    Queue<Task> m_queue;  
    std::atomic<bool> m_running_flag;  
    std::thread m_thread;  
 +  std::mutex m_conditional_mutex;  
 +  std::condition_variable m_conditional_lock;  
  };  
    
    
 @@ -121,6 +125,7 @@  
  inline void Worker<Task, Queue>::stop()  
  {  
    m_running_flag.store(false, std::memory_order_relaxed);  
 +  m_conditional_lock.notify_all();  
    m_thread.join();  
  }  
    
 @@ -140,6 +145,7 @@  
  template <typename Handler>  
  inline bool Worker<Task, Queue>::post(Handler&& handler)  
  {  
 +  m_conditional_lock.notify_one();  
    return m_queue.push(std::forward<Handler>(handler));  
  }  
    
 @@ -171,7 +177,8 @@  
      }  
      else  
      {  
 -      std::this_thread::sleep_for(std::chrono::milliseconds(1));  
 +      std::unique_lock<std::mutex> lock(m_conditional_mutex);  
 +      m_conditional_lock.wait(lock);  
      }  
    }  
  }  
   

Воспользуемся std::condition_variable.

std::condition_variable при запросе ожидания сразу отправляет поток в очередь планировщика sleep, не расходуя ресурсы процессора. Приведенный выше патч делает следующее. Каждый воркер, обнаруживая, что очередь заданий пуста, отправляется спать. И пробуждается по выполнении метода post(). (Ну и, разумеется, при шатдауне пула все воркеры пробуждаются перед завершением).

Что мы получаем?


Получаем мы трехкратное улучшение латентности пула при непостянной нагрузке заданиями, меньшее использование CPU и вот такой график латентности пула:


Слева на графике - оригинальный worker.hpp. Справа - тот же пул после наложения вышеприведенного патча.

std::condition_variable имеет еще одно преимущество. Несмотря на использование mutex, все операции с ней атомарны и не вызывают заметного повышения wait.

Тесты показали, что данный патч не нарушает алгоритмической логики thread pool и не вызывает сериализации.