Модуль: Concurrency · Уровень: Senior
TL;DR#
«Share memory by communicating» — это рекомендация, а не догма. Каналы хороши, когда вы передаёте владение данными между горутинами или координируете поток событий/жизненный цикл; мьютекс/atomic хороши, когда вы защищаете разделяемое состояние при коротких операциях. Канал внутри — это структура с мьютексом и очередью, так что он не «бесплатнее» мьютекса. Критерий простой: владение/передача/оркестрация → канал; разделяемое состояние/кэш/счётчик → мьютекс/atomic.
Теория#
Два девиза Go#
- «Don’t communicate by sharing memory; share memory by communicating.» — каналы как основной способ координации.
- Контр-цитата команды Go: «Use whichever is most expressive and/or most simple.» (Go wiki). Каналы — не самоцель.
Что такое канал на самом деле#
Канал (hchan) — это структура с мьютексом (lock), кольцевым буфером, очередями sendq/recvq парковки горутин. То есть канал реализован поверх блокировок и семафоров рантайма. Поэтому:
- канал не дешевле мьютекса для простой защиты переменной — наоборот, обычно дороже (больше работы: блокировка, копирование значения, парковка/побудка);
- «канал = безопасность» — миф; неправильное использование канала даёт дедлоки и утечки не хуже мьютекса.
Когда КАНАЛ#
- Передача владения данными. Горутина-производитель отдаёт значение и больше его не трогает — получатель становится единственным владельцем. Нет shared state → нет гонок по построению.
jobs := make(chan Job) go func() { for j := range jobs { process(j) } }() // владелец j — обработчик jobs <- job - Оркестрация / поток событий. Pipeline, fan-in/fan-out, сигналы (done/cancel), таймауты через select. Каналы интегрируются с
selectиcontext. - Сигнал «событие произошло».
close(done)как broadcast; буфер для развязки producer/consumer. - Распределение работы. Worker pool: один канал задач, N воркеров.
Когда MUTEX (или atomic)#
- Защита разделяемого состояния с короткими операциями: счётчики, кэши, мапы, конфиг.
type Cache struct { mu sync.Mutex; m map[string]V } func (c *Cache) Get(k string) V { c.mu.Lock(); defer c.mu.Unlock(); return c.m[k] } - Составной инвариант над несколькими полями структуры (atomic не умеет).
- Горячий путь, где важна латентность/throughput простой защиты — мьютекс с быстрым CAS-путём дешевле канала.
- Кэш/реестр/пул, где «состояние» естественно живёт в одном месте, а не «течёт» между горутинами.
Сравнительная таблица#
| Критерий | Канал | Mutex/atomic |
|---|---|---|
| Парадигма | передача владения, коммуникация | защита общего состояния |
| Интеграция с select/ctx/таймаут | да | нет |
| Передача данных | да | нет (только защита) |
| Broadcast события | да (close) | нет (нужен Cond) |
| Стоимость простой защиты | выше | ниже (CAS быстрый путь) |
| Составной инвариант | неудобно | да |
| Риск дедлока/утечки | да (забытая ветка) | да (забытый Unlock) |
| Backpressure | да (буфер/блокировка) | нет |
Критерии выбора (чек-лист)#
- Данные перемещаются от одной горутины к другой → канал.
- Данные остаются на месте, доступ конкурентный → мьютекс/atomic.
- Нужен таймаут/отмена/выбор из нескольких источников → канал + select.
- Нужен счётчик/флаг/один указатель → atomic.
- Нужно ограничить число одновременных операций → канал-семафор (
chan struct{}ёмкости N). - Нужно broadcast всем «стоп» →
close(done). - Высокая частота, простая операция, важна латентность → мьютекс/atomic.
Подводные камни / gotchas#
- «Каналы всегда безопаснее/быстрее» — неверно. Канал — это мьютекс + очередь + копирование. Для защиты счётчика канал избыточен и медленнее atomic.
- Один разделяемый объект «спрятать» за каналом (актор-горутина, владеющая состоянием) — валидный паттерн, но добавляет горутину, латентность и точку отказа. Иногда мьютекс проще.
- Смешение подходов в одном месте (часть состояния через канал, часть через мьютекс на тех же данных) → путаница и гонки. Выберите одну модель владения на единицу данных.
- Мапа под мьютексом vs sync.Map vs канал-актор: для read-heavy —
atomic.Pointer(COW) или sharded mutex; канал-актор сериализует доступ и может стать бутылочным горлышком. - Дедлок на небуферизованном канале при отправке без готового получателя — частая ошибка «канальной» модели, аналог забытого Unlock в «мьютексной».
Вопросы на собеседовании#
В: Канал или мьютекс — что выбрать и почему именно так формулируют критерий? О: Если данные передаются между горутинами (меняется владелец) — канал: получатель становится единственным владельцем, гонок нет по построению. Если данные разделяются и доступ конкурентный (счётчик, кэш, инвариант) — мьютекс/atomic. Это и есть граница: коммуникация/владение vs защита состояния.
В: Правда ли, что каналы безопаснее и поэтому всегда предпочтительны? О: Нет. Каналы дают другую модель (передача владения), которая для своих задач элегантна, но они так же подвержены дедлокам и утечкам. Сама команда Go советует выбирать наиболее простой и выразительный инструмент, а не каналы по умолчанию.
В: Дешевле ли канал, чем мьютекс? О: Обычно нет. Канал внутри — структура с собственным мьютексом, кольцевым буфером и очередями парковки, плюс копирование значения. Для простой защиты переменной atomic/мьютекс быстрее. Канал оправдан коммуникацией, а не экономией.
В: Как защитить счётчик: канал или atomic?
О: atomic.Int64. Канал тут — оверкилл: больше аллокаций, парковок, копирований и латентности. Atomic.Add — одна аппаратная инструкция.
В: Когда канал явно лучше мьютекса? О: Когда нужны: передача данных между горутинами, выбор из нескольких источников/таймаут/отмена (select+ctx), backpressure через буфер, broadcast «стоп» через close, распределение задач воркерам. Мьютекс этого не умеет.
В: Что такое «актор» на канале и его минусы? О: Горутина — единоличный владелец состояния, остальные шлют ей запросы по каналу; она сериализует доступ. Плюс: нет блокировок у клиентов, чёткое владение. Минусы: лишняя горутина, латентность запрос-ответ, потенциальное бутылочное горлышко, сложнее отладка. Часто мьютекс проще.
В: Можно ли смешивать каналы и мьютексы? О: В программе — да, для разных задач. Но для одной единицы данных выбирайте одну модель владения. Защищать одно и то же состояние частично каналом, частично мьютексом — путь к гонкам и путанице.
В: Как ограничить конкуренцию — каналом или мьютексом?
О: Каналом-семафором: sem := make(chan struct{}, N); перед работой sem <- struct{}{}, после — <-sem. Это ограничивает N одновременных операций. Мьютекс даёт только N=1. Для N>1 — буферизованный канал или golang.org/x/sync/semaphore.
На что копают на senior+#
- Реализация
hchan: канал как мьютекс+буфер+sendq/recvq; почему он не дешевле мьютекса. - Передача владения как способ устранить гонки без блокировок (memory model: send happens-before recv).
- Актор-модель на горутине-владельце vs мьютекс vs atomic.Pointer COW: trade-offs по латентности, throughput, сложности.
- Backpressure и буферизация: как каналы дают управление потоком, чего мьютекс не умеет.
- Когнитивная стоимость: дедлоки канальной модели vs забытый Unlock; диагностика и того, и другого.
- Бенчмаркинг: почему «каналы медленнее» в микробенчах и когда это перестаёт иметь значение под реальной нагрузкой.