Основная идея статьи - сравнить производительность std::conditional_variable и std::atomic_flag::wait из С++20, посмотреть примеры использования.

Когда встает вопрос об ожидании какого-то события/событий, то одно из первых что приходит на ум - это std::conditional_variable. Согласно cppreference:

The condition_variable class is a synchronization primitive used with a std::mutex to block one or more threads until another thread both modifies a shared variable (the condition) and notifies the condition_variable.

т.е. condition_variable - это примитив синхронизации, который работает поверх std::mutex (если быть точным - то используется напрямую std::unique_lock, который уже работает поверхstd::mutex). Давайте быстро посмотрим примеры и кратко разберем как это работает.

class Example final {
public:
	void produce() {
		{
			std::lock_guard lck(m_);
			update = true;
		}
		cv_.notify_one();
	}

	void consume() {
		std::unique_lock lck(m_);
		cv_.wait(lck, [this] { return update;});
		if (update) {
			std::cout << "update: new message from producer" << std::endl;
		}
	}
private:
	std::mutex m_;
	std::condition_variable cv_;

	bool update{false};
};

int main() {
	Example example;
	auto produce_thread = std::thread(&Example::produce, &example);
	auto consume_thread = std::thread(&Example::consume, &example);

	if (produce_thread.joinable())
		produce_thread.join();

	if (consume_thread.joinable())
		consume_thread.join();
}

Вывод будет следующим:

update: new message from producer

Process finished with exit code 0

Все просто: создаются 2 потока, один пишет, другой считывает новое сообщение.

Начнем с produce(). и первое, что увидим - это std::lock_guard.

std::lock_guard

На (5) строке мы захватываем mutex c помощью std::lock_guard. Это пример RAII. Мы получаем по ссылке шаблон, у которого есть методы lock и unlock и сохраняем внутри non-const-ссылку. И вызываем lock/unlock в ctor/dtor. Копирование помечено как delete.

Все просто.

исходник std::lock_guard
template<typename _Mutex>
    class lock_guard
    {
    public:
      typedef _Mutex mutex_type;

      explicit lock_guard(mutex_type& __m) : _M_device(__m)
      { _M_device.lock(); }

      lock_guard(mutex_type& __m, adopt_lock_t) noexcept : _M_device(__m)
      { } // calling thread owns mutex

      ~lock_guard()
      { _M_device.unlock(); }

      lock_guard(const lock_guard&) = delete;
      lock_guard& operator=(const lock_guard&) = delete;

    private:
      mutex_type&  _M_device;
    };

Есть интересный момент, а именно конструктор:

lock_guard(mutex_type& __m, adopt_lock_t)

Это конструктор не блокирует переданный mutex, но при этом в деструкторе все также вызывается unlock. Так в чем же дело? adopt_lock_t можно считать некоторым тегом, который сообщает lock_guard, что переданный mutex уже заблокирован, но в деструкторе его также надо освобождать. Все становится еще более ясным, когда переведем с английского adopt (принять, принимать). adopt_lock - принимает блокировку.

Для чего нужен adopt_lock?

До С++17 и появления std::scoped_lock это был способ для блокирования нескольких mutex и правильной их разблокировки + exception safe.

std::mutex m1;
std::mutex m2;
// ...
std::lock(m1, m2);
std::lock_guard lck1(m1, std::adopt_lock);
std::lock_guard lck2(m2, std::adopt_lock);
// ...

lock_guard разобрали, давайте смотреть дальше produce. Под заблокированным mutex изменяем данные. текущая область видимости ограничена (4) и (7) строчками. Нужно ли под mutex вносить notify_one - разберем дальше. сам notify_one() разберем дальше вместе с wait.

Переходим к consume() и здесь первое, что мы видим - это std::unique_lock.

std::unique_lock

На (12) строке мы захватываем mutex с помощью std::unique_lock. И это тоже RAII.

Внутри этот лок сохраняет сырой указатель на mutex. Появляется дополнительное поле _M_owns - по которому определяется, кто владелец этой блокировки.

mutex_type*	_M_device;
bool		_M_owns;

Разберем пример работы конструктора:

// ...
explicit unique_lock(mutex_type& __m)
      : _M_device(std::__addressof(__m)), _M_owns(false)
      {
	lock();
	_M_owns = true;
      }
// ...
void
lock()
{
	if (!_M_device)
	  __throw_system_error(int(errc::operation_not_permitted));
	else if (_M_owns)
	  __throw_system_error(int(errc::resource_deadlock_would_occur));
	else
	  {
	    _M_device->lock();
	    _M_owns = true;
	  }
}
// ...

Сохраняет адрес переданного mutex в _M_device и вызываем lock(), где указываем, что мы владелец блокировки. Странно конечно, что _M_owns = true здесь будет вызвано 2 раза. Ну да ладно.

Посмотрим деструктор:

~unique_lock()
{
	if (_M_owns)
	  unlock();
}

unlock() будем звать только если мы владелец блокировки, в отличии от деструктора std::lock_guard, где в деструкторе всегда вызывается unlock().

Есть знакомый нам конструктор со знакомым тегом adopt_lock:

unique_lock(mutex_type& __m, adopt_lock_t) noexcept
      : _M_device(std::__addressof(__m)), _M_owns(true)
      {
	// XXX calling thread owns mutex
      }

Принимаем заблокированный mutex и становимся его владельцем.

Но есть и новые теги. Например defer_lock:

unique_lock(mutex_type& __m, defer_lock_t) noexcept
      : _M_device(std::__addressof(__m)), _M_owns(false)
      { }

С англ. defer - отложить т.е. в данном контексте отложить блокировку.

Но кто тогда будет разблокировать mutex, если в деструкторе как мы видели unlock() вызывается только если мы владелец блокировки, а в этом конструкторе _M_owns(false)? std::unique_lock предоставляет гораздо больше функциональности нежели std::lock_guard. Например метод lock()/unlock(). И при создании unique_lock с defer_lock можно впоследствии этот unique_lock передать в std::lock, который вызовет lock() и сделает этот unique_lock владельцем блокировки.

Дополнительно есть try_to_lock_t, duration логика, но на них останавливаться не буду.

Где это использовать?

Например тот же пример, что и у lock_guard - блокировка нескольких mutex до появления std::scoped_lock.

std::mutex m1;
std::mutex m2;
// ...
std::unique_lock lck1(m1, std::defer_lock);
std::unique_lock lck2(m2, std::defer_lock);
std::lock(lck1, lck2);

Возвращаемся к методу consume() и смотрим следующую строчку (13). До начала выполнения wait мы имеем заблокированный mutex - владелец блокировки std::unique_lock.

std::condition_variable::wait

В wait мы передаем заблокированный std::unique_lock, wait снимает временно блокировку и усыпает (не всегда: когда предикат в wait истинный может и не уснуть). Через какое-то время поток просыпается и вызывает блокировку. Именно из-за того, что нужна функциональность lock/unlock здесь нельзя использовать std::lock_guard - у него попросту нет такой функциональности.

Отдельно стоит поговорить про пробуждения потока на wait. Варианты пробуждения потока:

  • notify_one()/notify_all(): Вызываем из другого потока и wait проверяет предикат. Интересный момент с удержанием блокировки при notify_*. Для всех mutex рекомендуется держать блокировку не дольше чем нужно. В моем примере это до (6) строчки включительно. Но как быть с notify? Ответ как всегда в документации:

    The notifying thread does not need to hold the lock on the same mutex as the one held by the waiting thread(s); in fact doing so is a pessimization, since the notified thread would immediately block again, waiting for the notifying thread to release the lock. However, some implementations (in particular many implementations of pthreads) recognize this situation and avoid this "hurry up and wait" scenario by transferring the waiting thread from the condition variable's queue directly to the queue of the mutex within the notify call, without waking it up.

    Notifying while under the lock may nevertheless be necessary when precise scheduling of events is required, e.g. if the waiting thread would exit the program if the condition is satisfied, causing destruction of the notifying thread's condition variable. A spurious wakeup after mutex unlock but before notify would result in notify called on a destroyed object.

  • spurious wakeup: они же ложные пробуждения. Они есть, но мнения причины их существования/возникновения встречал разные. Есть такое объяснение - когда поток просыпается, сначала он должен взять блокировку - вызвав syscall. Между пробуждением и вызовом syscall проходит время - какой-то интервал времени. За этот интервал времени состояние системы может измениться (например условие которое нас пробудило перестало быть истинным). И в итоге получаем ложный вызов. Поэтому после wait рекомендуют всегда проверять истинность условие пробуждения. Есть интересное старое обсуждение.

  • существуют std::conditional_variable_any, wait_for ... - тут рассматривать не буду.

std::atomic_flag

std::atomic_flag - это самый простой атомарный тип, представляющий собой булев флаг. Объекты этого типа могут находятся в одном из 2-х состояний: установлен или сброшен. В С++20 у него появились новые методы: wait(), notify_*(). И кажется, что в самых простых сценариях - простое изменение флага, это может быть альтернативой для std::conditional_variable.

Набросаю небольшой пример игры пинг-понга (полное исходники: репозиторий):

Версия с conditional_variable:

void game_cv::ping() {
	int counter = 0;

	while (counter <= MaxCountTimes) {
		{
			std::unique_lock lck(m_);
			cv_.wait(lck, [this]() {
				return ping_done_;
			});
			ping_done_ = false;
			pong_done_ = true;
			counter++;
		}
		cv_.notify_one();
	}
}

void game_cv::pong() {
	int counter = 0;

	while (counter<MaxCountTimes) {
		{
			std::unique_lock lck(m_);
			cv_.wait(lck, [this](){
				return pong_done_;
			});
			ping_done_ = true;
			pong_done_ = false;
			counter++;
		}
		cv_.notify_one();
	}
}

void game_cv::start_game() {
	{
		std::unique_lock lck(m_);
		ping_done_ = true;
	}
	cv_.notify_one();
}

Версия с atomic_flag:

void game_atomic::ping() {
	int counter = 0;

	while (counter <= MaxCountTimes) {
		pass_.wait(false);
		pass_.clear();
		counter++;
		current_counter_++;
		pass_.notify_one();
	}
}

void game_atomic::pong() {
	int counter = 0;

	while (counter < MaxCountTimes) {
		pass_.wait(true);

		pass_.test_and_set();
		counter++;
		pass_.notify_one();
	}
}

void game_atomic::start_game() {
	pass_.test_and_set();
	pass_.notify_one();
}

Для замера времени:

auto start = std::chrono::system_clock::now();

game_->run();

std::chrono::duration<double> dur = std::chrono::system_clock::now() - start;
std::cout << "Duration: " << dur.count() << " seconds" << std::endl;

Результаты замеров:

Локальный замер: ОС: ubuntu 22.04, CPU: 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz RAM: 32 Гб

clang-14 release

gcc-11 release

версия с conditional_variable

4.0581 - 4.29326 - 4.15922 seconds

4.15656 - 3.96363 - 3.94372 seconds

версия с atomic_flag

0.250251 - 0.24626 - 0.23954 seconds

0.263211- 3.96363 - 0.260344 seconds

Замер в CI: можно посмотреть здесь

Также сделал замеры на виртуальной машине в cloud: результаты описал в readme ссылка

Новый wait выглядит очень хорошей альтернативой для std::conditional_variable, когда у нас есть простой предикат - бонусом будет чуть более шустрая работа.

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


  1. sergio_nsk
    02.01.2023 22:12
    +2

    Полезное новшество, но вот качество статьи сомнительно.

    "усыпает" - что за слово такое? Засыпает, усыпляет?

    С переменными в первом классе бардак, некоторые с подчёркиванием в конце, некоторые - без.


  1. rafuck
    03.01.2023 09:30

    atomic_flag скорее хорошая альтернатива для "недолгих" ожиданий без смены контекста.