Паттерны багов с каналами в Go
- title
- Паттерны багов с каналами в Go
- type
- concept
- summary
- Четыре вида ошибок (deadlock, утечка, race, нарушение протокола), воспроизводимых каналами один в один
- tags
- golang, concurrency
- created
- 2026-04-25
- updated
- 2026-09-14
- lang
- ru
- translation_of
- go-channel-bug-patterns
- source_updated
- 2026-09-14
- translated
- 2026-09-14
- translator
- lllm/antigravity/gemini-3.7-flash-medium
Классификация из исследования 171 реального бага конкурентности в Go (Tu et al., ASPLOS 2019), сформулированная Arthur O'Dwyer и автором message-passing-shared-mutable-state. У каждой классической ошибки с разделяемым изменяемым состоянием есть эквивалент на каналах. Каналы в Go - это не направленный двухточечный примитив из пи-исчисления, а конкурентные очереди: любая goroutine с ссылкой на канал может отправлять в него данные или читать из него. Если воспринимать каналы как разделяемые изменяемые структуры, эти паттерны становятся предсказуемыми.
Deadlock
Goroutine A отправляет значение в один канал и блокируется в ожидании ответа из другого. Goroutine B делает наоборот. Обе блокируются. Это циклическая зависимость по разделяемому состоянию - структурно идентичная взаимной блокировке на мьютексах, но выраженная через очереди, а не блокировки. Такие ошибки встречались в реальных багах в Docker, Kubernetes и gRPC.
В Go встроен детектор взаимных блокировок, но в протестированных авторами статьи случаях он поймал всего 2 из 21 блокирующего бага. Детектор срабатывает, только когда заблокированы все goroutine'ы; на практике же несколько goroutine'ов зависают в deadlock'е, пока остальная программа продолжает работать.
Утечка
Goroutine отправляет данные в небуферизованный канал, который никто не читает, или ждёт данных из канала, в который никто не пишет. Заблокированная goroutine удерживает ссылки в стеке и куче, а сам объект канала удерживает goroutine. Сборщик мусора Go не умеет собирать goroutine'ы, заблокированные на каналах.
Канонический пример - паттерн с таймаутом из Kubernetes:
func finishReq(timeout time.Duration) ob {
ch := make(chan ob)
go func() {
result := fn()
ch <- result // blocks forever if timeout wins
}()
select {
case result = <-ch:
return result
case <-time.After(timeout):
return nil // ch never read; child leaks
}
}
Исправление занимает один символ: make(chan ob, 1). Буферизованный канал позволяет дочерней goroutine отправить данные и завершиться, даже если родительская уже перестала ждать.
Это канальный аналог утечки памяти из-за повисшей ссылки на разделяемый объект. Под нагрузкой такие утечки накапливаются, пока процесс не начнёт деградировать или не будет завершён по OOM.
Go 1.27 умеет находить этот паттерн в работающем процессе: goroutine-leak-profiler переиспользует фазу разметки GC, чтобы помечать goroutine'ы, навечно заблокированные на каналах и примитивах синхронизации. Профайлер сообщает о них, но не освобождает память.
Race
Несколько goroutine'ов читают из одного канала. Какая из них получит конкретное сообщение? Планировщик runtime'а Go выбирает получателя недетерминированно. Это конкурентный доступ к разделяемому ресурсу, где недетерминизм обусловлен планировщиком, а не явными блокировками. Встречалось в etcd и CockroachDB.
Здесь помогает race detector в Go - в исследовании он выявил примерно половину неблокирующих багов. Но вторая половина осталась незамеченной.
Нарушение протокола
Goroutine отправляет сообщение, которого получатель не ждёт, пишет в закрытый канал (что приводит к панике) или закрывает уже закрытый канал (что тоже приводит к панике). Неявный контракт разделяемой очереди нарушен. Это та же категория багов, что и некорректное использование любого разделяемого объекта: обращение в неправильном состоянии, в неверном порядке или после уничтожения.
На вопрос "кто закрывает канал" в языке нет строгого ответа. По соглашению закрывать должен отправитель, но при нескольких отправителях это правило перестаёт работать. Распространённые обходные пути - координирующие закрытие goroutine'ы или подсчёт ссылок на отправителей; в обоих случаях это дополнительная координация поверх примитива, который сам должен был служить средством координации.
Зачем нужна эта классификация
Каждый из этих паттернов - канальная вариация уже известного бага с разделяемым состоянием. Отсюда следует вывод из message-passing-vs-shared-memory: если возникают те же самые ошибки, примитив координации не решает задач, которые ему приписывали. Подробный эмпирический разбор см. также в message-passing-shared-mutable-state.