Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 20 из 25 ~18 мин +20 XP

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

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

Содержание урока · 10 разделов

Сценарий для старта: бот напоминаний стабильно работал месяц, затем дежурный заметил, что контейнер съедает вдвое больше памяти, чем при релизе. График go_goroutines показал прямую линию вверх: с двухсот горутин на старте до сорока тысяч к концу месяца. В pprof-снимке почти все горутины стояли в одном и том же select — отправка результата в канал, который после отмены контекста никто не читал. Задача напоминаний дёргала внешнее API: при таймауте-отмене основная горутина тика выходила, а вспомогательная — горутина запроса — оставалась ждать читателя. Один тик — одна потерянная горутина, при минутном расписании это 1440 горутин в сутки. Механику горутин и каналов разбирали урок про горутины и урок про каналы; здесь она работает в цикле по расписанию, где ошибка умножается на число тиков.

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

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

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

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, отправка останется ждать читателя, который не придёт. Каждый такой тик — одна потерянная горутина со всем, что она держит.

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

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

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

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

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

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

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

Третий источник — внешние вызовы без дедлайна. http.DefaultClient не имеет таймаута: зависший TCP-пир держит соединение и горутину до разрыва на сетевом уровне, а он может не наступить вовсе. Задача с таким клиентом при каждом сбое сети оставляет по горутине и по соединению; файловые дескрипторы конечны, и через несколько дней сервис отказывает уже по ним. Лечение — дедлайн на каждый вызов: клиент с полем Timeout, request с NewRequestWithContext от контекста задачи из урока про контекст и закрытие тела ответа через defer. Вторая линия обороны — ограничение параллелизма: фиксированный пул воркеров из проекта воркера превращает утечку из бесконечной в ограниченную — горутин не больше, чем слотов. Пул замедляет деградацию, таймауты устраняют причину; в рабочем сервисе ставят и то, и другое.

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

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 на клиенте ограничивает весь вызов — от соединения до чтения тела, — контекст запроса связан с отменой тика, а закрытие тела освобождает соединение для пула. Канал здесь не нужен: результат обрабатывается в той же горутине.

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

Прочитайте код и выберите, что он выведет:

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

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

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

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

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

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

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

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

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

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

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

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

ticker := time.(30 * time.Second) defer ticker.() for { select { case <-ticker.C: poll() case <-ctx.(): return } }

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

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

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

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

Термин

Утечка горутин

нажмите, чтобы перевернуть
Определение

Горутина дожидается условия, которое не наступит: канал без читателя или отправителя, мьютекс без освобождения. GC не освобождает её память, пока горутина жива.

1 / 6

Утечка горутин в задачах — арифметика расписания, помноженная на дефект синхронизации: заблокированная горутина не возвращается, GC не освобождает её память, а тики плодят новые. Три источника закрывают большую часть случаев: тикеры без Stop, вызовы без таймаутов и каналы без читателей; лечение каждый раз одно — дедлайны на блокирующие вызовы, defer на ресурсы и select с веткой отмены при отправке. Обнаружение идёт по нарастающей: счётчик в метрике замечает наклон, pprof-снимок называет строку, goleak не даёт вернуться. Дальше по маршруту: распределённые блокировки — что происходит, когда инстансов несколько, и тестирование задач — где живут тесты с goleak. Если возвращались с пропуска — graceful shutdown показывает, как правильно останавливать задачи, конкурентность задач — откуда берётся параллельная работа, а метрики горутин детализируют счётчик из этого урока. Полный маршрут — на странице курса. В домашнем задании — счётчик горутин, снимок pprof и goleak-тест на своей обёртке.

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

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

  • Утечка горутин — горутина, дожидающаяся условия, которое не наступит; 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-тест на пакет задач, включая сценарий с остановкой по контексту. Финал — сравните график горутин до и после: после фикса он обязан быть ровным при том же расписании.

Личные заметкиПусто
Хранятся только в вашем браузере (Cron · Утечки горутин в задачах)

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

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

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

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

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

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

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

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

Slog25 уроков~9 чпрактика25 уроков уже доступны

Логирование в Go: структурные логи на slog

Курс по slog в Go: уровни и атрибуты, JSON-логи, свой Handler, производительность, секреты и прод. 25 уроков с примерами и проектом.

  • Логировать структурированно: уровни, ключи-атрибуты, группы
  • Выбирать формат вывода: TextHandler, JSONHandler, свой Handler
  • Мигрировать со старого пакета log без переписывания всей кодовой базы
  • Держать производительность: Enabled, предвычисление, горячий путь