Модуль: 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 | Каналы |
|---|---|---|
| Broadcast | Broadcast() | 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 производительности и читаемости.