GraphLMS

ОС
Начать

ОС · Конкурентность · 14 мин

Условные переменные

Проблема: как ждать события, не сжигая CPU

Представь кассу в кафе. Бариста ждёт, пока в очереди появится заказ. У него два варианта поведения:

  1. Опрос (busy-waiting, активное ожидание): каждую секунду спрашивать «есть заказ? есть заказ? есть заказ?» — и так без остановки. Бариста занят, но бесполезно: он крутит головой вхолостую.
  2. Сон с будильником: «разбудите меня, когда придёт заказ». Бариста спит, ничего не делает, а как только заказ появился — кто-то его толкает.

В программе ровно та же дилемма. Поток должен дождаться условия — например, «в очереди появился элемент», «соединение установлено», «буфер освободился». Если он крутит цикл 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 позволяет ждать произвольно сложное условие.
  • signal vs broadcast? signal будит один поток (дешевле, но рискованно, если ждут разные условия), broadcast будит всех (безопаснее, но лишние пробуждения — «thundering herd»). С корректным while оба безопасны по данным.
  • Как это связано с Go? Идиоматично — каналы; sync.Cond для редких случаев. Под капотом всё равно работает механика «усыпить/разбудить» поверх футексов ОС.
Конкурентные структуры, барьеры памяти, false sharingСемафоры