EnglishРусский Map
Message Passing vs Shared Memory

Паттерны багов с каналами в Go

title
Паттерны багов с каналами в Go
type
concept
summary
Четыре вида ошибок (deadlock, утечка, race, нарушение протокола), воспроизводимых каналами один в один
tags
golang, concurrency
created
2026-04-25
updated
2026-09-14
lang
ru
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.