---
title: "Утечки горутин в задачах"
description: "Утечка горутин в задачах по расписанию: забытый ticker.Stop, воркер без таймаута, канал без читателя; NumGoroutine в Gauge и pprof-снимок находят виновника."
course: "Фоновые задачи в Go: планировщик на robfig/cron"
url: "https://golangmaster.ru/courses/cron/lessons/20-utechki-gorutin/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 18
---

# Утечки горутин в задачах

*Курс «Фоновые задачи в Go: планировщик на robfig/cron», урок 20 из 25. ~18 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/cron/lessons/20-utechki-gorutin/*

Утечка горутин — тихий дефект задач по расписанию: каждый тик оставляет после себя одну-две горутины, застрявшие в бесконечном ожидании, и через месяц работы сервис держит десятки тысяч мёртвых горутин, растущий heap и непонятно зачем занятые соединения. Планировщик усугубляет картину: тики приходят по расписанию, а задача, которая не чистит за собой, копит мусор линейно со временем. Разберём три главных источника утечек в cron-задачах — забытые тикеры, воркеры без таймаутов и каналы без читателей, — а затем инструменты обнаружения: счётчик горутин в метрике, pprof-снимок и тесты с проверкой на goroutine leak go-кода.

Сценарий для старта: бот напоминаний стабильно работал месяц, затем дежурный заметил, что контейнер съедает вдвое больше памяти, чем при релизе. График go_goroutines показал прямую линию вверх: с двухсот горутин на старте до сорока тысяч к концу месяца. В pprof-снимке почти все горутины стояли в одном и том же select — отправка результата в канал, который после отмены контекста никто не читал. Задача напоминаний дёргала внешнее API: при таймауте-отмене основная горутина тика выходила, а вспомогательная — горутина запроса — оставалась ждать читателя. Один тик — одна потерянная горутина, при минутном расписании это 1440 горутин в сутки. Механику горутин и каналов разбирали [урок про горутины](/courses/go-advanced/lessons/04-gorutiny/) и [урок про каналы](/courses/go-advanced/lessons/05-kanaly/); здесь она работает в цикле по расписанию, где ошибка умножается на число тиков.

### Утечка горутин: почему горутина не возвращается

Утечка горутин — это горутина, которая дожидается условия, которое не наступит: чтения из канала, куда больше никто не пишет, отправки в канал, который никто не читает, блокировки на мьютексе, который не отпустят. Сборщик мусора здесь бессилен: пока горутина жива, её стек и все захваченные переменные считаются используемыми, и память под них не освобождается. Отсюда типичный симптом — heap растёт при стабильном трафике, а профилировщик указывает на давно «умершие» структуры. В cron-задачах арифметика жёстче, чем в HTTP-сервисе: каждый тик запускает новую горутину, и если тик протекает, горутины копятся с точностью до расписания. Перекрытие запусков из [урока про конкурентность](/courses/cron/lessons/12-konkurentnost-zadach/) — уже половина утечки: задача живёт дольше интервала, и гасить её должна обёртка из цепочки. Разберём три источника по порядку появления в реальном коде.

### Задача, которая теряет горутину

```go
func (s *Syncer) Run() {
    results := make(chan string) // буфера нет
    go func() {
        res, err := s.api.FetchAll() // клиент без таймаута
        if err != nil {
            return // ошибка потеряна: читатель ждёт вечно
        }
        results <- res // блокируется, если читатель уже ушёл
    }()

    select {
    case res := <-results:
        s.save(res)
    case <-s.ctx.Done():
        // тик выходит по отмене, а горутина запроса висит
    }
}
```

В одном коротком фрагменте собраны оба дефекта. Внешний вызов идёт клиентом без таймаута: зависший сервер держит горутину запроса столько, сколько держит TCP-соединение. Отправка результата идёт в небуферизованный канал без select по отмене: если тик вышел через Done, отправка останется ждать читателя, который не придёт. Каждый такой тик — одна потерянная горутина со всем, что она держит.

### Проверка: что происходит с горутиной на отправке

**Вопрос:** Горутина заблокирована на отправке в канал, читатель ушёл по отмене контекста. Что произойдёт с горутиной?

- Сборщик мусора освободит её через несколько циклов GC
- ✅ Она останется заблокированной до конца работы процесса: отправка в незакрытый канал не прерывается
- Runtime обнаружит отсутствие читателя и завершит горутину с паникой
- Паника send on closed channel остановит весь процесс

Отправка в канал без читателя блокирует горутину без ограничения по времени: runtime не следит за тем, есть ли у канала читатель, а закрытие канала не отменяет ожидание отправителя — паника случилась бы при закрытии, но канал никто не закрывает. GC не помощник: горутина жива, значит, и её память используется.

### Забытые тикеры: Stop как часть контракта

Второй источник — таймеры. time.NewTicker создаёт тикер с каналом и внутренним таймером; документация time требует вызывать Stop, чтобы освободить ресурсы, — тикер без Stop продолжает генерировать тики, которые складываются в буфер канала ёмкостью в один элемент. Чаще это утечка таймеров и памяти, но в задаче, пересоздающей тикеры каждый тик, счёт живых таймеров растёт линейно. Родственный дефект — time.After внутри select в горячем цикле: каждый вызов создаёт таймер, живущий до срабатывания; при выходе из select по другой ветке таймер продолжает тратить ресурс до истечения. Для одноразовых таймеров в цикле берут NewTimer с Stop. Контракт простой: создали тикер — defer ticker.Stop() на той же строке, как defer resp.Body.Close() у HTTP. Подробности таймеров и тикеров — в [уроке про пакет time](/courses/go-advanced/lessons/23-time/), поведение select — в [уроке про select](/courses/go-advanced/lessons/06-select/).

### Проверка: time.After в цикле

**Вопрос:** В цикле воркера на каждый круг вызывается time.After(5 * time.Second) внутри select, но чаще срабатывает другая ветка. Что происходит с таймерами?

- Ничего: таймеры переиспользуются runtime между итерациями
- ✅ Каждый вызов создаёт таймер, живущий до срабатывания: в горячем цикле они копятся
- Таймер удаляется при выходе из select
- Компилятор заменит time.After на NewTimer со Stop автоматически

time.After создаёт новый таймер на каждый вызов и удаляет его только после срабатывания. Если select выходит по другой ветке, таймер продолжает жить до истечения. В цикле, который крутится часто, копятся тысячи живых таймеров — в профиле это видно как рост памяти без роста горутин.

### Зависшие воркеры: таймауты на внешние вызовы

Третий источник — внешние вызовы без дедлайна. http.DefaultClient не имеет таймаута: зависший TCP-пир держит соединение и горутину до разрыва на сетевом уровне, а он может не наступить вовсе. Задача с таким клиентом при каждом сбое сети оставляет по горутине и по соединению; файловые дескрипторы конечны, и через несколько дней сервис отказывает уже по ним. Лечение — дедлайн на каждый вызов: клиент с полем Timeout, request с NewRequestWithContext от контекста задачи из [урока про контекст](/courses/cron/lessons/10-kontekst-zadachi/) и закрытие тела ответа через defer. Вторая линия обороны — ограничение параллелизма: фиксированный пул воркеров из [проекта воркера](/courses/go-advanced/lessons/25-proekt-vorker/) превращает утечку из бесконечной в ограниченную — горутин не больше, чем слотов. Пул замедляет деградацию, таймауты устраняют причину; в рабочем сервисе ставят и то, и другое.

### Защищённая версия той же задачи

```go
func (s *Syncer) Run() {
    client := &http.Client{Timeout: 10 * time.Second}
    req, err := http.NewRequestWithContext(s.ctx, http.MethodGet, s.url, nil)
    if err != nil {
        s.log.Warn("битый запрос", "err", err)
        return
    }

    resp, err := client.Do(req)
    if err != nil {
        s.log.Warn("выгрузка прервана", "err", err)
        return
    }
    defer resp.Body.Close()

    s.save(decode(resp.Body))
}
```

Ни одной горутины сверх тика: запрос выполняется синхронно, отмена и таймаут встроены в контракт HTTP-клиента. Timeout на клиенте ограничивает весь вызов — от соединения до чтения тела, — контекст запроса связан с отменой тика, а закрытие тела освобождает соединение для пула. Канал здесь не нужен: результат обрабатывается в той же горутине.

### Что напечатает эта программа?

```go
ch := make(chan int)
done := make(chan struct{})
close(done)
go func() { ch <- 42 }()

select {
case <-done:
    fmt.Println("выход: отправитель остался ждать")
}
```

**Что выведет программа?**

- ✅ выход: отправитель остался ждать
- получено 42
- паника: send on closed channel
- пустой вывод: select блокируется навечно

Канал done закрыт, поэтому его нулевое значение готово мгновенно, и select печатает строку. Горутина с отправкой в ch останется заблокированной: канал не закрыт, читателя нет, паники при отправке в незакрытый канал не бывает. В короткой программе процесс завершится и заберёт горутину с собой; в живом сервисе она осталась бы ждать до конца его работы — вот утечка.

### Симптомы утечек и инструменты поиска

| Признак | Инструмент | Что искать |
| --- | --- | --- |
| число горутин растёт по прямой | Gauge go_goroutines в метриках | наклон графика после релиза |
| сотни одинаковых стеков | pprof: /debug/pprof/goroutine?debug=2 | строки select или send в одном месте |
| память растёт при ровном трафике | heap-профиль | структуры, живые через стек горутины |
| тесты висят до таймаута | goleak в тестах | утечка воспроизводится локально |
| кончаются файловые дескрипторы | счётчик открытых файлов процесса | ответы HTTP без Close |

### Detect по метрике: NumGoroutine и pprof

Обнаружение строится на трёх инструментах разной глубины. Первый — счётчик: runtime.NumGoroutine() в Gauge, как в [уроке про метрики горутин](/courses/prometheus/lessons/16-metriki-gorutin/); client_golang уже отдаёт go_goroutines из коробки, остаётся поставить алерт на наклон — рост без релиза означает утечку. Второй — pprof из стандартной библиотеки: импорт net/http/pprof открывает эндпоинт, снимок /debug/pprof/goroutine?debug=2 печатает стек каждой горутины; сотни одинаковых стеков, сходящихся в один select или send, указывают на место утечки до строки. Третий — тесты: сторонний goleak проверяет число горутин в конце теста и падает при расхождении — утечка ловится до деплоя. Глубина растёт от графика к стеку: метрика замечает сам факт, pprof называет место, goleak не даёт повториться.

> **Утечка в общем коде умножается на все задачи.** Кейс: команда написала собственную обёртку аудита поверх урока про wrappers — она отправляла событие о каждом тике в канал, из которого читал сборщик в отдельной горутине. После Stop сборщик выходил, а задачи продолжали отправлять: каждый тик каждой задачи оставлял горутину, заблокированную на отправке. При пятиминутном расписании и двадцати задачах это почти шесть тысяч горутин в сутки. Отличие этого кейса от одиночной ошибки — масштаб: обёртка — общий код, и её дефект наследуют все задачи сразу. Отсюда правило: обёртки, воркеры и очереди проверяются теми же инструментами, что и задачи, — снимком pprof после тестового прогона и goleak в тестах общего пакета.

### Спорное: пул воркеров как решение утечек

Спорная привычка, которую стоит оспорить: считать пул воркеров готовым ответом на утечки. Ограничение параллелизма действительно ставит потолок: горутин не больше, чем слотов. Но если каждый воркер завис на вызове без таймаута, пул встаёт целиком: тихая утечка превращается в остановку всей фоновой работы, причём без единой ошибки в логах — задачи просто не обрабатываются, а метрика горутин стоит на месте потолка. Пул без таймаутов — это распределение боли, а не лечение. Верный порядок обратный: сначала дедлайны на каждый блокирующий вызов и дисциплина каналов, потом пул как ограничитель для хвостовых случаев, которые нельзя исключить. Тогда слоты освобождаются сами, а потолок работает как страховка, а не как предел терпения.

### Чеклист против утечек

- Создали тикер или таймер — defer Stop на той же строке; time.After в горячих циклах не используется.
- Каждый внешний вызов имеет дедлайн: Timeout у клиента или контекст с дедлайном на запрос.
- Отправка в канал идёт через select с веткой отмены, а читатель гарантирован на весь срок жизни отправителя.
- Число горутин в метрике с алертом на наклон; после релиза график ровный.
- pprof-эндпоинт открыт для внутреннего контура, снимок goroutine проверяется после тестового прогона.
- Обёртки и воркеры покрыты goleak-тестами, включая сценарий остановки.

### Соберите тикер без утечки

Впишите создание тикера, освобождение ресурса и чтение отмены контекста.

```go
ticker := time.___(30 * time.Second)
defer ticker.___()

for {
    select {
    case <-ticker.C:
        poll()
    case <-ctx.___():
        return
    }
}
```

Подсказка: Создание — time.NewTicker; освобождение — Stop; канал отмены — ctx.Done.

Правильные ответы в пропусках: `NewTicker`, `Stop`, `Done`

### Расставьте защищённый вызов API

Расставьте строки в порядке файла: клиент с таймаутом, запрос с контекстом, выполнение, обработка ошибки и закрытие тела.

Правильный порядок строк:

1. `client := &http.Client{Timeout: 10 * time.Second}`
2. `req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)`
3. `resp, err := client.Do(req)`
4. `if err != nil {`
5. `    return err`
6. `}`

### Карточки: утечки горутин

- **Утечка горутин** — Горутина дожидается условия, которое не наступит: канал без читателя или отправителя, мьютекс без освобождения. GC не освобождает её память, пока горутина жива.
- **ticker.Stop** — Тикер без Stop продолжает генерировать тики и держать таймер с каналом. Контракт: defer ticker.Stop() сразу после создания.
- **time.After в цикле** — Каждый вызов создаёт таймер, живущий до срабатывания. При выходе из select по другой ветке таймер продолжает существование — в горячем цикле они копятся.
- **NumGoroutine в метрике** — runtime.NumGoroutine в Gauge или готовый go_goroutines: алерт на наклон графика ловит утечку до исчерпания памяти.
- **pprof goroutine** — Снимок /debug/pprof/goroutine?debug=2 печатает стек каждой горутины: сотни одинаковых стеков в одном select указывают на место утечки.
- **goleak** — Сторонний пакет для тестов: проверяет число горутин после теста и падает при расхождении. Ловит утечку до деплоя, включая сценарии остановки.

> **Где запускать примеры.** Механику утечки песочница показывает честно: каналы, select, close и горутины там работают, и предикт с висящим отправителем исполняется один в один. Пакетов runtime и net/http/pprof в песочнице нет, поэтому счётчик горутин замените собственным целочисленным счётчиком в замыкании: увеличили при старте горутины, уменьшили по завершении через defer — идея та же, что у метрики. Пакет time в песочнице есть: тикеры и таймеры с defer Stop проверяются на месте. Полный pprof-снимок и goleak выполняются локально из модуля второго урока.

Утечка горутин в задачах — арифметика расписания, помноженная на дефект синхронизации: заблокированная горутина не возвращается, GC не освобождает её память, а тики плодят новые. Три источника закрывают большую часть случаев: тикеры без Stop, вызовы без таймаутов и каналы без читателей; лечение каждый раз одно — дедлайны на блокирующие вызовы, defer на ресурсы и select с веткой отмены при отправке. Обнаружение идёт по нарастающей: счётчик в метрике замечает наклон, pprof-снимок называет строку, goleak не даёт вернуться. Дальше по маршруту: [распределённые блокировки](/courses/cron/lessons/21-raspredelennye-blokirovki/) — что происходит, когда инстансов несколько, и [тестирование задач](/courses/cron/lessons/22-testy-zadach/) — где живут тесты с goleak. Если возвращались с пропуска — [graceful shutdown](/courses/cron/lessons/19-graceful-cron/) показывает, как правильно останавливать задачи, [конкурентность задач](/courses/cron/lessons/12-konkurentnost-zadach/) — откуда берётся параллельная работа, а [метрики горутин](/courses/prometheus/lessons/16-metriki-gorutin/) детализируют счётчик из этого урока. Полный маршрут — [на странице курса](/courses/cron/). В домашнем задании — счётчик горутин, снимок pprof и goleak-тест на своей обёртке.

## Коротко: что запомнить

- Утечка горутин — горутина, дожидающаяся условия, которое не наступит; GC не освобождает её память, пока она жива.
- Каждый тик создаёт горутину: дефект синхронизации умножается на число тиков и растёт линейно со временем.
- Тикеры и таймеры требуют Stop: defer на строке создания; time.After в горячем цикле копит таймеры.
- Каждый внешний вызов — с дедлайном: Timeout клиента, контекст на запрос, defer resp.Body.Close.
- Отправка в канал — через select с веткой отмены, а читатель живёт столько же, сколько отправитель.
- Обнаружение: NumGoroutine в метрике на наклон, pprof-снимок на место, goleak в тестах на повторение.

## Домашнее задание

Заведите в сервисе счётчик горутин: готовый go_goroutines или runtime.NumGoroutine в своей Gauge — и алерт на наклон графика. Напишите задачу с утечкой из этого урока: канал без читателя и клиент без таймаута, снимите /debug/pprof/goroutine?debug=2 через час работы в тестовом окружении и найдите виновника по стеку. Затем исправьте её по чеклисту: Timeout у клиента, NewRequestWithContext, select с веткой отмены на отправке, defer на тикеры. Добавьте goleak-тест на пакет задач, включая сценарий с остановкой по контексту. Финал — сравните график горутин до и после: после фикса он обязан быть ровным при том же расписании.

## Вопросы и ответы по уроку

### Почему сборщик мусора не убирает зависшие горутины?

GC освобождает объекты, до которых нет путей из живых корней. Зависшая горутина — живой поток исполнения: её стек и захваченные переменные доступны runtime, поэтому память остаётся занятой до завершения горутины или процесса. Утечка горутин — это одновременно утечка стека и всего, что горутина держит.

### Чем pprof-снимок горутин помогает в поиске утечки?

Снимок /debug/pprof/goroutine?debug=2 печатает полный стек каждой горутины. Утёкшие горутины зависают в одном и том же месте — на отправке в канал или чтении из него, — поэтому сотни одинаковых стеков сходятся в одну строку кода. Это быстрее всего приводит к месту утечки, чем счётчик в метриках.

### Достаточно ли ограничить параллелизм пулом воркеров?

Пул ставит потолок числу одновременно живых горутин, но не устраняет причину: зависшие воркеры занимают слоты до конца работы, и фоновая работа останавливается. Пул полезен как ограничитель после дедлайнов и дисциплины каналов, но сам по себе утечку не лечит.
