Files
ubbook/concurrency/condition_variable.md
2022-03-24 14:03:41 +03:00

107 lines
7.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Condition variable, или как сделать все правильно и уйти в deadlock
Синхронизация потоков это сложно, хотя у нас и есть примитивы. Такой себе каламбур.
Хорошо, если есть готовые высокоуровневые абстракции в виде очередей или каналов.
Но иногда приходится мастерить их самому. С использованием более низкоуровневых вещей: мьютексов, атомарных переменных и обвязки вокруг них.
`condition_variable` — примитив синхронизации, позволяющий одному или нескольким потокам ожидать сообщений от других потоков. Ожидать пассивно, не тратя время CPU впустую на постоянные проверки чего-то в цикле. Поток просто снимается с исполнения, ставится в очередь операционной системой, а по наступлении определенного события — уведомления — от другого потока пробуждается. Все замечательно и удобно.
Сам по себе примитив `condition_variable` не передает никакой информации, а только служит для пробуждения или усыпления потоков. Причем пробуждения, из-за особенностей реализации блокировок, могут случаться ложно, самопроизвольно (spurious), а не только лишь по непосредственной команде через `condition_variable`.
Потому типичное использование требует дополнительной проверки условий и выглядит как-то так.
```C++
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 мьютекс используется только для защиты булевого флага.
Невиданное расточительство! Целых два системных вызова в худшем случае. Давайте лучше флаг сделаем атомарным!
```C++
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` с предикатом.
```C++
// 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