---
title: "Условные переменные"
description: "Лок из прошлых глав решает одну задачу: не пустить двоих в критическую секцию. Но потокам часто нужно другое — дождаться, пока что-то случится. Воркер ждёт, пока в очереди…"
canonical: https://graphlms.ru/os/book/condition-variables
course: "Операционные системы"
updated: 2026-10-12
lang: ru
publisher: GraphLMS
---

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

> Источник: https://graphlms.ru/os/book/condition-variables — курс «Операционные системы», GraphLMS.

## Как ждать события, не сжигая CPU

Лок из [прошлых глав](/os/book/locks-hardware) решает одну задачу: не пустить
двоих в критическую секцию. Но потокам часто нужно другое — дождаться, пока
что-то случится. Воркер ждёт, пока в очереди появится задача. Пул соединений
ждёт, пока кто-то вернёт соединение. Писатель ждёт, пока в буфере освободится
место.

Наивный способ — проверять условие в цикле:

```text
lock(m)
while (очередь пуста) {
    unlock(m)
    lock(m)        // проверим ещё раз
}
взять задачу
unlock(m)
```

Это **активное ожидание** (busy waiting): поток занимает ядро процессора на 100%, проверяя
условие миллионы раз в секунду, а задача может прийти через минуту. Если таких
воркеров восемь, восемь ядер греют воздух. Активное ожидание оправдано, только
когда событие наступит через наносекунды (как в спинлоке).

Нужен способ сказать: «усыпи меня, пока условие не станет истинным, и разбуди,
когда кто-то его изменит». Для этого существует **условная переменная**.

## Что такое условная переменная

**Условная переменная** (condition variable, CV) — это очередь спящих потоков,
которые ждут, пока какое-то условие станет истинным. Операций три:

- `wait(cv, m)` — уснуть в очереди этой CV (CPU не тратится);
- `signal(cv)` — разбудить один спящий поток, если такой есть;
- `broadcast(cv)` — разбудить все спящие потоки.

Само условие («очередь не пуста») в CV не хранится. Его проверяет программа,
читая общие данные под мьютексом. CV — только механизм «уснуть и быть
разбуженным». Поэтому `wait` всегда принимает мьютекс вторым аргументом, и
почему это обязательно — главная мысль главы.

## Почему wait атомарно отпускает лок

Потребитель проверяет условие под мьютексом и, если оно ложно, засыпает:

```text
lock(m)
while (очередь пуста)
    wait(cv, m)
взять задачу
unlock(m)
```

Предположим, `wait` был бы просто двумя шагами: `unlock(m)`, потом «уснуть».
Посмотрим, что может случиться между ними:

```text
   ПОТРЕБИТЕЛЬ                       ПРОИЗВОДИТЕЛЬ
   ────────────────────────────      ────────────────────────────
   lock(m)
   видит: очередь пуста
   unlock(m)
                                     lock(m)
                                     кладёт задачу
                                     signal(cv)   ← в очереди CV никого нет
                                     unlock(m)
   уснуть в очереди cv               ← засыпает уже после сигнала
   ...спит, хотя задача в очереди
```

Сигнал ушёл, когда потребитель ещё не лёг спать, и пропал: CV не запоминает
сигналы. Потребитель спит, задача лежит, и разбудить его некому, пока не придёт
следующая задача (а если она не придёт — никогда). Это **потерянное
пробуждение** (lost wakeup), та же ошибка, от которой futex защищался проверкой
значения в [главе про локи](/os/book/locks-hardware).

Поэтому контракт `wait(cv, m)` такой:

```text
   wait(cv, m):
     1) встать в очередь cv и отпустить m — одним неделимым шагом
     2) спать
     ── после пробуждения ──
     3) снова захватить m
     4) вернуться
```

Пока поток держит `m`, производитель не может положить задачу и послать сигнал.
А отпускает `m` поток уже стоя в очереди CV, так что сигнал его найдёт. Щели нет.
Реализации (pthread, рантайм Go) обеспечивают это сами, программист только
обязан вызывать `wait` с захваченным мьютексом и менять условие тоже под ним.

Обратите внимание на шаг 3: после возврата из `wait` мьютекс снова ваш, звать
`lock` повторно не нужно, а `unlock` в конце — нужно.

## Почему while, а не if

После пробуждения условие нужно проверить заново. Первая причина — семантика
сигнала. Почти все реальные системы реализуют её так, как в языке Mesa
(отсюда название **Mesa-семантика**): `signal` только переводит спящий поток в
состояние «готов к запуску». Когда он реально получит процессор и мьютекс,
пройдёт время, и мир мог измениться.

```text
   В очереди 1 задача. Спят два потребителя, T1 и T2.
   Производитель положил задачу и сделал broadcast.

   T1: проснулся → взял m → взял задачу → unlock
   T2: проснулся → ждал m → взял m → а очередь снова пуста
       с if:    берёт задачу из пустой очереди → паника или мусор
       с while: видит «пусто» → wait() → спит дальше
```

То же самое бывает и без `broadcast`: пока разбуженный T1 ждал мьютекс, третий
поток T3, который только что пришёл и вообще не спал, успел захватить мьютекс
первым и забрал задачу.

Вторая причина — **ложные пробуждения** (spurious wakeups). POSIX разрешает
`pthread_cond_wait` вернуться без всякого сигнала (так проще реализовать его
эффективно на многопроцессорных машинах). В Go это не так: документация
`sync.Cond` прямо говорит, что `Wait` не возвращается без `Signal` или
`Broadcast`. Но цикл в Go всё равно обязателен из-за первой причины.

Отсюда правило без исключений: условие проверяется в цикле `while` (в Go —
`for`). Сигнал означает не «условие выполнено», а «стоит проверить ещё раз».

## Производитель и потребитель

Классическая задача: ограниченный буфер на N мест. Производитель ждёт, если
буфер полон. Потребитель ждёт, если буфер пуст. Нужны две условные переменные:
`notFull` (на ней спят производители) и `notEmpty` (на ней спят потребители).

```go play
package main

import (
	"fmt"
	"sync"
)

// BoundedBuffer — очередь ёмкостью capacity на мьютексе и двух условных переменных.
type BoundedBuffer struct {
	mu       sync.Mutex
	notEmpty *sync.Cond // ждут потребители
	notFull  *sync.Cond // ждут производители
	items    []int
	capacity int
}

func NewBoundedBuffer(capacity int) *BoundedBuffer {
	b := &BoundedBuffer{capacity: capacity}
	b.notEmpty = sync.NewCond(&b.mu)
	b.notFull = sync.NewCond(&b.mu)
	return b
}

func (b *BoundedBuffer) Put(x int) {
	b.mu.Lock()
	for len(b.items) == b.capacity { // for, а не if
		b.notFull.Wait() // отпускает mu, спит, при пробуждении снова берёт mu
	}
	b.items = append(b.items, x)
	b.notEmpty.Signal()
	b.mu.Unlock()
}

func (b *BoundedBuffer) Get() int {
	b.mu.Lock()
	for len(b.items) == 0 {
		b.notEmpty.Wait()
	}
	x := b.items[0]
	b.items = b.items[1:]
	b.notFull.Signal()
	b.mu.Unlock()
	return x
}

func main() {
	buf := NewBoundedBuffer(2)
	var wg sync.WaitGroup

	wg.Add(1)
	go func() { // производитель кладёт 5 чисел в буфер на 2 места
		defer wg.Done()
		for i := 1; i <= 5; i++ {
			buf.Put(i * 10)
		}
	}()

	sum := 0
	for i := 0; i < 5; i++ {
		x := buf.Get()
		sum += x
		fmt.Println("получил", x)
	}
	wg.Wait()
	fmt.Println("сумма:", sum)
}
```

```text
получил 10
получил 20
получил 30
получил 40
получил 50
сумма: 150
```

Производитель кладёт пять чисел в буфер на два места, поэтому как минимум трижды
упирается в «полно» и засыпает на `notFull`, пока потребитель не освободит
место. Ни одна горутина не крутится вхолостую.

### Почему одной CV мало

Соблазнительно обойтись одной условной переменной на всех. Разберём, где это
ломается. Буфер на одно место, два потребителя C1 и C2, один производитель P,
одна CV, будим через `signal`, проверки в `while`:

```text
   1. C1: буфер пуст → wait          очередь CV: [C1]
   2. C2: буфер пуст → wait          очередь CV: [C1, C2]
   3. P:  кладёт элемент, signal     будит C1; очередь: [C2]
   4. P:  буфер полон → wait         очередь: [C2, P]
   5. C1: забирает элемент, signal   будит... C2 (он первый в очереди)
   6. C1: буфер пуст → wait          очередь: [P, C1]
   7. C2: буфер пуст → wait          очередь: [P, C1, C2]

   Спят все трое. Элементов нет, места есть, а разбудить P некому.
```

На шаге 5 потребитель хотел разбудить производителя, но CV общая, и сигнал
достался другому потребителю. Решения два. Правильное — две CV, как в коде
выше: потребители будят только производителей и наоборот. Грубое —
`broadcast` вместо `signal`: проснутся все, и нужный тоже, но остальные
проснутся зря, возьмут мьютекс, увидят ложное условие и снова уснут. Когда
спящих много, это называют **thundering herd** (громыхающее стадо): толпа
просыпается ради одного события.

## Условные переменные и семафоры

В [следующей главе](/os/book/semaphores) будет **семафор** — счётчик с очередью
спящих. Главное отличие от CV: семафор помнит сигналы. Если `signal` пришёл,
когда никто не спал, счётчик увеличится, и следующий `wait` пройдёт сразу. CV
ничего не помнит, поэтому всё держится на условии, которое проверяется под
мьютексом.

| | Условная переменная | Семафор |
|---|---|---|
| Хранит состояние | нет, сигнал без спящих теряется | да, счётчик |
| Нужен внешний мьютекс | да | нет |
| Какое условие можно ждать | любое (`len(q) > 0 && !closed`) | «счётчик больше нуля» |

## Что в Go

В Go есть `sync.Cond` с методами `Wait`, `Signal`, `Broadcast`, и работает он
ровно по правилам этой главы: `Wait` вызывается с захваченным `c.L`, атомарно
отпускает его и паркует горутину, после пробуждения снова захватывает. Условие —
в цикле `for`.

На практике `sync.Cond` встречается редко. Типичное ожидание события в Go
пишут каналами: канал сам объединяет буфер, блокировку и пробуждение, а
`select` позволяет ждать несколько событий сразу и выйти по таймауту или
`context` (у `sync.Cond` отмены нет). Ограниченный буфер из этой главы — это
`make(chan int, 2)`. См. [каналы](/go/book/channels) и
[примитивы синхронизации](/go/book/sync-primitives) в Go-курсе.

Где `sync.Cond` всё-таки уместен: когда одно изменение состояния должно
разбудить много ждущих (`Broadcast`), и ждут они сложного условия над общими
данными. Например, несколько горутин ждут, пока кэш перейдёт из состояния
«загружается» в «готов».

Устроено это так: каналы и `sync.Cond` хранят очередь ждущих горутин внутри
рантайма и паркуют их через планировщик Go. Потоки ОС при этом не засыпают;
futex рантайм использует уровнем ниже, когда усыпляет простаивающий поток ОС.

> Квиз по разделу: см. вопросы в конце документа или на https://graphlms.ru/os/book/condition-variables

## Частые ошибки

- Проверять условие через `if` вместо `for`/`while`.
- Менять условие или вызывать `Signal` без мьютекса, которым защищено условие.
- Ждать, что сигнал «запомнится», если в момент `signal` никто не спал.
- Использовать одну условную переменную и `Signal` для производителей и потребителей сразу.
- Брать `sync.Cond` там, где нужен таймаут или отмена: у `Wait` их нет, нужен канал и `select`.

## Проверьте себя

1. Почему `wait` должен отпускать мьютекс и засыпать одним неделимым шагом?
2. В Go нет ложных пробуждений. Зачем тогда `for` вокруг `Wait`?
3. Что произойдёт в схеме с одной условной переменной и `signal`, если потребитель разбудит другого потребителя?
4. В `BoundedBuffer` замените `for` на `if` в методе `Get`. Есть два потребителя и один производитель. Опишите последовательность событий, при которой `Get` упадёт с выходом за границы слайса.

Ответы. (1) Иначе сигнал может прийти между «отпустил» и «уснул», и поток уснёт навсегда (lost wakeup). (2) Между сигналом и моментом, когда разбуженная горутина снова захватит мьютекс, условие может изменить другая горутина. (3) Оба потребителя уснут, производитель тоже спит — все трое ждут, и разбудить их некому. (4) C1 ждёт в `Get` на пустом буфере. Производитель кладёт элемент и будит C1. Пока C1 ждёт мьютекс, C2 только что вызвал `Get`, захватывает мьютекс первым, видит один элемент и забирает его. C1 получает мьютекс, с `if` условие не перепроверяет и читает `items[0]` из пустого слайса — паника index out of range.

## Что спрашивают на собесе

- **Почему `wait` атомарно отпускает лок и засыпает?** Иначе сигнал может
  прийти в щель между «отпустил» и «уснул», и поток уснёт навсегда (lost
  wakeup).
- **Почему условие проверяют в `while`?** Mesa-семантика: между сигналом и
  реальным запуском условие могли изменить другие потоки. Плюс POSIX допускает
  ложные пробуждения. В Go ложных пробуждений нет, но цикл всё равно нужен.
- **В каком состоянии мьютекс после возврата из `wait`?** Захвачен вызывающим.
- **Зачем CV мьютекс?** Чтобы проверка условия и засыпание, а также изменение
  условия и сигнал не перемешались.
- **Почему в producer/consumer две CV?** С одной CV и `signal` потребитель может
  разбудить потребителя, и все уснут. Две CV будят только нужную сторону.
- **`signal` или `broadcast`?** `signal` дешевле, если любой ждущий подойдёт.
  `broadcast` — когда изменение может быть интересно многим или ждут разных
  условий; цена — лишние пробуждения.
- **Чем CV отличается от семафора?** Семафор помнит сигналы в счётчике, CV —
  нет; зато CV позволяет ждать произвольного условия.

## Вопросы для самопроверки

**Квиз «Условные переменные»**

1. Почему wait() должен АТОМАРНО отпускать мьютекс и засыпать?
   - Чтобы сэкономить память на очереди спящих
   - Чтобы ускорить захват лока
   - Атомарность тут не нужна, это оптимизация
   - Чтобы между отпусканием лока и засыпанием не вклинился signal и не случилось потерянное пробуждение (верно)
   - Пояснение: Если отпустить лок и заснуть двумя отдельными шагами, в щель между ними может попасть signal от другого потока. Тогда сигнал уйдёт в пустоту, а поток уснёт навсегда — это lost wakeup. Атомарность wait закрывает эту щель.
2. Почему условие проверяют в while, а не в if?
   - Из-за гонки: после пробуждения другой поток мог изменить условие (увести элемент) (верно)
   - Потому что if медленнее while
   - Из-за ложных пробуждений (spurious wakeup) — ОС может разбудить без сигнала (верно)
   - Чтобы wait не перезахватывал лок
   - Пояснение: while нужен по двум причинам: ложные пробуждения (поток может проснуться без signal) и гонка между разбуженными потоками (пока ждал лок, условие снова стало ложным). while заставляет перепроверить условие после пробуждения.
3. В каком состоянии находится мьютекс СРАЗУ после возврата из wait()?
   - Уничтожен
   - Захвачен другим потоком
   - Захвачен текущим потоком — wait сам его перезахватил (верно)
   - Отпущен — нужно вызвать lock вручную
   - Пояснение: wait при пробуждении сам перезахватывает мьютекс перед возвратом. Поэтому после wait лок снова у вас, и звать lock повторно не нужно — а в конце критической секции надо сделать unlock.
4. Главное практическое отличие условной переменной от семафора:
   - CV быстрее семафора всегда
   - Семафор хранит счётчик и помнит сигналы, а CV состояния не хранит — сигнал в пустоту теряется (верно)
   - Семафор не умеет будить потоки
   - CV не требует никакой синхронизации
   - Пояснение: Семафор = счётчик + очередь: операция up увеличивает счётчик, и сигнал не теряется, даже если никто не спит. CV состояния не имеет: signal, пущенный когда никто не ждёт, пропадает. Поэтому CV всегда работает с внешним мьютексом и ручной проверкой условия.
5. Сколько операций пробуждения у условной переменной (не считая wait)?
   - Ответ: 2
   - Пояснение: Две: signal будит один ждущий поток, broadcast (notify_all) будит всех. wait — это ожидание, а не пробуждение.
6. Как идиоматично ждать события в Go?
   - Обычно через каналы; sync.Cond — для редких случаев вроде Broadcast многим горутинам (верно)
   - Только через sync.Cond, каналы для этого не годятся
   - Через time.Sleep в цикле
   - Через busy-wait в цикле for
   - Пояснение: В Go идиоматично использовать каналы — они сочетают буфер, блокировку и пробуждение. sync.Cond существует, но нужен редко (например, разбудить сразу много горутин). При этом под капотом и каналы, и Cond используют ту же механику усыпить/разбудить.
