JavaRush /Курси /C++ SELF /std::condition_variable: wait(lock, predicate)

std::condition_variable: wait(lock, predicate)

C++ SELF
Рівень 70 , Лекція 1
Відкрита

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()
std::lock_guard
«захопив → тримаю до кінця scope» ні ні
std::unique_lock
«захопив → можу відпустити → можу знову захопити» так так

Саме через цю «керованість» 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, то перетворюєте весь багатопотоковий код на однопотоковий — лише з зайвими проблемами.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ