Модуль: 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/goroutine

debug=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"),
)

Профилактика#

  1. context.Context во всех долгоживущих горутинах — единый стандарт отмены. Первый select-case всегда <-ctx.Done().
  2. Документировать контракт канала — комментарием прямо у объявления: кто пишет, кто закрывает. Правило Go: закрывает всегда отправитель, и только один; нельзя закрывать канал из нескольких мест и нельзя писать в закрытый канал (паника).
  3. 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() // горутина завершится сама, записав в буфер
    }
}
  1. 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 все ошибки.