Синхронизация потоков это сложно, хотя у нас и есть примитивы. Такой себе каламбур. Хорошо, если есть готовые высокоуровневые абстракции в виде очередей или каналов. Но иногда приходится мастерить их самому. С использованием более низкоуровневых вещей: мьютексов, атомарных переменных и обвязки вокруг них.
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 начинает ждать и никогда не дождется!
Отоптимизировали? Возвращайте захват мьютекса обратно.
Захват мьютекса в уведомляющем потоке гарантирует, что ожидающий уведомления поток либо еще не начал ждать и проверять событие, либо уже ждет. Если же мьютекс не захвачен, мы можем попасть в промежуточное состояние.
Осторожнее с примитивами!
- https://en.cppreference.com/w/cpp/thread/condition_variable
- https://stackoverflow.com/questions/2531359/do-condition-variables-still-need-a-mutex-if-youre-changing-the-checked-value-a/2531397#2531397
- https://www.modernescpp.com/index.php/c-core-guidelines-be-aware-of-the-traps-of-condition-variables