Программирование GoГорутины и каналыРазработчик Go среднего уровня

Когда для ожидания изменения общего состояния в Go уместнее sync.Cond, чем канал?

Когда для ожидания изменения общего состояния в Go уместнее sync.Cond, чем канал?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

sync.Cond уместнее, когда горутины должны ждать выполнения условия над общим изменяемым состоянием, защищённым одним mutex, а не получать поток отдельных событий. Ожидание выполняют в цикле: проверить условие под блокировкой, вызвать Wait, после пробуждения снова проверить условие.

Канал обычно лучше моделирует передачу данных, сигнал завершения или поток событий. sync.Cond подходит для координации доступа к уже существующему состоянию, когда важно не потерять смысл сигнала: состояние проверяется заново, даже если уведомление произошло раньше ожидания.

Исторический контекст

Условные переменные появились как примитив координации потоков вокруг общего состояния. Они решают проблему ожидания без активного опроса: поток временно освобождает mutex и засыпает, пока другой поток не изменит состояние и не подаст сигнал.

В Go эту модель предоставляет sync.Cond. Каналы предлагают другую модель — коммуникацию между горутинами через передачу значений, поэтому замена одного примитива другим зависит от того, что именно нужно выразить: состояние или сообщение.

Постановка проблемы

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

sync.Cond решает это через атомарную для логики ожидания последовательность: горутина отпускает mutex и засыпает, а при пробуждении снова захватывает mutex. Неверное использование опасно: проверка условия вне блокировки, одиночный if вместо цикла или отсутствие изменения состояния могут привести к чтению некорректных данных, преждевременному продолжению или вечному ожиданию.

Подробное решение

Условие всегда проверяют в цикле под тем же mutex, который защищает состояние. Метод Wait освобождает связанный mutex на время ожидания, блокирует горутину, а перед возвратом снова захватывает этот mutex.

package main import ( "fmt" "sync" ) func main() { mu := sync.Mutex{} cond := sync.NewCond(&mu) queue := []int{} go func() { mu.Lock() queue = append(queue, 42) cond.Signal() mu.Unlock() }() mu.Lock() for len(queue) == 0 { cond.Wait() } fmt.Println(queue[0]) mu.Unlock() }

Вызов Signal будит одну ожидающую горутину, а Broadcast — всех. Сигнал сам по себе не хранится: если в момент вызова ожидающих нет, последующее ожидание не будет разблокировано этим прошлым сигналом. Поэтому важен не сигнал, а проверяемое состояние, которое производитель изменяет под mutex.

Канал предпочтительнее, когда нужно передавать значения, строить конвейер, ограничивать поток работы или распространять отмену. sync.Cond не передаёт данные и не обеспечивает владение ими; он лишь помогает эффективно ждать изменения состояния. При нескольких независимых условиях часто проще использовать отдельные каналы или контекст, чем усложнять протокол с несколькими condition variables.

Ситуация из практики

В сервере есть очередь заданий и несколько рабочих горутин. Рабочие должны спать, пока очередь пуста, а после добавления задания один из них должен проснуться.

Можно закрывать и заново создавать каналы уведомлений. Такой вариант способен работать, но требует аккуратного управления жизненным циклом каналов, особенно при нескольких производителях и повторных уведомлениях. Канал заданий проще, если сами задания естественно передаются через него: тогда ожидание и передача данных объединяются.

В выбранном варианте очередь остаётся общей структурой, защищённой mutex, а sync.Cond используется только для ожидания непустого состояния. Производитель добавляет задание и вызывает Signal, потребитель после пробуждения снова проверяет длину очереди. Это позволяет не терять уведомления и не создавать канал на каждое изменение, сохраняя явное управление очередью и её дополнительными инвариантами.

Что кандидаты часто упускают

  1. Почему проверка условия должна находиться в цикле, а не в условном операторе?

    Между пробуждением и фактическим продолжением работы другая горутина может изменить состояние. Например, несколько потребителей проснулись после Broadcast, но элемент очереди успел забрать только один из них. Остальные должны снова проверить условие и продолжить ожидание.

    Кроме того, сам факт пробуждения не означает, что требуемое условие обязательно истинно. Правильный шаблон — удерживать mutex, выполнять for с проверкой предиката, внутри цикла вызывать Wait, а после выхода работать с защищённым состоянием.

  2. Можно ли вызывать Signal без захвата mutex?

    Документация Go допускает вызов Signal и Broadcast без удержания L, однако обычно сигнал подают после изменения защищаемого состояния и при удерживаемом mutex. Это делает связь между изменением состояния и уведомлением очевидной и предотвращает ошибки в протоколе доступа.

    Критично не формальное место вызова сигнала, а корректная синхронизация самого состояния. Если производитель изменяет его без mutex, а потребитель читает под mutex, возникает гонка данных независимо от того, где был вызван Signal.

  3. Чем sync.Cond отличается от канала закрытия для одноразового события?

    Закрытый канал хорошо подходит для широковещательного одноразового сигнала: все последующие получатели смогут обнаружить закрытие. Он удобен для отмены или объявления завершения, когда событие нельзя «отменить» и оно больше не повторяется.

    sync.Cond не фиксирует факт сигнала после его отправки. Он подходит не для хранения одноразового события, а для ожидания текущего предиката, например «очередь не пуста» или «состояние стало готовым». Если нужно сохранить состояние события, его хранят отдельно под mutex и проверяют в цикле; если нужна простая необратимая отмена, обычно понятнее использовать закрытие канала или context.Context.