1. Навіщо потрібен std::condition_variable, якщо вже є mutex
Якщо подивитися на mutex очима новачка, може здатися: «я ж уже можу захистити чергу задач, отже, все гаразд». І справді, mutex чудово захищає дані від гонок. Але є проблема: він не вміє «гарно чекати». Якщо потоку-споживачеві нічого робити, він або крутиться в циклі (busy-wait), або періодично засинає через sleep_for і «підглядає», чи не зʼявилася робота. Обидва підходи — це компроміс між зайвим навантаженням і затримками.
std::condition_variable — це як дзвінок у двері: ви не стоїте біля килимка й не перевіряєте кожні 10 мілісекунд, «чи не прийшов курʼєр». Ви натиснули «чекати» — і… справді чекаєте. Коли хтось принесе піцу (задачу), вам подзвонять — і ви продовжите роботу.
Три учасники протоколу: стан, mutex і condition_variable
Зараз буде важлива думка, яку в багатопоточності варто повторювати як заклинання: ми чекаємо не «сигнал» — ми чекаємо стан. Стан — це щось, що можна перевірити «прямо зараз»: прапорець ready, лічильник tokens, черга queue (порожня / непорожня). А сповіщення (notify_*) — лише ввічливий натяк: «перевір ще раз».
Звʼязка виглядає так: у нас є захищений стан (наприклад, черга задач), який ми читаємо й змінюємо лише під одним mutex. Поруч — condition_variable, який дає змогу потоку «заснути» до моменту, коли стан може стати придатним для роботи.
Невелика схема протоколу:
flowchart TD
A[Споживач захопив мʼютекс] --> B{Стан готовий?}
B -- ні --> C["cv.wait(lock, predicate) мʼютекс тимчасово відпущено потік спить"]
C --> D[Пробудження: мʼютекс знову захоплено]
D --> B
B -- так --> E[Забираємо дані й виходимо з критичної секції]
Саме такий «цикл перевірки» і вбудовано у wait(lock, predicate).
2. Чому wait працює зі std::unique_lock, а не зі std::lock_guard
На перший погляд це може дивувати: «чому не можна, як завжди, lock_guard?» Тому що wait виконує трюк, якого lock_guard принципово не вміє: тимчасово відпускає мʼютекс, а потім знову захоплює його. Потік має заснути, але водночас не тримати mutex; інакше інші потоки не зможуть змінити стан і розбудити його.
Суть unique_lock проста: він не просто «захопив і тримає до кінця scope», а ще й уміє unlock() і lock().
#include <mutex>
std::mutex m;
void unique_lock_demo() {
std::unique_lock<std::mutex> lock(m);
lock.unlock(); // відпустили мʼютекс
lock.lock(); // знову захопили
}
А тепер — порівняння в таблиці:
| Інструмент | Що робить | Чи можна вручну unlock()? | Підходить для cv.wait() |
|---|---|---|---|
|
«захопив → тримаю до кінця scope» | ні | ні |
|
«захопив → можу відпустити → можу знову захопити» | так | так |
Саме через цю «керованість» unique_lock — стандартний напарник condition_variable.
3. Сигнатура та сенс wait(lock, predicate)
Зараз — центральна частина лекції: правильний шаблон очікування. У стандартній бібліотеці є перевантаження wait, яке приймає предикат (булеву функцію). Це перевантаження існує спеціально для того, щоб очікування було коректним і надійним. У матеріалах про стандарт окремо розглядають і тему «condition_variable::wait із предикатом» — зокрема в контексті точного формулювання відповідних вимог.
«Голий» wait(lock) — очікування без логіки
Якщо ви пишете так:
cv.wait(lock);
то ви, по суті, кажете: «приспіть мене й розбудіть будь-коли». Іноді це доречно в дуже низькорівневих протоколах, але для навчального й прикладного коду це майже завжди небезпечно: після пробудження ви все одно зобовʼязані самі перевірити, чи можна продовжувати.
wait(lock, predicate) — очікування з правильною перевіркою
Правильна форма виглядає так:
cv.wait(lock, [&] { return /* умова готовності */; });
Сенс простий: «чекай, доки умова не стане істинною». Усередині бібліотека фактично виконує еквівалент циклу «перевірив → якщо ні, заснув → прокинувся → перевірив знову».
Класичний «еквівалентний запис»:
while (!predicate()) {
cv.wait(lock);
}
І саме тому предикат не повинен мати побічних ефектів: його можуть викликати багато разів.
4. Мініприклад: коректно чекаємо на прапорець ready
Коли ви починаєте писати багатопотоковий код, спершу хочеться спробувати щось просте: є прапорець «готово / не готово», потік-виробник виставляє true, потік-споживач чекає.
#include <condition_variable>
#include <mutex>
std::mutex m;
std::condition_variable cv;
bool ready = false;
void consumer_waits() {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] { return ready; });
// тут ready == true, і mutex знову захоплений
}
Потік-виробник при цьому зобовʼязаний змінювати стан під тим самим мʼютексом:
#include <condition_variable>
#include <mutex>
extern std::mutex m;
extern std::condition_variable cv;
extern bool ready;
void producer_sets_ready() {
{
std::lock_guard<std::mutex> lock(m);
ready = true;
}
cv.notify_one();
}
Зверніть увагу на форму: ми змінюємо ready всередині блоку, щоб мʼютекс гарантовано відпустився, і лише потім викликаємо notify_one().
5. Вбудовуємо wait(lock, predicate) у мінізастосунок
Уявімо, що в попередній лекції (про busy-wait) ми вже почали писати «консольний обробник задач»: один потік додає задачі, другий — виконує їх. Поки що потік-споживач міг «крутитися» й перевіряти queue.empty(). Тепер зробимо правильно: потік-споживач спатиме, доки черга порожня.
Для простоти нехай задача — це просто число int (наприклад, «скільки разів надрукувати рядок» або «яке число обчислити»).
Стан застосунку: черга + синхронізація.
#include <condition_variable>
#include <mutex>
#include <queue>
std::mutex g_mutex;
std::condition_variable g_cv;
std::queue<int> g_tasks;
Потік-виробник: кладемо задачу й будимо потік-споживач
Потік-виробник — це, наприклад, main, який читає команди користувача.
#include <condition_variable>
#include <mutex>
#include <queue>
extern std::mutex g_mutex;
extern std::condition_variable g_cv;
extern std::queue<int> g_tasks;
void push_task(int x) {
{
std::lock_guard<std::mutex> lock(g_mutex);
g_tasks.push(x);
}
g_cv.notify_one();
}
Потік-споживач: чекаємо, доки черга не спорожніла
Ось тут і працює наш wait(lock, predicate):
#include <condition_variable>
#include <mutex>
#include <queue>
extern std::mutex g_mutex;
extern std::condition_variable g_cv;
extern std::queue<int> g_tasks;
int pop_task_blocking() {
std::unique_lock<std::mutex> lock(g_mutex);
g_cv.wait(lock, [] { return !g_tasks.empty(); });
int x = g_tasks.front();
g_tasks.pop();
return x;
}
Зауважте, наскільки це «по-людськи»: ми буквально записали «чекай, доки черга не порожня». І тут немає магії: просто wait звільняє mutex, засинає, а потім під час пробудження знову захоплює його й ще раз перевіряє предикат.
6. Як захоплювати стан у предикаті: [&], [], [this]
Коли ви пишете cv.wait(lock, ...), то часто використовуєте лямбду. А отже, виникає питання: «який спосіб захоплення обрати?». Це не косметика: від цього залежать і коректність, і читабельність.
Якщо стан — глобальні змінні (як у наших коротких навчальних прикладах), можна писати [], бо доступ і так буде до глобальних обʼєктів:
g_cv.wait(lock, [] { return !g_tasks.empty(); });
Якщо ж ви інкапсулюєте чергу в обʼєкті (що ми робитимемо далі протягом дня), то частіше буде так: this->tasks_, this->closed_, і тоді зручніше захопити this неявно через [&] або явно через [this]. У навчальному коді зазвичай читабельніше використовувати [&]:
cv_.wait(lock, [&] { return !tasks_.empty(); });
Тут важливо виробити звичку: предикат має бути «чистою перевіркою» стану: без push, без pop, без виведення в консоль. Його можуть викликати часто, і ви навряд чи хочете, щоб ваш код друкував «прокинувся!» 200 разів лише тому, що потік прокинувся невчасно.
7. notify_one(), notify_all() і місце виклику notify_*
На цьому етапі важливо не потонути в деталях, але базову інтуїцію треба зафіксувати. Є два способи будити потоки, які очікують:
notify_one() будить один потік, який очікує. Це ідеально підходить для сценарію «одна нова задача → один worker прокинувся й забрав її».
notify_all() будить усіх, хто очікує. Це корисно, коли стан змінився так, що потенційно продовжити роботу можуть багато потоків (або коли потрібно всіх повідомити про важливу зміну). Тему коректного завершення й випадки, коли notify_all() є обовʼязковим, ми детально розберемо наприкінці дня.
До речі, у текстах про стандартну бібліотеку окремо наголошують, що для condition_variable важливі і ефективність, і коректні формулювання поведінки.
Тепер класичне питання: «а чи можна зробити notify_one(), поки mutex ще захоплений?». Технічно можна, але найчастіше зручніше й охайніше робити так: спочатку змінюємо стан під мʼютексом, потім відпускаємо мʼютекс (виходимо зі scope), і лише після цього сповіщаємо.
Причина доволі практична. Якщо ви викличете notify_one() під мʼютексом, пробуджений потік майже відразу спробує захопити mutex, але не зможе, бо ви все ще його тримаєте. Формально це не помилка, але дуже схоже на ситуацію: «я подзвонив у двері, але тримаю їх замкненими й не віддаю ключ».
Хороший навчальний шаблон:
{
std::lock_guard<std::mutex> lock(m);
ready = true;
}
cv.notify_one();
Такий код легше читати й супроводжувати: зміна стану та сигнал про нього візуально розділені.
8. Ще кілька мініприкладів wait(lock, predicate) для закріплення
Іноді корисно подивитися на один і той самий шаблон у різних варіаціях. Це як вивчати граматику іноземної мови: поки не побачите 10 речень, здається, що все зрозуміло. Але це оманливе відчуття.
«Токени»: чекаємо, доки лічильник стане більшим за нуль
#include <condition_variable>
#include <mutex>
std::mutex m;
std::condition_variable cv;
int tokens = 0;
void take_token() {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] { return tokens > 0; });
--tokens;
}
«Є робота»: чекаємо, доки зʼявиться хоча б один елемент
#include <condition_variable>
#include <mutex>
#include <queue>
std::mutex m;
std::condition_variable cv;
std::queue<int> q;
int pop_one() {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] { return !q.empty(); });
int x = q.front();
q.pop();
return x;
}
«Перевірка готовності» та виведення в консоль
#include <iostream>
#include <condition_variable>
#include <mutex>
std::mutex m;
std::condition_variable cv;
bool ready = false;
void wait_and_print() {
std::unique_lock<std::mutex> lock(m);
cv.wait(lock, [] { return ready; });
std::cout << "Готово!\n"; // Готово!
}
9. Типові помилки під час роботи з wait(lock, predicate)
Помилка № 1: використовувати cv.wait(lock) «бо так коротше».
Так, справді коротше, але це як написати if (x) ... без розуміння, що таке x і хто його змінює. Якщо ви чекаєте на конкретну умову (черга не порожня, прапорець готовності піднято, токени зʼявилися), то коректний базовий стиль — wait(lock, predicate). Він привʼязує очікування до стану, а не до «містичного факту пробудження».
Помилка № 2: намагатися передати в wait std::lock_guard.
lock_guard не вміє відпускати мʼютекс, а wait зобовʼязаний відпускати його під час сну. Тому для очікування потрібен саме std::unique_lock. Якщо ви ловите себе на думці: «чому компілятор свариться?», найімовірніше, ви обрали не той тип блокування.
Помилка № 3: предикат із побічними ефектами.
Іноді новачки пишуть предикат, який друкує в консоль, збільшує лічильник або навіть намагається зробити pop() із черги. Це простий спосіб отримати хаос: предикат можуть викликати багато разів, зокрема в моменти, коли реальної роботи ще немає. Предикат має бути чистою перевіркою bool, не більше.
Помилка № 4: змінювати стан без мʼютекса й сподіватися, що notify_one() «якось синхронізує».
notify_one() не захищає дані й не замінює mutex. Він лише будить потік. Якщо запис у ready або виклик queue.push зроблено без того самого mutex, ви ламаєте дисципліну доступу до спільного стану. У найкращому разі отримаєте рідкісні дивні баги, у найгіршому — вічне очікування або падіння.
Помилка № 5: тримати мʼютекс під час «довгої роботи» після пробудження.
Правильна критична секція зазвичай виглядає так: «перевірив умову → забрав дані → оновив структуру → відпустив мʼютекс». Якщо ви після wait починаєте виконувати важкі обчислення, друкувати гігабайти тексту або імітувати роботу через sleep_for під захопленим mutex, то перетворюєте весь багатопотоковий код на однопотоковий — лише з зайвими проблемами.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ