1. Хибні пробудження: що це таке і чому це не «збій ОС»
Коли ви вперше пишете cv.wait(lock), уява малює просту картину: «потік спить, доки хтось не викличе notify_* — тоді він прокидається, і все гаразд». Саме тут і починається класична історія. Так думають багато хто, а потім зʼявляються рідкісні зависання, падіння або читання з порожньої черги — «раз на тиждень у пʼятницю». Щоб ви не стали героєм такої історії, сьогодні зафіксуємо ключове поняття: хибне пробудження (spurious wakeup).
Хибне пробудження означає, що wait може повернутися, навіть якщо ніхто не викликав для вас notify_one()/notify_all(), і навіть якщо умова, на яку ви чекаєте, узагалі не стала істинною. Це не «злий жарт» і не «зламався компілятор». Так улаштована модель очікування: реалізація може прокинутися з внутрішніх причин, через особливості планувальника, через гонки на рівні ядра тощо. Вам не потрібно знати всі причини. Досить памʼятати одне: код має бути коректним, навіть якщо wait повернувся «просто так».
Щоб це було простіше запамʼятати, візьмімо майже побутову аналогію. notify — це не лист із повідомленням, який гарантовано доставлять. Радше це стукіт у двері. Постукали — ви відчинили. Але це ще не означає, що за дверима стоїть курʼєр. Може, це сусід, може, грюкнув протяг, а може, вам просто здалося. Тому ви щоразу перевіряєте заново: «А доставка вже приїхала?» — тобто перевіряєте стан.
notify — не подія: чекаємо на стан, а не на сигнал
Коли новачки стикаються з condition variable, вони часто сприймають notify_one() як «подію», яку хтось обовʼязково отримає. На жаль, це не так. notify — це лише підказка: «ей, можливо, стан змінився — перевір ще раз». Справжнє джерело істини — це ваш захищений стан: queue, ready, tokens, closed, лічильник — будь-що.
Важливу думку зручно тримати в голові так: якби доставку «сигналу» можна було гарантувати, mutex поруч нам не знадобився б. Але так не працює. Тому протокол завжди один: стан зберігається під mutex, і очікування теж перевіряє цей стан під тим самим mutex.
Існує навіть окреме перевантаження condition_variable::wait із предикатом, тобто «чекай, доки предикат не стане істинним». Це не «хитрий трюк», а звичайна частина контракту стандартної бібліотеки.
Коректний код чекає на предикат, тобто на стан. notify лише спонукає ще раз перевірити предикат.
2. Антипатерн: if (...) wait(lock); — як дійти до front() на порожній черзі
Зараз зробимо невеликий «контрольований вибух». Ми навмисно напишемо код, який виглядає логічно, компілюється, часто працює… але час від часу ламається.
Уявіть consumer для черги завдань. Якщо черга порожня — почекаємо. Прокинулися — отже, завдання є. Логіка проста. І саме тому вона небезпечна.
#include <condition_variable>
#include <mutex>
#include <queue>
std::mutex m;
std::condition_variable cv;
std::queue<int> q;
int pop_bad() {
std::unique_lock<std::mutex> lock(m);
if (q.empty()) {
cv.wait(lock); // ПОГАНО: чекаємо на "сигнал"
}
int x = q.front(); // ризик: черга все ще порожня
q.pop();
return x;
}
Чому це погано? Бо cv.wait(lock) може повернутися через хибне пробудження. І це ще не все: навіть за «чесного» пробудження ви не одні. Інший consumer міг прокинутися раніше й забрати елемент. У підсумку ви викликаєте front() на порожній черзі — і далі вже все залежить від реалізації та вашої вдачі: невизначена поведінка, аварія або дивні ефекти.
Критична деталь така: if перевіряє умову рівно один раз — до очікування. Але очікування — це «дірка в часі»: поки ви спали, світ міг змінитися скільки завгодно разів. Тому після пробудження ви зобовʼязані заново перевірити, чи умова все ще істинна.
3. Правильний стиль: wait(lock, predicate) і ручний while
Тепер перепишімо ту саму функцію так, як і має виглядати добрий код: зрозуміло для вас, для керівника команди і для того студента, який відкриє цей файл через пів року та скаже: «О, тут усе зрозуміло». Після пробудження ми не покладаємося на магію — ми покладаємося на предикат.
#include <condition_variable>
#include <mutex>
#include <queue>
std::mutex m;
std::condition_variable cv;
std::queue<int> q;
int pop_good() {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] { return !q.empty(); }); // ЧЕКАЄМО НА СТАН
int x = q.front(); // безпечно: q не порожня під тим самим mutex
q.pop();
return x;
}
Що концептуально робить wait(lock, predicate)? Його зручно уявляти як цикл: «доки предикат хибний — спи». Прокинувся — знову перевірив. І так хоч десять разів. Тому хибні пробудження стають нудною рутиною: «ну прокинувся й прокинувся, перевірив — не готово — знову спати».
Ще один важливий нюанс: предикат викликається під захопленим mutex. Тобто перевірка !q.empty() синхронізована з тим, як producer робить q.push(...). Це і є єдина дисципліна доступу до стану.
Ручний while як чесна «розшифровка» предиката
Іноді корисно побачити, що саме ховається всередині wait(lock, predicate). Насправді майже завжди ви писатимете саме варіант із предикатом: він коротший, і в ньому менше шансів помилитися. Але розуміти ручний еквівалент теж важливо. Це як таблиця множення: калькулятор у нас є, але без неї впевненість усе ж не та.
#include <condition_variable>
#include <mutex>
#include <queue>
extern std::mutex m;
extern std::condition_variable cv;
extern std::queue<int> q;
int pop_good_while() {
std::unique_lock<std::mutex> lock(m);
while (q.empty()) {
cv.wait(lock); // Прокинувся? Не віримо. Перевіряємо знову.
}
int x = q.front();
q.pop();
return x;
}
Зауважте: тут while, а не if. Саме це і є «ліки» від хибних пробуджень і конкуренції між кількома споживачами. Прокинувся — перевірив — якщо все ще порожньо, знову чекаєш.
Якщо ви запамʼятаєте з лекції лише одну фразу, нехай це буде вона: wait майже ніколи не повинен стояти під if.
4. Lost wakeup: як «загубити сповіщення»
Хибні пробудження лякають, бо звучать як щось випадкове. Але є ще один сценарій, який ламає наївний код навіть без жодної випадковості: втрачене сповіщення (lost wakeup). Це ситуація, коли сповіщення відбулося раніше, ніж потік справді перейшов в очікування.
Уявіть такий сюжет. consumer перевіряє q.empty(), бачить, що черга порожня, і збирається викликати wait. У цей момент producer кладе елемент і викликає notify_one(). Але consumer ще не встиг справді заснути. Потім consumer усе-таки викликає wait і… може заснути назавжди, бо «подія» вже минула і не зобовʼязана накопичуватися.
Правильний протокол захищає від цього дуже просто: ми чекаємо на стан, і якщо він уже істинний, то взагалі не засинаємо.
Подивіться на різницю:
#include <condition_variable>
#include <mutex>
std::mutex m;
std::condition_variable cv;
bool ready = true; // вже готово!
void consumer_bad() {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock); // може піти спати, хоча ready == true
}
void consumer_good() {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] { return ready; }); // не чекає, якщо вже готово
}
Друга версія не «ловить сигнал», а перевіряє: «Чи готово прямо зараз?» Якщо так — продовжуємо. Якщо ні — чекаємо, але так, щоб після кожного пробудження перевіряти знову. Саме це робить систему стійкою і до ситуації «сповіщення прийшло надто рано», і до ситуації «сповіщення прийшло, але не про нас».
5. Як писати предикат: чистота, mutex і кілька споживачів
Багато хто, вперше побачивши wait(lock, predicate), думає: «О, круто! Сюди можна засунути будь-яку логіку». Іноді туди й справді намагаються покласти щось на кшталт: «якщо порожньо — надрукувати лог, збільшити лічильник, надіслати метрики». Це шлях до дивних багів: предикат може викликатися багато разів, у найнесподіваніші моменти, і ви отримаєте «дублікати» або порушите інваріанти.
Правильний предикат — це чиста перевірка стану. Він читає захищені дані й повертає bool. І все.
Погана ідея (побічний ефект — лічильник):
#include <condition_variable>
#include <mutex>
std::mutex m;
std::condition_variable cv;
int wake_checks = 0;
bool ready = false;
void wait_bad_predicate() {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] {
++wake_checks; // ПОГАНО: побічний ефект
return ready;
});
}
Хороша ідея (чиста перевірка):
#include <condition_variable>
#include <mutex>
std::mutex m;
std::condition_variable cv;
bool ready = false;
void wait_good_predicate() {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] { return ready; }); // лише bool-перевірка
}
Ще один принцип: предикат має перевіряти стан, який захищений тим самим mutex, що й wait. Якщо всередині предиката ви читаєте змінну, яку деінде змінюєте без цього mutex, ви самі ламаєте дисципліну синхронізації. condition_variable вас не врятує: це не «магічний барʼєр», а лише механізм сну й пробудження навколо правильно захищеного стану.
Кілька споживачів: предикат потрібен, навіть якщо «notify був чесний»
У моделі producer–consumer часто є кілька потоків-споживачів. Наприклад, один producer кладе завдання, а два потоки-обробники їх розбирають. У такому світі notify_one() розбудить одного, інколи notify_all() розбудить усіх, а інколи ОС розбудить не того, на кого ви розраховували. І навіть якщо всіх розбудили «по справі», завдання в черзі все одно лише одне.
Саме тут предикат перестає бути «страховкою на рідкісний випадок» і стає звичною необхідністю: кожен потік, що прокинувся, зобовʼязаний переконатися, що робота справді є саме зараз.
Схематично це виглядає так:
sequenceDiagram
participant P as Виробник
participant C1 as Споживач#1
participant C2 as Споживач#2
C1->>C1: wait(...)
C2->>C2: wait(...)
P->>P: push(task)
P->>C1: notify_all (або notify_one, але планувальник розбудив C1/C2)
C1->>C1: прокинувся, перевірив предикат -> true, pop()
C2->>C2: прокинувся, перевірив предикат -> false, знову wait()
Без предиката другий споживач міг би піти далі й спробувати дістати елемент, якого вже немає. З предикатом він просто спокійно повернеться до сну.
6. Вбудовуємо в застосунок: робимо TaskQueue::pop() коректним
Щоб не розмазувати по коду правила на кшталт «тут — lock, тут — wait, тут — predicate», зазвичай роблять невеликий клас-обгортку: черга + mutex + condition_variable. Зараз ми робимо ще один крок до правильного коду: додаємо pop() так, щоб він був безпечним щодо хибних пробуджень.
Почнемо з мінімального каркаса:
#include <condition_variable>
#include <mutex>
#include <optional>
#include <queue>
class TaskQueue {
public:
void push(int x);
std::optional<int> pop(); // чекає: "є завдання або закрито"
void close();
private:
std::mutex m_;
std::condition_variable cv_;
std::queue<int> q_;
bool closed_ = false;
};
Реалізація push має змінювати стан під захистом mutex, а сповіщати — вже після цього:
#include <mutex>
void TaskQueue::push(int x) {
{
std::lock_guard<std::mutex> lock(m_);
q_.push(x);
}
cv_.notify_one();
}
Тепер найцікавіше: pop. Ми чекаємо не просто на «черга не порожня», а на «черга не порожня або закрито». І робимо це через предикат.
#include <optional>
std::optional<int> TaskQueue::pop() {
std::unique_lock<std::mutex> lock(m_);
cv_.wait(lock, [&] { return closed_ || !q_.empty(); });
if (q_.empty()) {
return std::nullopt; // закрито й порожньо
}
int x = q_.front();
q_.pop();
return x;
}
Чому це хороший стиль саме з погляду хибних пробуджень? Бо навіть якщо wait повернувся «просто так», предикат тут же скаже: «closed_ — false, а черга порожня» — і потік знову засне. А якщо прокинувся конкурент і забрав елемент, другий потік після пробудження знову побачить порожню чергу й теж спокійно піде чекати.
Залишилося розглянути закриття. Воно майже завжди пробуджує всіх, бо умова виходу має стати видимою для кожного, хто очікує:
#include <mutex>
void TaskQueue::close() {
{
std::lock_guard<std::mutex> lock(m_);
closed_ = true;
}
cv_.notify_all();
}
Якщо ви випадково використаєте тут notify_one(), другий споживач може залишитися спати назавжди. Це вже тема окремої розмови про протоколи завершення, але головний висновок тут такий: предикат очікування часто включає «робота або закриття».
Таблиця-памʼятка: що писати в реальному коді
Коли ви вперше пишете багатопотоковий код, хочеться втримати все в голові. Але голова не гумова і, чесно кажучи, має чим зайнятися, крім запамʼятовування протоколів. Тому ось компактна таблиця, яку корисно подумки приклеїти до condition_variable.
| Сценарій у коді | Наївний запис | Чому погано | Правильний запис |
|---|---|---|---|
| «Почекаю, доки черга не порожня» | |
хибні пробудження, конкуренція між споживачами | |
| «Почекаю, доки буде “готово”» | |
lost wakeup: можна заснути, коли умова вже істинна | |
| «Хочу підрахувати, скільки разів прокинувся» | побічні ефекти в предикаті | предикат викликається багато разів | предикат лише bool, статистика — окремо |
7. Типові помилки під час роботи з хибними пробудженнями
Помилка № 1: використовувати wait(lock) як «чекати notify», а потім одразу працювати з даними.
Це найпоширеніша пастка, бо код виглядає дуже компактно. Але wait не обіцяє, що після пробудження умова істинна. Наслідок — спроби викликати front() на порожній черзі або зменшити лічильник нижче нуля. Лікування нудне, зате надійне: чекати лише через wait(lock, predicate) або через ручний while.
Помилка № 2: ставити if замість while, «бо одного разу ж достатньо».
На практиці «одного разу» не буває. Навіть якщо ви впевнені, що producer робить notify відразу після push, залишаються хибні пробудження і конкуренція між кількома споживачами. if перевіряє один раз і далі покладається на вдачу. while перевіряє щоразу і покладається на математику.
Помилка № 3: писати предикат із побічними ефектами.
Предикат — не місце для логування, зміни лічильників, «підчищання черги» та інших дій. Він може виконуватися багато разів, і це «багато разів» не завжди збігається з вашими очікуваннями. Тримайте його чистим: лише перевірка стану й bool-відповідь.
Помилка № 4: перевіряти в предикаті стан, який змінюється без цього самого mutex.
Іноді хочеться «оптимізувати» і читати closed_ без блокування, а писати під блокуванням, або навпаки. Це ламає дисципліну «один стан — один mutex», і далі ви вже не можете міркувати про коректність протоколу. У нашому стилі сьогодні правило просте: усе, що бере участь у предикаті очікування, читається і пишеться під тим самим mutex.
Помилка № 5: плутати «прокинувся» з «можна продовжувати».
Пробудження означає лише одне: настав час ще раз перевірити стан. Жодних гарантій, що робота зʼявилася, що ніхто не забрав завдання раніше і що ви не прокинулися без причини, тут немає. І, як не дивно, це хороша новина: ви пишете код, який коректно працює за будь-яких пробуджень, а отже стає значно надійнішим.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ