Skip to content

Latest commit

 

History

History
107 lines (85 loc) · 7.63 KB

condition_variable.md

File metadata and controls

107 lines (85 loc) · 7.63 KB

Condition variable, или как сделать все правильно и уйти в deadlock

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

condition_variable — примитив синхронизации, позволяющий одному или нескольким потокам ожидать сообщений от других потоков. Ожидать пассивно, не тратя время CPU впустую на постоянные проверки чего-то в цикле. Поток просто снимается с исполнения, ставится в очередь операционной системой, а по наступлении определенного события — уведомления — от другого потока пробуждается. Все замечательно и удобно.

Сам по себе примитив condition_variable не передает никакой информации, а только служит для пробуждения или усыпления потоков. Причем пробуждения, из-за особенностей реализации блокировок, могут случаться ложно, самопроизвольно (spurious), а не только лишь по непосредственной команде через condition_variable.

Потому типичное использование требует дополнительной проверки условий и выглядит как-то так.

std::condition_variable cv;
std::mutex event_mutex;
bool event_happened = false;

// исполняется в одном потоке
void task1() {
    std::unique_lock lock { event_mutex };
    // предикат гарантированно проверяется под захваченной блокировкой
    cv.wait(lock, [&] { return event_happened; }); // безпредикатная версия wait ждет только уведомления,
    // но может произойти ложное пробуждения (обычно, если кто-то отпускает этот же мьютекс)
    ...
    // дождались — событие наступило
    // выполняем что нужно
}

// исполняется в другом потоке
void task2() {
    ...
    {
        std::lock_guard lock {event_mutex};
        event_happened = true;
    }
    // Обратите внимание: вызов notify не обязан быть под захваченной блокировкой.
    // Однако, в ранних версиях msvc, а также в очень старой версии из boost были
    // баги, требующие удерживать мьютекс захваченным во время вызова notify()
    // Но есть случай, когда делать вызов notify под блокировкой необходимо — если
    // другой тред может вызвать, например, завершаясь, деструктор объекта cv
    cv.notify_one(); // notify_all()
}

Хм, внимательный читатель может сообразить, что в task2 мьютекс используется только для защиты булевого флага. Невиданное расточительство! Целых два системных вызова в худшем случае. Давайте лучше флаг сделаем атомарным!

std::atomic_bool event_happened = false;
std::condition_variable cv;
std::mutex event_mutex;

void task1() {
    std::unique_lock lock { event_mutex };
    cv.wait(lock, [&] { return event_happened; });
    ...
}

void task2() {
    ...
    event_happened = true;
    cv.notify_one(); // notify_all()
    ...
}

Компилируем, запускаем, работает — классно, срочно в релиз!

Но однажды приходит пользователь и говорит, что запустил task1 и task2 как обычно одновременно, но сегодня внезапно task1 не завершается, хотя task2 отработал! Вы идете к пользователю, смотрите — висит. Перезапускаете — не зависает. Еще перезапускаете — опять не зависает. Перезапускаете 50 раз — все равно не зависает. Сбой какой-то железный разовый, думаете вы.

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

На самом деле в программе ошибка, приводящая к блокировке при редком совпадении в порядке инструкций. Чтобы понять ее, нужно посмотреть внимательнее на то, как устроен метод wait с предикатом.

// thread a
std::unique_lock lock {event_mutex};           // a1
// cv.wait(lock, [&] { return event_happened; }) это
while (![&] { return event_happened; }()) {    // a2
    cv.wait(lock);                             // a3
}

// -------------------------------
// thread b
event_happened = true;     // b1
cv.notify_one();           // b2

Рассмотрим следующую последовательность исполнения строчек в двух потоках

a1 // поток 1 захватывает блокировку
a2 // поток 1 проверяет условие --- истинно, событие не наступило
b1 // поток 2 выставляет событие,
b2 // уведомляет о нем, но поток 1 еще не начал ждать! уведомление потеряно!
a3 // поток 1 начинает ждать и никогда не дождется!

Отоптимизировали? Возвращайте захват мьютекса обратно.

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

Осторожнее с примитивами!

Полезные ссылки

  1. https://en.cppreference.com/w/cpp/thread/condition_variable
  2. https://stackoverflow.com/questions/2531359/do-condition-variables-still-need-a-mutex-if-youre-changing-the-checked-value-a/2531397#2531397
  3. https://www.modernescpp.com/index.php/c-core-guidelines-be-aware-of-the-traps-of-condition-variables