Модуль: 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 парковки горутин. То есть канал реализован поверх блокировок и семафоров рантайма. Поэтому:

  • канал не дешевле мьютекса для простой защиты переменной — наоборот, обычно дороже (больше работы: блокировка, копирование значения, парковка/побудка);
  • «канал = безопасность» — миф; неправильное использование канала даёт дедлоки и утечки не хуже мьютекса.

Когда КАНАЛ#

  1. Передача владения данными. Горутина-производитель отдаёт значение и больше его не трогает — получатель становится единственным владельцем. Нет shared state → нет гонок по построению.
    jobs := make(chan Job)
    go func() { for j := range jobs { process(j) } }() // владелец j — обработчик
    jobs <- job
  2. Оркестрация / поток событий. Pipeline, fan-in/fan-out, сигналы (done/cancel), таймауты через select. Каналы интегрируются с select и context.
  3. Сигнал «событие произошло». close(done) как broadcast; буфер для развязки producer/consumer.
  4. Распределение работы. Worker pool: один канал задач, N воркеров.

Когда MUTEX (или atomic)#

  1. Защита разделяемого состояния с короткими операциями: счётчики, кэши, мапы, конфиг.
    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] }
  2. Составной инвариант над несколькими полями структуры (atomic не умеет).
  3. Горячий путь, где важна латентность/throughput простой защиты — мьютекс с быстрым CAS-путём дешевле канала.
  4. Кэш/реестр/пул, где «состояние» естественно живёт в одном месте, а не «течёт» между горутинами.

Сравнительная таблица#

КритерийКанал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; диагностика и того, и другого.
  • Бенчмаркинг: почему «каналы медленнее» в микробенчах и когда это перестаёт иметь значение под реальной нагрузкой.