open atlas
↑ К треку
Go с нуля до senior GO · 02 · 02

Каналы: точки синхронизации, ограниченные очереди и дедлоки между ними

Небуферизованный канал — точка синхронизации; буферизованный — ограниченная очередь. Аксиомы: nil блокирует навсегда, закрытый читается нулями, отправка в закрытый паникует. select, fan-out/fan-in, пулы воркеров, отмена через context — и дедлок из-за забытого close.

GO Senior ◷ 19 min
Уровень
ОсновыJuniorMiddleSenior

Ночной экспорт умер в 03:12 с самым прямолинейным сообщением рантайма: fatal error: all goroutines are asleep - deadlock! Пайплайн был хрестоматийный: один продюсер читает строки в канал, восемь воркеров их преобразуют, один коллектор пишет выходной файл. Тремя неделями раньше кто-то добавил воркерам обработку ошибок — на плохой строке воркер логировал и делал return. Той ночью все восемь воркеров напоролись на плохие строки и вышли. Продюсер продолжал слать в канал с ёмкостью 64; через шестьдесят четыре отправки он заблокировался навсегда, коллектор заблокировался на чтении от воркеров, которых больше не существовало, и рантайм — видя, что каждая горутина припаркована и разбудить её некому, — убил процесс. Буфер не предотвратил дедлок. Он лишь отсрочил его ровно на 64 строки — достаточно, чтобы пройти все тесты на фикстурах из десяти строк.

Небуферизованный — рандеву, буферизованный — очередь

У небуферизованного канала (make(chan T)) нет хранилища: отправка блокируется, пока получатель не будет готов, и значение передаётся напрямую из стека одной горутины в стек другой. Это делает его точкой синхронизации: когда ch <- v вернулся, вы знаете, что получатель забрал значение, и отправка гарантированно happens-before завершения приёма. Буферизованный канал (make(chan T, n)) — ограниченная FIFO-очередь: отправки завершаются мгновенно, пока есть ёмкость, и блокируются, когда она исчерпана; приёмы блокируются на пустом канале. Семантика различается качественно, а не количественно: небуферизованный канал передаёт и синхронизирует; буферизованный в основном лишь передаёт, ничего не говоря о том, когда выполнялась другая сторона.

Ёмкость — ручка настройки с острой гранью. Ёмкость 0 связывает продюсера и консьюмера такт в такт. Небольшой буфер (размером с реальный всплеск) гасит джиттер и расцепляет их расписания. «Бесконечный» буфер (make(chan T, 1_000_000)) — способ превратить видимый backpressure (давление обратной связи: продюсер блокируется, когда консьюмер не успевает) в невидимый рост памяти: продюсер не почувствует отставание консьюмера, пока процесс не словит OOM. Правило сениора: выбирайте 0, если не можете назвать измеренный всплеск, который буфер должен поглотить, и относитесь к заполненному буферу как к сигналу (консьюмер слишком медленный), а не как к препятствию, которое обходят числом побольше.

Аксиомы каналов

Четыре поведения определяют каждый канальный баг, который вы будете отлаживать:

  1. Отправка в nil-канал блокируется навсегда. Приём — тоже. var ch chan int без make — или чтение из map, вернувшее нулевое значение, — даёт горутину, припаркованную навечно. Сознательно полезно внутри select: присвоив каналу кейса nil, вы отключаете этот кейс.
  2. Приём из закрытого канала возвращается сразу с нулевым значением типа; двухзначная форма v, ok := <-ch сообщает ok == false. Цикл for range ch завершается, когда канал закрыт и вычерпан — поэтому range по каналу, который никто не закрывает, висит вечно.
  3. Отправка в закрытый канал паникует. Всегда, немедленно, и по сути это нарушение протокола: две стороны считали, что владеют каналом.
  4. Закрытие закрытого (или nil) канала паникует. Close — одноразовый broadcast, а не уборка, которую рассыпают на всякий случай.

Все четыре аксиомы вместе объясняют каждый загадочный дедлок или панику, с которыми вы столкнётесь: nil-канал молча поглощает, закрытый молча осушается, второй писатель молча портит — три разных режима отказа, все невидимые без знания аксиом. Закрывает отправитель, никогда не получатель, и закрывает ровно одна сторона. Close означает «значений больше не будет никогда» — это сообщение от продюсера. При нескольких продюсерах ни один из них не может закрыть безопасно; идиома — sync.WaitGroup по продюсерам и одна горутина-супервизор, вызывающая close после wg.Wait().

// Fan-out / fan-in с правильной дисциплиной close и отменой.
func process(ctx context.Context, rows <-chan Row) <-chan Result {
	out := make(chan Result)
	var wg sync.WaitGroup
	for i := 0; i < 8; i++ { // fan-out: 8 воркеров читают из одного входного канала
		wg.Add(1)
		go func() {
			defer wg.Done()
			for r := range rows { // выходит, когда rows закрыт и вычерпан
				select {
				case out <- transform(r):
				case <-ctx.Done(): // разблокирует отправку, если downstream исчез
					return
				}
			}
		}()
	}
	go func() { // ровно один закрывальщик, после того как ВСЕ отправители закончили
		wg.Wait()
		close(out)
	}()
	return out // fan-in: консьюмер читает из одного объединённого потока
}
Викторина

Цикл for range читает из канала, который наполняют три горутины-продюсера. Цикл не завершается даже после возврата всех продюсеров. Самая вероятная причина?

select: композиция каналов и запасной выход default

select ждёт несколько канальных операций и выполняет одну из готовых, выбирая равномерно случайно среди готовых кейсов — случайность намеренная, она предотвращает голодание кейса, который «тоже всегда готов». С веткой default он становится неблокирующим: попробуй операцию, провались дальше, если она заблокировала бы. Это строительный блок паттернов try-send — сбрасывать нагрузку вместо того, чтобы её копить:

select {
case events <- e: // доставлено
default:
	droppedTotal.Inc() // буфер полон: сбрасываем и считаем, не блокируем горячий путь
}

Честный компромисс: default превращает блокировку в потерю данных, что правильно для метрик и неправильно для денег. Средний вариант — кейс с таймаутом (case <-time.After(d) — или переиспользуемый time.Timer в горячих циклах, поскольку time.After аллоцирует таймер, живущий до срабатывания).

Отмена течёт вниз по пайплайну

Пайплайн надёжен ровно настолько, насколько продумана его история выхода. Стандартный контракт: каждая стадия принимает context.Context, и каждая отправка сидит внутри select с веткой case <-ctx.Done(). Отмена течёт вниз от вызывающего; close течёт вперёд вместе с данными; и горутины разблокируются независимо от того, какая сторона исчезла. Пропустите select на отправке — и вы воспроизведёте историю из вступления: стадия ниже по течению, переставшая читать, оставляет каждого отправителя выше припаркованным навсегда — утечка из предыдущего урока, поставленная на поток. Рантайм объявляет deadlock! только когда припаркована каждая горутина; в реальном сервере с одним живым HTTP-листенером ваш дедлокнутый пайплайн просто молча стоит, неотличимый от простоя нигде, кроме goroutine-профиля.

Викторина

Продюсер шлёт в канал с ёмкостью 64; все восемь его консьюмеров упали. Тесты продюсера (10 элементов) проходят, а продакшен (миллионы элементов) виснет. Почему буфер не предотвратил зависание?

lesson.inset.deep-dive

Пул воркеров — это fan-out с бюджетом. Размер пула ограничивает одновременную работу (а значит, память, соединения к зависимостям и борьбу за CPU); ёмкость входного канала ограничивает работу в очереди; вместе они определяют backpressure системы. Пул из 8 воркеров над небуферизованным входом означает максимум 8 элементов в полёте, и продюсер мгновенно чувствует каждую заминку. Ёмкость 100 позволяет продюсеру убегать вперёд на всплесках, но добавляет старейшему элементу очереди до 100 элементов задержки. Чего делать нельзя никогда — реактивно увеличивать любое из этих чисел под нагрузкой: именно так медленная база данных превращается в медленную базу плюс десять тысяч горутин, держащих буферы запросов. Бюджет назначается при проектировании пула, глубина очереди выводится метрикой, а сверх неё — сброс или отказ.

Вспомните перед уходом
  1. 01
    Назовите четыре аксиомы каналов и правило владения, которое они навязывают.
  2. 02
    Почему буферизованный канал не защищает пайплайн от мёртвого консьюмера и что защищает?
Итог

Каналы бывают двух семантически разных видов. Небуферизованные — точки рандеву: отправка блокируется, пока получатель не заберёт значение из рук в руки, поэтому завершение отправки синхронизирует две горутины. Буферизованные — ограниченные FIFO-очереди, расцепляющие расписания, пока хватает ёмкости; их заполненность — информация о backpressure, и замена полного буфера огромным лишь меняет видимую блокировку на невидимый рост памяти. Всем правят четыре аксиомы: nil-каналы блокируют навсегда (и намеренно отключают кейсы select), закрытые читаются нулевыми значениями с ok false и завершают range после вычерпывания, отправка в закрытый канал паникует, двойной close паникует. Эти паники навязывают правило владения — закрывает отправитель, ровно один раз, а при нескольких продюсерах супервизор закрывает после того, как WaitGroup подтвердит завершение всех. select компонует канальные операции, выбирая равномерно случайно среди готовых кейсов; с default он становится неблокирующим try-send для сброса нагрузки, с таймаут-кейсом — ограничивает ожидание. Fan-out распределяет один входной канал по пулу воркеров, чей размер бюджетирует конкурентность, fan-in сливает результаты в один поток, а отмена течёт навстречу данным: каждая отправка каждой стадии делает select на ctx.Done, и исчезнувший потребитель не может навечно припарковать отправителей выше по течению. Авария ночного экспорта — канонический капкан: воркеры, выходящие по ошибке, пока продюсер шлёт в канал ёмкостью 64; буфер откладывает зависание ровно на 64 строки, тесты с маленькими фикстурами до него не доходят, а рантайм объявляет дедлок лишь когда припаркован весь процесс — в реальном сервере тот же баг прячется тихой утечкой горутин. Теперь, когда встретишь пайплайн, виснущий в продакшене, но проходящий все тесты, — считай воркеров, ищи пропущенный select ctx.Done() на отправках и проверь, что fan-in (объединяющий канал результатов) закрывает ровно один супервизор: аксиомы каналов сразу покажут, какой шаг был пропущен.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 6 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.