ОС · Конкурентность · 14 мин
Условные переменные
Проблема: как ждать события, не сжигая CPU
Представь кассу в кафе. Бариста ждёт, пока в очереди появится заказ. У него два варианта поведения:
- Опрос (busy-waiting, активное ожидание): каждую секунду спрашивать «есть заказ? есть заказ? есть заказ?» — и так без остановки. Бариста занят, но бесполезно: он крутит головой вхолостую.
- Сон с будильником: «разбудите меня, когда придёт заказ». Бариста спит, ничего не делает, а как только заказ появился — кто-то его толкает.
В программе ровно та же дилемма. Поток должен дождаться условия — например,
«в очереди появился элемент», «соединение установлено», «буфер освободился».
Если он крутит цикл while (очередь пуста) {}, он жжёт целое ядро впустую.
Это и есть busy-waiting, и это почти всегда зло.
Busy-waiting (плохо) Сон на условной переменной (хорошо)
──────────────────── ──────────────────────────────────
┌──────────────┐ ┌──────────────┐
│ while(пусто){}│ CPU 100% │ wait(...) │ CPU ~0%
│ крутим... │ ←── жжём ядро │ спим... │ ←── ядро свободно
│ крутим... │ │ спим... │
│ крутим... │ └──────┬───────┘
└──────────────┘ │ кто-то сделал signal()
▼
┌──────────────┐
│ проснулись │
└──────────────┘На схеме слева поток молотит процессор, проверяя условие миллионы раз в секунду. Справа — поток спит и не отъедает CPU, пока его не разбудят. Чтобы реализовать правый вариант, нужен специальный примитив — условная переменная.
Активное ожидание иногда оправдано — когда условие наступит через считанные наносекунды (спинлок на ядро, см. /os/book/locks-hardware). Но для «ждём, пока придёт работа» это расточительство.
Что такое условная переменная
Условная переменная (condition variable, CV) — это очередь спящих потоков, привязанная к какому-то условию. У неё три операции:
wait()— «усыпи меня и положи в очередь ждущих». Поток засыпает и не потребляет CPU.signal()— «разбуди ОДИН спящий поток» (если есть кого будить).broadcast()(он жеnotify_all) — «разбуди ВСЕ спящие потоки».
Ключевая, неинтуитивная деталь: wait() всегда работает в паре с мьютексом
(блокировкой). Почему — разберём ниже, это самое важное в главе.
Условная переменная = очередь спящих + связанный мьютекс
┌─────────────────── CV ───────────────────┐
│ очередь спящих: [T2] → [T5] → [T9] │
└───────────────────────────────────────────┘
▲ │
wait() ───┘ (засыпаю, │ signal() ── будит одного (T2)
встаю в очередь) broadcast() ── будит всехНарисована суть: CV хранит список потоков, которые «легли спать». wait добавляет
текущий поток в этот список, signal/broadcast достают потоки оттуда и делают
их снова готовыми к выполнению.
Почему wait должен атомарно отпускать лок
Вот сердцевина темы. Поток проверяет условие под защитой мьютекса:
lock(m)
while (очередь пуста) // условие ещё не выполнено
wait(cv, m) // надо заснуть
... работаем с очередью ...
unlock(m)Допустим, wait делал бы две операции по-отдельности: сначала unlock(m), потом
«заснуть». Между этими двумя шагами есть микроскопическая щель. И именно в неё
может провалиться сигнал. Смотри тайминг:
ПОТОК-ПОТРЕБИТЕЛЬ (ждёт) ПОТОК-ПРОИЗВОДИТЕЛЬ (кладёт)
─────────────────────── ───────────────────────────
lock(m)
видит: очередь пуста
unlock(m) ← лок отпущен
lock(m)
кладёт элемент
signal(cv) ← БУДИТ... но никто не спит!
unlock(m)
заснуть() ← засыпает ПОСЛЕ
сигнала и спит
ВЕЧНО 💀Сигнал ушёл «в пустоту» — потребитель ещё не успел заснуть, а будить его уже поздно. Потребитель уснёт навсегда, хотя элемент в очереди есть. Это классический баг — потерянное пробуждение (lost wakeup).
Решение: wait() обязан атомарно (одним неделимым действием) сделать две
вещи — отпустить лок И заснуть. ОС гарантирует, что между «лок отпущен» и «поток
в очереди спящих» не может вклиниться чужой signal.
wait(cv, m) делает АТОМАРНО:
┌───────────────────────────────────────────┐
│ 1) положить поток в очередь спящих CV │
│ 2) отпустить мьютекс m │ ← всё это одним
│ 3) заснуть │ неделимым шагом
└───────────────────────────────────────────┘
── при пробуждении ──
┌───────────────────────────────────────────┐
│ 4) снова захватить мьютекс m │ ← wait сам берёт лок назад
│ 5) вернуть управление из wait() │
└───────────────────────────────────────────┘Запомни: после возврата из wait() мьютекс снова у тебя в руках — wait сам
его перезахватил. Тебе не надо вызывать lock повторно.
Вот почему лок и CV неразлучны: мьютекс защищает проверку условия и саму выдачу сигнала, не давая образоваться той самой щели. Подробнее про устройство мьютексов — /os/book/locks-hardware.
Железное правило: while, а не if
Второй смертный грех новичка — написать if вместо while:
❌ НЕПРАВИЛЬНО ✅ ПРАВИЛЬНО
───────────── ───────────
lock(m) lock(m)
if (очередь пуста) while (очередь пуста)
wait(cv, m) wait(cv, m)
взять элемент взять элемент
unlock(m) unlock(m)Почему if ломается — две причины:
Причина 1. Ложное пробуждение (spurious wakeup). ОС имеет право разбудить
поток из wait БЕЗ всякого сигнала — просто так, по внутренним причинам (это
разрешено стандартами POSIX и Go). Если стоит if, поток проснётся, решит, что
условие выполнено, и полезет брать элемент из пустой очереди — краш или порча
данных. С while поток перепроверит условие, увидит «всё ещё пусто» и спокойно
заснёт обратно.
Причина 2. Гонка между пробуждёнными потоками. Представь: спят два
потребителя, производитель кладёт ОДИН элемент и делает broadcast (разбудил
обоих). Просыпаются оба, но элемент-то один.
В очереди 1 элемент, broadcast разбудил T1 и T2:
T1: проснулся → перезахватил лок → взял элемент → unlock
T2: проснулся → ждёт лок → захватил лок → очередь СНОВА ПУСТА!
│
├── if: лезет брать из пустой очереди → 💥 баг
└── while: видит «пусто» → wait() обратно спать ✅Между «меня разбудили» и «я реально захватил лок» условие могло измениться: его «перехватил» другой проснувшийся поток. Поэтому правило железобетонное:
Проверяй условие в цикле
while, а не вif. Всегда. Без исключений.
while превращает сигнал из обещания «условие точно выполнено» в подсказку
«возможно, стоит перепроверить». Это надёжно при любых ложных и групповых
пробуждениях.
Канонический пример: производитель-потребитель
Это «hello world» условных переменных. Производитель кладёт элементы в общий буфер (очередь), потребитель забирает. Буфер ограничен по размеру. Нужны два условия: «буфер не пуст» (для потребителя) и «буфер не полон» (для производителя).
producer буфер (ёмкость N) consumer
──────── ────────────────── ────────
put ┌──┬──┬──┬──┐ get
[item] ───────────────────▶│##│##│ │ │───────────────▶ [item]
└──┴──┴──┴──┘
ждёт на CV "не полон" head tail ждёт на CV "не пуст"Логика с двумя условными переменными:
PRODUCER CONSUMER
──────── ────────
lock(m) lock(m)
while (буфер ПОЛОН) while (буфер ПУСТ)
wait(not_full, m) wait(not_empty, m)
put(item) item = get()
signal(not_empty) // есть еда signal(not_full) // есть место
unlock(m) unlock(m)Разберём один цикл по шагам:
t0: буфер пуст. Consumer: lock → пусто → wait(not_empty) → спит, лок отпущен
t1: Producer: lock → не полон → put(item) → signal(not_empty) → unlock
t2: Consumer проснулся внутри wait → перезахватил лок
t3: Consumer: while перепроверил → "не пусто" → get(item) → signal(not_full)
t4: Consumer: unlock. Готово, элемент передан без busy-wait и без гонок.Почему две разные CV, а не одна? Если будить одной CV и продюсеров, и
консьюмеров вперемешку, можно разбудить «не того» (продюсер разбудит продюсера,
оба упрутся в полный буфер). Раздельные CV + while решают это чисто. В крайнем
случае можно обойтись одной CV и broadcast, но это будит лишних — менее
эффективно.
Связь с семафорами и Go
Семафоры (см. /os/book/semaphores) — родственный
примитив. Грубо говоря, семафор = счётчик + очередь спящих внутри одного объекта.
Producer-consumer на семафорах часто пишется компактнее (два семафора empty и
full), потому что счётчик «помнит» сигналы. CV счётчика не имеют — сигнал,
пущенный когда никто не спит, теряется (поэтому и нужна проверка условия под
локом). Это главное практическое различие:
| Свойство | Условная переменная | Семафор |
|---|---|---|
| Хранит состояние/счёт | Нет (сигнал в пустоту теряется) | Да (счётчик помнит up) |
| Нужен внешний мьютекс | Да, обязателен | Нет, самодостаточен |
| Проверка условия | Вручную, в while |
Встроена в счётчик |
| Гибкость условий | Любое сложное условие | Только «счёт ≥ 0» |
| Риск потерять сигнал | Высокий без лока | Нет |
В Go условную переменную напрямую почти не используют — есть sync.Cond,
но идиоматичный Go решает «ждём события» через каналы
(см. /book/channels): канал сам по себе сочетает буфер, блокировку
и пробуждение. sync.Cond остаётся для редких случаев (например, разбудить
сразу много горутин по одному условию через Broadcast). Но даже если ты пишешь
на каналах, механика CV — это то, что крутится под капотом у рантайма и у
sync.Cond. API синхронизации Go разобран в
/book/sync-primitives.
sync.Cond в Go (если уж используешь):
c := sync.NewCond(&mu)
// ждущая горутина: // сигналящая горутина:
c.L.Lock() c.L.Lock()
for !условие { // ← for! условие = true
c.Wait() // отпускает c.Signal() // или c.Broadcast()
} // mu и спит c.L.Unlock()
... работаем ...
c.L.Unlock()Обрати внимание: в Go тоже for, а не if, и Wait() так же атомарно отпускает
c.L и засыпает, а при пробуждении перезахватывает его. Те же законы.
Почему wait() должен АТОМАРНО отпускать мьютекс и засыпать?
Почему условие проверяют в while, а не в if?
В каком состоянии находится мьютекс СРАЗУ после возврата из wait()?
Главное практическое отличие условной переменной от семафора:
Сколько операций пробуждения предоставляет условная переменная (signal и broadcast)?
Как идиоматично ждать события в Go?
Что спрашивают на собесе
- Почему
waitдолжен атомарно отпускать лок и засыпать? Чтобы не было «потерянного пробуждения»: если отпустить лок и заснуть двумя шагами, сигнал может прийти в щель между ними, и поток уснёт навсегда. - Почему проверяем условие в
while, а не вif? Из-за ложных пробуждений (spurious wakeup) и гонок: после пробуждения и перезахвата лока условие могло снова стать ложным (другой поток «увёл» элемент).whileзаставляет перепроверить. - Зачем условной переменной нужен мьютекс? Он защищает атомарность связки «проверка условия → засыпание» и «изменение состояния → сигнал», закрывая гонку между ожидающим и сигналящим.
- Чем CV отличается от семафора? Семафор хранит счётчик и «помнит» сигналы; CV состояния не хранит — сигнал в пустоту теряется, поэтому нужны внешний лок и ручная проверка условия. Зато CV позволяет ждать произвольно сложное условие.
signalvsbroadcast?signalбудит один поток (дешевле, но рискованно, если ждут разные условия),broadcastбудит всех (безопаснее, но лишние пробуждения — «thundering herd»). С корректнымwhileоба безопасны по данным.- Как это связано с Go? Идиоматично — каналы;
sync.Condдля редких случаев. Под капотом всё равно работает механика «усыпить/разбудить» поверх футексов ОС.