Модуль: Concurrency · Уровень: Senior

TL;DR#

sync.Cond — условная переменная: горутина под захваченным мьютексом вызывает Wait, чтобы заснуть до тех пор, пока другая горутина не вызовет Signal (разбудить одну) или Broadcast (разбудить всех). Нужна редко: почти всё решается каналами. Реальная ниша — широковещательное пробуждение многих ожидающих по изменению разделяемого состояния, где каналы неудобны. Wait обязан вызываться в цикле проверки условия из-за spurious-подобных пробуждений и гонок.

Теория#

Контракт#

c := sync.NewCond(&sync.Mutex{})

// Ожидающая сторона
c.L.Lock()
for !condition() {   // ВСЕГДА в цикле, не if
    c.Wait()         // атомарно: Unlock(L) + park; при пробуждении: Lock(L)
}
useState()
c.L.Unlock()

// Сигнализирующая сторона
c.L.Lock()
changeState()
c.L.Unlock()
c.Signal()          // или c.Broadcast()
  • Wait() атомарно отпускает мьютекс и паркует горутину; при пробуждении снова захватывает мьютекс перед возвратом. Поэтому Wait вызывается строго под Lock.
  • Signal() будит одну ожидающую горутину (если есть).
  • Broadcast() будит все ожидающие горутины.
  • Сигнал/Broadcast можно вызывать с захваченным мьютексом или без — но менять защищаемое состояние нужно под мьютексом.

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

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

  • между пробуждением и захватом мьютекса другая горутина могла «перехватить» состояние (например, при Broadcast разбудили всех, но ресурс достался первому);
  • возможны нежелательные ранние пробуждения.

Поэтому Wait всегда внутри for !condition(): проснулись → проверили условие → если не выполнено, снова Wait. Это инвариант, нарушение которого — классический баг.

Устройство под капотом#

type Cond struct {
    noCopy  noCopy
    L       Locker         // обычно *Mutex
    notify  notifyList     // список ожидающих в рантайме
    checker copyChecker    // ловит копирование Cond
}
  • notifyList — структура рантайма с двумя счётчиками-тикетами (wait, notify) и списком парковки.
  • Wait: берёт «тикет» (runtime_notifyListAdd), отпускает мьютекс, паркуется (runtime_notifyListWait), при пробуждении захватывает мьютекс.
  • Signal/Broadcast: runtime_notifyListNotifyOne/NotifyAll пробуждают горутины по тикетам. Тикеты гарантируют, что сигнал не «потеряется» для уже добавленной в список горутины и не разбудит будущую.
  • copyChecker хранит адрес самого Cond и паникует, если обнаруживает копирование (несовпадение адреса).

Cond против каналов#

Закрытие канала — это естественный broadcast: все, кто читает <-done, разблокируются. Это покрывает большинство сценариев и интегрируется с select/context.

Критерийsync.CondКаналы
BroadcastBroadcast()close(ch) (одноразово)
Повторные сигналыда, многократноканал-сигнал нужно пересоздавать после close
Интеграция с select/ctxнет (Wait не selectable)да
Передача данныхнет (только сигнал)да
Связка с разделяемым состояниеместественная (под общим мьютексом)требует доп. синхронизации
Простота/безопасностьлегко ошибиться (for, гонки)идиоматичнее

Когда Cond оправдан: много ожидающих, повторяющиеся изменения общего состояния, нужно будить выборочно (Signal) или всех (Broadcast) многократно, и заводить/закрывать каналы на каждое событие дорого/неудобно. Пример: пул ресурсов, ограниченная очередь (producer/consumer) с пробуждением по «появилось место»/«появился элемент».

// Ограниченная очередь на Cond
type Queue struct {
    mu       sync.Mutex
    notEmpty *sync.Cond
    notFull  *sync.Cond
    items    []int
    cap      int
}

func (q *Queue) Push(x int) {
    q.mu.Lock()
    for len(q.items) == q.cap {
        q.notFull.Wait()
    }
    q.items = append(q.items, x)
    q.notEmpty.Signal()
    q.mu.Unlock()
}

Подводные камни / gotchas#

  • if вместо for вокруг Wait — самый частый баг: проснулись по ложному/устаревшему сигналу, условие не выполнено, но идём дальше → работа со «свежим» состоянием, которого нет.
  • Изменение состояния без мьютекса перед сигналом → гонка: ожидающий может проверить условие в момент между изменением и Signal и пропустить пробуждение. Меняйте состояние под c.L.
  • Потерянный сигнал (lost wakeup). Если Signal вызван, когда никто ещё не в Wait (горутина не успела заснуть), сигнал «теряется». Защита — менять состояние под мьютексом и проверять условие в цикле перед Wait; тогда заснуть и пропустить уже изменённое состояние нельзя.
  • Broadcast + контеншн. Broadcast будит всех, они все ломятся за мьютексом, большинство снова видит невыполненное условие и засыпает — «thundering herd». Если будить нужно одного, используйте Signal.
  • Копирование Cond запрещено (copyChecker паникует в рантайме, go vet тоже ругается). Храните по указателю.
  • Не интегрируется с context/таймаутами. Wait нельзя прервать таймаутом или отменой напрямую. Для отменяемого ожидания нужен дополнительный механизм (например, периодический Broadcast по таймеру + проверка ctx, или вообще каналы).

Вопросы на собеседовании#

В: Что делает Wait под капотом? О: Атомарно отпускает мьютекс и паркует горутину (добавив её в notifyList по тикету). При пробуждении заново захватывает мьютекс перед возвратом. Поэтому Wait вызывается только под захваченным L.

В: Почему Wait обязательно в цикле? О: После пробуждения и повторного захвата мьютекса условие может уже не выполняться (другая горутина перехватила состояние, особенно при Broadcast; возможны ранние пробуждения). Цикл for !cond() перепроверяет и при необходимости снова Wait.

В: В чём разница Signal и Broadcast? О: Signal будит одну ожидающую горутину, Broadcast — все. Signal — когда изменение состояния может удовлетворить лишь одного ждущего (положили один элемент). Broadcast — когда изменение касается всех (состояние закрыто/сброшено).

В: Что такое потерянный сигнал и как его избежать? О: Если Signal послан до того, как горутина успела войти в Wait, пробуждение пропадает. Избегается тем, что состояние меняется под тем же мьютексом, а ожидающий проверяет условие в цикле перед Wait: либо он увидит уже изменённое состояние и не заснёт, либо заснёт до изменения и будет разбужен.

В: Когда Cond лучше каналов? О: Когда нужно многократно и/или выборочно будить много горутин по изменению общего состояния, защищённого мьютексом, и пересоздавать каналы на каждое событие неудобно/дорого. Пример: bounded queue, пул ресурсов. В остальных случаях идиоматичнее каналы.

В: Почему Cond редко используют? О: Каналы + close покрывают broadcast, select даёт таймауты/отмену, передачу данных. Cond не selectable, не отменяем, легко ошибиться (for, гонки, lost wakeup). Поэтому в идиоматичном Go его избегают, кроме узких ниш.

В: Можно ли отменить Wait по таймауту/контексту? О: Напрямую нет. Обходные пути: будить всех периодическим Broadcast по таймеру и проверять ctx в цикле, либо отказаться от Cond в пользу каналов/select. Это одна из причин редкости Cond.

В: Что произойдёт при копировании Cond? О: copyChecker в рантайме обнаружит несовпадение сохранённого адреса и вызовет панику; go vet также предупреждает. Cond нужно хранить и передавать по указателю.

На что копают на senior+#

  • notifyList и тикетная схема (wait/notify счётчики): как она предотвращает потерю и «угон» сигнала между Add и Wait.
  • Атомарность Unlock+park в Wait и почему без неё возможен lost wakeup.
  • Thundering herd при Broadcast и стратегии (Signal vs Broadcast, повторная проверка условия).
  • Почему Cond не интегрирован с runtime poller/select и как это влияет на дизайн (выбор каналов).
  • Эквивалентность «bounded queue на Cond» и «bounded queue на буферизованном канале»: trade-offs производительности и читаемости.