В чем я был неправ относительно std::condition_variable::wait?

std::condition_variable — это объект синхронизации, появившийся в C++11. Он используется, когда Ваш поток должен ожидать какого-либо сигнала от другого потока (например, о готовности данных для их последующей обработки).

Недавно мой коллега указал мне на то, что я неправильно понимаю работу с предикатом pred, который передается вторым параметром в метод std::condition_variable::wait. Он нужен для предотвращения ложного пробуждения потока (spurious wakeup). Интересный нюанс в том, когда он вызывается.

В чем была моя ошибка?

Метод std::condition_variable::wait (далее просто wait) подробно описывается на странице: https://en.cppreference.com/w/cpp/thread/condition_variable/wait.html. В самом начале написано:

wait causes the current thread to block until the condition variable is notified or a spurious wakeup occurs. pred can be optionally provided to detect spurious wakeup.

Или в моём переводе:

wait блокирует текущий поток до тех пор, пока либо не будет уведомлена условная переменная, либо не произойдет ложное пробуждение потока. Опционально может быть предоставлен pred для определения ложных пробуждений.

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

Взглянем вот на такой пример:

#include <chrono>
#include <condition_variable>
#include <iostream>
#include <thread>

int main() 
{
    std::mutex mtx;
    std::unique_lock lock{mtx};
    std::condition_variable cv;

    auto start = std::chrono::high_resolution_clock::now();
    cv.wait(lock, [](){ return true; });
    auto end = std::chrono::high_resolution_clock::now();
    
    std::cout
        << std::chrono::duration_cast
            <std::chrono::milliseconds>(end-start).count()
        << "ms" << std::endl;

    return 0;
};

Открыть на GodBolt.

Здесь создается условная переменная и на ней блокируется поток. С помощью стандартной библиотеки chrono мы замеряем время, которое мы находились в заблокированном состоянии.

Обратите внимание: у нас всего один поток. Значит, уведомление на условную переменную никто не отправляет. Значит, мы будем заблокированы до первого ложного пробуждения. И в выводе программы увидим время до него. Как думаете, сколько придется ждать?

Я сделал несколько запусков и во всех получил один и тот же результат: «0ms». Разумеется мои «несколько запусков» не гарантируют, что такое поведение имеет место всегда и везде. Но тем не менее.

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

Чтобы ответить на эти вопросы нужно внимательнее посмотреть на описание метода wait.

Что я не увидел?

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

while (!pred())
    wait(lock);

Ключевым моментом здесь является то, что предикат pred вызывается до начала ожидания. Из этого следует, что в нашем тестовом примере нет никаких ложных пробуждений. Поток просто ничего не ждет. Он вызвал предикат, тот вернул true, и функция wait завершилась.

Эту версию подтверждает и просмотр исходных текстов стандартной библиотеки. Вот одна из реализаций метода wait (https://gcc.gnu.org/onlinedocs/gcc-4.8.4/libstdc++/api/a00786_source.html)

template<typename _Predicate>
  void
  wait(unique_lock<mutex>& __lock, _Predicate __p)
  {
    while (!__p())
      wait(__lock);
  }

Как это позволяет упростить код?

Осознание новой информации позволяет сильно упростить код. Ранее приходилось писать так:

std::condition_variable cv;
std::mutex mtx;
std::atomic<bool> is_ready;

// ....

std::unique_lock lock{mtx};
if(! is_ready)
   cv.wait(lock, [&]() { return is_ready; } );

То есть флаг is_ready нужно было проверять до вызова wait и с уже захваченным мютексом чтобы избежать гонки. Если флаг не проверить, то мы могли напрасно уснуть до первого ложного пробуждения, если флаг уже установлен (помним, в этой логике предикат вызывается только после пробуждения потока).

Теперь же код упрощается до

std::condition_variable cv;
std::mutex mtx;
std::atomic<bool> is_ready;

// ....

std::unique_lock lock{mtx};
cv.wait(lock, [&]() { return is_ready; } );

Так как предикат вызовется до начала ожидания (причем с уже захваченным мьютексом), то и проблем с напрасным ожиданием не возникнет.

wait_until без предиката

Во время поиска дополнительной информации о методе wait я наткнулся на любопытный вопрос на stackoverflow: https://stackoverflow.com/questions/61121520/how-works-stdcondition-variablewait-until. В нём автор спрашивает, почему быстро работает следующий код:

int main() {
    std::condition_variable cv;
    std::mutex mtx;
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait_until(lock, std::chrono::system_clock::now() + 10000ms);

    return 0;
}

Программа, вопреки ожиданиям автора вопроса, не замирает на 10 секунд, а сразу же завершается. Как будто метод wait_until и не вызывается вовсе.

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

#include <condition_variable>

int main() {
    std::condition_variable cv;
    std::mutex mtx;
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait_until(lock, std::chrono::system_clock::now() + 10000ms,
        []{ return false; });

    return 0;
}

Я попробовал воспроизвести эту ситуацию у себя. Во всех моих тестах программа честно «замирала» на 10 секунд (+/- несколько миллисекунд) вне зависимости от того установлен предикат или нет.

Разумеется это не значит, что предикат не нужен. Здесь очень многое зависит от аппаратного обеспечения и операционной системы. Поэтому рассчитывать на то, что wait_until всегда приостанавливает программу на заданное время нельзя. Даже если в Ваших тестах это так.

Замечание выше относится и к методу wait_for.

Выводы

Из всего вышесказанного можно сделать несколько выводов.

  • Ложные пробуждения — не миф. Они действительно могут встречаться в Вашем коде.
  • Для защиты от них используйте предикаты в соответствующих методах.
  • Внимательнее читайте документацию.
  • Помните, что предикат вызывается до начала ожидания. Причем это справедливо для всех ожидающих методов std::condition_variable.

Напоследок приведу ссылку на короткую заметку Реймонда Чена (Raymond Chen) о возможных причинах возникновения ложных пробуждений: https://devblogs.microsoft.com/oldnewthing/20180201-00/?p=97946.

Ну и конечно же еще раз выражаю благодарность своему коллеге, который обратил моё внимание на этот важный нюанс.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *