Модуль: Runtime и память · Уровень: Senior+
TL;DR#
Утечка горутины — это горутина, которая никогда не завершается и не освобождается рантаймом, потому что навсегда заблокирована на канале/мьютексе/системном вызове или крутится в бесконечном цикле без условия выхода. Каждая утёкшая горутина удерживает свой стек (минимум 2 КБ, растёт сегментами) плюс всё, что захвачено через замыкание, поэтому утечки проявляются как медленный рост памяти и runtime.NumGoroutine(). Главные инструменты диагностики — pprof goroutine profile и дамп по SIGQUIT/debug=2; главная профилактика — context.Context, errgroup и чёткий контракт «кто закрывает канал».
Теория#
Горутина в Go — это не поток ОС, а легковесная сущность, которой управляет планировщик (scheduler) рантайма по модели M:N (G — горутина, M — поток ОС, P — логический процессор). Сборщик мусора не собирает заблокированные горутины: с точки зрения GC горутина, висящая на chan recv, — это живой объект, корень для своего стека. Нет механизма «таймаута» или «прерывания» горутины извне — горутина завершается только тогда, когда возвращается её функция. Отсюда фундаментальное следствие: если вы запустили горутину, вы обязаны гарантировать путь к её завершению.
Что именно течёт#
Когда горутина «зависает», утекает:
| Ресурс | Размер / детали |
|---|---|
| Стек горутины | Стартует с 2 КБ (_StackMin), растёт удвоением сегментами; для зависшей горутины может остаться большим, т.к. усадка стека (stack shrink) бывает только во время GC и при определённых условиях |
| Захваченные переменные | Всё, на что ссылается замыкание горутины, остаётся живым: буферы, соединения, большие структуры |
| Записи в планировщике | G-структура в пуле, не возвращается |
| Внешние ресурсы | Открытые net.Conn, файловые дескрипторы, транзакции БД, если горутина владеет ими |
Важно: defer conn.Close() в зависшей горутине никогда не выполнится, потому что функция не возвращается. Это значит, что утечка горутин часто тянет за собой утечку FD и соединений.
Причина 1: send в unbuffered-канал без читателя#
func leak1() {
ch := make(chan int) // unbuffered
go func() {
ch <- 42 // блокируется навсегда, если никто не читает
}()
// функция вернулась, читателя нет → горутина утекла
}Классика: горутина пишет результат в канал, но вызывающий код уже ушёл (например, по таймауту контекста), и читать никто не будет. send на unbuffered-канале требует рандеву с recv — без него горутина паркуется навсегда (gopark, состояние chan send).
Причина 2: receive без отправителя#
func leak2() {
ch := make(chan int)
go func() {
v := <-ch // никто никогда не пошлёт и не закроет канал
fmt.Println(v)
}()
}Симметричная проблема. Если канал нигде не закрывается и никто не пишет — горутина висит в состоянии chan receive. Особенно коварно при паттерне «слушаем результат», когда producer упал с паникой/ошибкой до отправки.
Причина 3: отсутствие отмены через context#
Самый частый источник утечек в продакшене — долгоживущие горутины (пуллеры, воркеры, фоновые таски), запущенные без механизма остановки.
// ПЛОХО: горутину невозможно остановить
func startWorker(jobs <-chan Job) {
go func() {
for j := range jobs {
process(j)
}
}()
}
// ХОРОШО: отмена через context
func startWorker(ctx context.Context, jobs <-chan Job) {
go func() {
for {
select {
case <-ctx.Done():
return // гарантированный выход
case j, ok := <-jobs:
if !ok {
return
}
process(ctx, j)
}
}
}()
}Причина 4: забытый WaitGroup#
func leak4() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Add(1)
go func() {
// забыли defer wg.Done()
doWork()
}()
}
wg.Wait() // блокируется навсегда → теперь утекла ГОРУТИНА-ВЫЗЫВАТЕЛЬ
}Здесь два вида беды: либо wg.Done() не вызывается и Wait() висит вечно, либо wg.Add вызван внутри горутины (race с Wait). Правило: wg.Add(n) — до запуска горутин, defer wg.Done() — первой строкой в горутине.
Причина 5: deadlock на mutex#
func leak5() {
var mu sync.Mutex
mu.Lock()
go func() {
mu.Lock() // ждёт навсегда, владелец не отпустит
defer mu.Unlock()
// ...
}()
// забыли mu.Unlock() в основном потоке
}Если рантайм обнаруживает, что все горутины заблокированы, он паникует с fatal error: all goroutines are asleep - deadlock!. Но если хотя бы одна горутина работает (например, HTTP-сервер крутится), частичный deadlock на подмножестве горутин рантайм не заметит — это и есть утечка. Частая причина: рекурсивный захват не-рекурсивного мьютекса, либо несогласованный порядок блокировок (lock ordering) у нескольких мьютексов.
Причина 6: бесконечный for без выхода#
func leak6() {
go func() {
for {
poll() // нет select на ctx.Done(), нет break-условия
time.Sleep(time.Second)
}
}()
}Тикеры и поллеры без канала отмены живут до конца процесса. Отдельный подвид — time.Tick (в отличие от time.NewTicker) нельзя остановить, утекает сам таймер.
Под капотом: как горутина «паркуется»#
Когда горутина блокируется на канале, планировщик вызывает gopark: горутина переводится в состояние _Gwaiting, её M освобождается под другие G. Состояние видно в дампе (chan receive, chan send, select, semacquire для мьютекса, IO wait для сети). Эти строки — главная подсказка при разборе утечки: они говорят, на чём и где (по стеку) висит горутина.
# Состояния, которые вы увидите в дампе:
# [chan receive] — recv без отправителя
# [chan send] — send без читателя
# [select] — select без готовых case и без default
# [semacquire] — ждёт sync.Mutex / sync.WaitGroup
# [IO wait] — сетевой read/write (netpoller)
# [sync.Cond.Wait] — ждёт сигнала на условной переменнойДиагностика#
runtime.NumGoroutine — первый сигнал#
import (
"expvar"
"runtime"
)
func init() {
expvar.Publish("goroutines", expvar.Func(func() any {
return runtime.NumGoroutine()
}))
}Постоянно растущий NumGoroutine под стабильной нагрузкой = почти наверняка утечка. Выведите метрику в Prometheus (у go_goroutines это делает стандартный client_golang).
pprof goroutine profile#
import _ "net/http/pprof" // регистрирует /debug/pprof/* на DefaultServeMux
func main() {
go func() { http.ListenAndServe("localhost:6060", nil) }()
// ...
}# Сводка по горутинам (агрегировано по стеку)
go tool pprof http://localhost:6060/debug/pprof/goroutine
# Полный текстовый дамп с дедупликацией
curl -s 'http://localhost:6060/debug/pprof/goroutine?debug=1' | less
# Дамп БЕЗ дедупликации, полные стеки каждой горутины
curl -s 'http://localhost:6060/debug/pprof/goroutine?debug=2' | less
# Граф/флеймграф в браузере
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/goroutinedebug=1 группирует одинаковые стеки и показывает счётчик — идеально, чтобы увидеть «1500 горутин висят на одном и том же chan receive в строке X». debug=2 показывает каждую горутину отдельно с её возрастом и состоянием. Сравнение двух профилей во времени (diff) точно покажет, какой стек растёт:
curl -s 'http://localhost:6060/debug/pprof/goroutine' -o g1.pprof
sleep 60
curl -s 'http://localhost:6060/debug/pprof/goroutine' -o g2.pprof
go tool pprof -base g1.pprof g2.pprof # дельта между снимкамиДамп через SIGQUIT#
Если pprof не подключён, можно получить полный стек-дамп всех горутин и аварийно завершить процесс:
kill -SIGQUIT <pid> # или Ctrl+\ в терминале
# Эквивалентно установке GOTRACEBACK=all и паникеУправление детализацией дампа:
GOTRACEBACK=none # минимум
GOTRACEBACK=single # только текущая горутина (по умолчанию для паники)
GOTRACEBACK=all # все пользовательские горутины
GOTRACEBACK=system # + системные горутины рантайма
GOTRACEBACK=crash # all + дамп ядра (core dump)goleak от Uber в тестах#
go.uber.org/goleak ловит утечки на этапе тестирования — лучший способ не дать им доехать до прода.
import "go.uber.org/goleak"
// Вариант 1: проверка всего пакета разом
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
// Вариант 2: точечно в конкретном тесте
func TestWorker(t *testing.T) {
defer goleak.VerifyNone(t)
// ... тело теста; в конце goleak убедится, что не осталось лишних горутин
}goleak делает снимок горутин, отфильтровывает известные системные (например, горутины самого тест-раннера, testing), и фейлит тест, если остались чужие. Полезно добавить опции игнорирования для библиотечных фоновых горутин:
goleak.VerifyNone(t,
goleak.IgnoreTopFunction("github.com/some/lib.(*Pool).run"),
)Профилактика#
- context.Context во всех долгоживущих горутинах — единый стандарт отмены. Первый
select-case всегда<-ctx.Done(). - Документировать контракт канала — комментарием прямо у объявления: кто пишет, кто закрывает. Правило Go: закрывает всегда отправитель, и только один; нельзя закрывать канал из нескольких мест и нельзя писать в закрытый канал (паника).
- Buffered-каналы для fire-and-forget — если результат может быть никому не нужен (отправитель ушёл по таймауту), буфер на 1 спасает горутину от вечной блокировки на
send:
func fetch(ctx context.Context) (Result, error) {
ch := make(chan Result, 1) // буфер 1 → send не заблокируется, даже если читатель ушёл
go func() {
ch <- doExpensiveCall() // не утечёт благодаря буферу
}()
select {
case r := <-ch:
return r, nil
case <-ctx.Done():
return Result{}, ctx.Err() // горутина завершится сама, записав в буфер
}
}- errgroup — для группы связанных горутин с распространением ошибки и отменой:
import "golang.org/x/sync/errgroup"
func process(ctx context.Context, urls []string) error {
g, ctx := errgroup.WithContext(ctx) // ctx отменяется при первой ошибке
g.SetLimit(8) // ограничение конкурентности (Go 1.20+)
for _, u := range urls {
u := u
g.Go(func() error {
return fetch(ctx, u) // при ошибке любой — ctx.Done() закроется для всех
})
}
return g.Wait() // ждёт всех и возвращает первую ошибку
}Worker pool с правильной отменой#
func WorkerPool(ctx context.Context, jobs <-chan Job, workers int) <-chan Result {
results := make(chan Result)
var wg sync.WaitGroup
wg.Add(workers)
for i := 0; i < workers; i++ {
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return // отмена — все воркеры выходят
case j, ok := <-jobs:
if !ok {
return // канал jobs закрыт — нормальное завершение
}
select {
case results <- process(ctx, j): // НЕ забываем select и тут!
case <-ctx.Done():
return // иначе утечём на send, если читатель results ушёл
}
}
}
}()
}
// Отдельная горутина закрывает results ПОСЛЕ всех воркеров — единственный закрыватель
go func() {
wg.Wait()
close(results)
}()
return results
}Ключевые моменты этого паттерна:
- Выход по
ctx.Done()и по закрытиюjobs— два независимых пути завершения. - Отправка в
resultsтоже обёрнута вselectсctx.Done()— иначе при отмене воркер залипнет наsend, если потребительresultsуже ушёл. close(results)делает одна горутина послеwg.Wait()— соблюдён инвариант «один закрыватель».
Подводные камни / gotchas#
time.Tickтечёт: возвращает канал, но не даёт его остановить. Используйтеt := time.NewTicker(d); defer t.Stop().time.Afterв цикле: на каждой итерацииselectсоздаёт новый таймер, который живёт до своего срабатывания. В горячем цикле это утечка памяти таймеров (до Go 1.23, где сборка улучшена) — используйте переиспользуемыйTimerсReset.- Отправка в
resultsбезselectна отмену — самая частая «утечка во второй половине» worker pool (исправлено в примере выше). deferв зависшей горутине не сработает —Close()/Unlock()/Done()не выполнятся, потянув за собой утечку FD/мьютексов/WG.- Закрытие канала из нескольких горутин → паника
close of closed channelили гонка. Закрыватель строго один. - Range по каналу, который никто не закроет, висит вечно — это утечка, а не deadlock (рантайм не видит её, если другие горутины активны).
- goleak ловит не сразу: горутина может завершаться асинхронно; goleak делает несколько ретраев с задержкой, но горутины с фоновым
time.Sleepмогут дать ложноположительный результат — настраивайтеIgnoreTopFunction. runtime.Goexitи паника: паника в горутине безrecoverроняет весь процесс, а не только горутину — это не «утечка», но связанная ошибка управления жизненным циклом.- Частичный deadlock не детектируется:
all goroutines are asleepсрабатывает, только если зависли ВСЕ. С живым HTTP-сервером утечки молча копятся.
Вопросы на собеседовании#
В: Почему сборщик мусора не освобождает заблокированную горутину?
О: Потому что заблокированная горутина с точки зрения рантайма жива и достижима: её G-структура и стек — это корень (root) для GC. GC собирает недостижимые объекты в куче, но горутина не «недостижима» — она запаркована планировщиком в состоянии _Gwaiting и может теоретически быть разбужена (если на канал кто-то пошлёт). Рантайм не делает анализа «а пошлёт ли кто-нибудь когда-нибудь» — это была бы неразрешимая задача. Поэтому горутина живёт, пока её функция не вернётся, и весь захваченный ею через замыкание граф объектов тоже остаётся живым.
В: В чём разница между debug=1 и debug=2 у goroutine profile, и когда что использовать?
О: debug=1 агрегирует горутины по идентичным стекам и показывает счётчик для каждой группы — удобно, когда тысячи горутин висят на одном месте и нужно быстро увидеть «горячий» стек. debug=2 печатает каждую горутину отдельно, с её ID, возрастом (X minutes) и состоянием (chan receive, semacquire и т.д.) — это формат, идентичный дампу по SIGQUIT. Для поиска утечки начинаю с debug=1 (где скопление?), а debug=2 беру, когда нужны детали конкретной долгоживущей горутины или её точный возраст. Ещё мощнее — diff двух профилей через go tool pprof -base, который покажет именно растущий стек.
В: Почему буферизация канала на 1 элемент спасает от утечки в паттерне «запрос с таймаутом»?
О: В паттерне, где горутина считает результат и шлёт его в канал, а вызывающий код ждёт результат или ctx.Done(), при срабатывании таймаута вызывающий уходит и больше не читает канал. Если канал unbuffered, send в горутине требует рандеву с recv, которого уже не будет — горутина виснет навсегда. Буфер на 1 позволяет send завершиться без читателя: значение ложится в буфер, горутина возвращается и собирается GC вместе с каналом. Размер ровно 1, потому что отправка одна; больше не нужно. Это идиома «fire-and-forget result».
В: Кто должен закрывать канал и почему это важно для утечек?
О: Канал закрывает отправитель, и только один. Причины: запись в закрытый канал — паника, повторный close — паника, а закрытие из нескольких мест порождает гонку. Для утечек это важно с двух сторон: (1) если канал никто не закроет, все читатели в range/recv зависнут навсегда — утечка; (2) закрытие канала — это и есть сигнал «данных больше не будет», который позволяет читателям корректно выйти из цикла (v, ok := <-ch; if !ok { return }). При нескольких отправителях используют отдельную координирующую горутину, которая делает wg.Wait(); close(ch) — как в worker pool.
В: Как errgroup.WithContext помогает избегать утечек?
О: errgroup.WithContext возвращает группу и производный контекст. При первой ошибке любой из g.Go-функций (или при отмене родительского контекста) этот производный контекст отменяется, и его Done() закрывается для всех остальных горутин. Если все горутины слушают этот ctx в своих select, они дружно завершаются — это автоматический fan-out отмены. g.Wait() дожидается всех и возвращает первую ошибку. Без errgroup пришлось бы вручную городить context.WithCancel + WaitGroup + сбор первой ошибки через мьютекс. Важная оговорка: errgroup отменяет контекст, но сами горутины обязаны его слушать — если функция игнорирует ctx, отмена не сработает.
В: Рантайм паникует all goroutines are asleep - deadlock. Всегда ли это срабатывает при дедлоке?
О: Нет. Этот детектор срабатывает, только если все горутины процесса находятся в заблокированном состоянии и нет ни одной runnable. Если в программе крутится хоть одна активная горутина — типично HTTP-сервер в Accept/netpoll или фоновый тикер — рантайм считает, что прогресс возможен, и частичный дедлок на подмножестве горутин остаётся незамеченным. Именно поэтому в долгоживущих сервисах утечки горутин накапливаются молча, и их видно только по росту NumGoroutine/pprof, а не по падению с deadlock.
В: Чем time.Tick опасен и как сделать правильно?
О: time.Tick(d) возвращает канал, но не возвращает сам *Ticker, поэтому его невозможно остановить через Stop(). Базовый тикер и горутина-источник тиков живут до конца процесса — это утечка, если такой паттерн используется в коде с ограниченным временем жизни. Правильно: t := time.NewTicker(d); defer t.Stop() и читать из t.C. time.Tick допустим только для тикеров, живущих весь срок программы, и даже тогда лучше явный NewTicker для читаемости.
В: Как встроить защиту от утечек в CI, чтобы не ловить их в проде?
О: Использую go.uber.org/goleak: в каждом конкурентном пакете добавляю TestMain с goleak.VerifyTestMain(m) либо defer goleak.VerifyNone(t) в конкретных тестах. goleak делает снимок горутин после теста, фильтрует системные и фейлит, если остались чужие, с показом их стеков. Для фоновых горутин библиотек добавляю goleak.IgnoreTopFunction. Дополнительно — экспортирую runtime.NumGoroutine() как метрику (go_goroutines в Prometheus) и ставлю алерт на монотонный рост, плюс держу net/http/pprof доступным в проде на внутреннем порту для разбора инцидентов через goroutine diff.
На что копают на senior+#
- Понимание разницы «deadlock vs leak»: способны ли вы объяснить, почему частичный дедлок не детектируется рантаймом и почему утечки в сервисах молчат.
- Контракт каналов: кто закрывает, что происходит при send/close в закрытый канал, как координировать close при N отправителях.
- Двусторонняя отмена в worker pool: видите ли вы, что отправка в
resultsтоже должна быть подselectсctx.Done(), а не только чтениеjobs. - Стоимость утечки: знаете ли, что течёт не только стек (2 КБ+), но и захваченный граф объектов и внешние ресурсы (FD, conn), потому что
deferне выполнится. - Инструментарий под нагрузкой: умение снять goroutine diff в проде, читать состояния
goparkв дампе, отличитьsemacquire(мьютекс/WG) отchan receive. - goleak в CI и его ограничения: настройка игнор-листов, ложные срабатывания на асинхронно завершающихся горутинах.
- Таймеры: знание про
time.Tick,time.Afterв цикле и поведение таймеров до/после Go 1.23. - errgroup семантика: что
WithContextотменяет контекст, но горутины обязаны его слушать;SetLimitдля конкурентности; первая ошибка vs все ошибки.