Конкурентность · 11 мин

Контент v0.12.0

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

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

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

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

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 атомарно отпускает лок

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

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

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

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

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

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

   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 только переводит спящий поток в состояние «готов к запуску». Когда он реально получит процессор и мьютекс, пройдёт время, и мир мог измениться.

   В очереди 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 (на ней спят потребители).

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)
}
получил 10
получил 20
получил 30
получил 40
получил 50
сумма: 150

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

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

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

   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 (громыхающее стадо): толпа просыпается ради одного события.

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

В следующей главе будет семафор — счётчик с очередью спящих. Главное отличие от 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-курсе.

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

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

Проверь себя· Условные переменные

Почему wait() должен АТОМАРНО отпускать мьютекс и засыпать?

Почему условие проверяют в while, а не в if?

В каком состоянии находится мьютекс СРАЗУ после возврата из wait()?

Главное практическое отличие условной переменной от семафора:

Сколько операций пробуждения у условной переменной (не считая wait)?

Как идиоматично ждать события в Go?

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

  • Проверять условие через 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 позволяет ждать произвольного условия.

Продолжить