Содержание урока · 10 разделов
- Утечка горутин: почему горутина не возвращается
- Забытые тикеры: Stop как часть контракта
- Зависшие воркеры: таймауты на внешние вызовы
- Симптомы утечек и инструменты поиска
- Detect по метрике: NumGoroutine и pprof
- Спорное: пул воркеров как решение утечек
- Чеклист против утечек
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Сценарий для старта: бот напоминаний стабильно работал месяц, затем дежурный заметил, что контейнер съедает вдвое больше памяти, чем при релизе. График go_goroutines показал прямую линию вверх: с двухсот горутин на старте до сорока тысяч к концу месяца. В pprof-снимке почти все горутины стояли в одном и том же select — отправка результата в канал, который после отмены контекста никто не читал. Задача напоминаний дёргала внешнее API: при таймауте-отмене основная горутина тика выходила, а вспомогательная — горутина запроса — оставалась ждать читателя. Один тик — одна потерянная горутина, при минутном расписании это 1440 горутин в сутки. Механику горутин и каналов разбирали урок про горутины и урок про каналы; здесь она работает в цикле по расписанию, где ошибка умножается на число тиков.
Утечка горутин: почему горутина не возвращается#
Утечка горутин — это горутина, которая дожидается условия, которое не наступит: чтения из канала, куда больше никто не пишет, отправки в канал, который никто не читает, блокировки на мьютексе, который не отпустят. Сборщик мусора здесь бессилен: пока горутина жива, её стек и все захваченные переменные считаются используемыми, и память под них не освобождается. Отсюда типичный симптом — heap растёт при стабильном трафике, а профилировщик указывает на давно «умершие» структуры. В cron-задачах арифметика жёстче, чем в HTTP-сервисе: каждый тик запускает новую горутину, и если тик протекает, горутины копятся с точностью до расписания. Перекрытие запусков из урока про конкурентность — уже половина утечки: задача живёт дольше интервала, и гасить её должна обёртка из цепочки. Разберём три источника по порядку появления в реальном коде.
Задача, которая теряет горутину
GOfunc (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. Вторая линия обороны — ограничение параллелизма: фиксированный пул воркеров из проекта воркера превращает утечку из бесконечной в ограниченную — горутин не больше, чем слотов. Пул замедляет деградацию, таймауты устраняют причину; в рабочем сервисе ставят и то, и другое.
Защищённая версия той же задачи
GOfunc (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 не даёт повториться.
Кейс: команда написала собственную обёртку аудита поверх урока про wrappers — она отправляла событие о каждом тике в канал, из которого читал сборщик в отдельной горутине. После Stop сборщик выходил, а задачи продолжали отправлять: каждый тик каждой задачи оставлял горутину, заблокированную на отправке. При пятиминутном расписании и двадцати задачах это почти шесть тысяч горутин в сутки. Отличие этого кейса от одиночной ошибки — масштаб: обёртка — общий код, и её дефект наследуют все задачи сразу. Отсюда правило: обёртки, воркеры и очереди проверяются теми же инструментами, что и задачи, — снимком pprof после тестового прогона и goleak в тестах общего пакета.
Спорное: пул воркеров как решение утечек#
Спорная привычка, которую стоит оспорить: считать пул воркеров готовым ответом на утечки. Ограничение параллелизма действительно ставит потолок: горутин не больше, чем слотов. Но если каждый воркер завис на вызове без таймаута, пул встаёт целиком: тихая утечка превращается в остановку всей фоновой работы, причём без единой ошибки в логах — задачи просто не обрабатываются, а метрика горутин стоит на месте потолка. Пул без таймаутов — это распределение боли, а не лечение. Верный порядок обратный: сначала дедлайны на каждый блокирующий вызов и дисциплина каналов, потом пул как ограничитель для хвостовых случаев, которые нельзя исключить. Тогда слоты освобождаются сами, а потолок работает как страховка, а не как предел терпения.
Чеклист против утечек#
- Создали тикер или таймер — defer Stop на той же строке; time.After в горячих циклах не используется.
- Каждый внешний вызов имеет дедлайн: Timeout у клиента или контекст с дедлайном на запрос.
- Отправка в канал идёт через select с веткой отмены, а читатель гарантирован на весь срок жизни отправителя.
- Число горутин в метрике с алертом на наклон; после релиза график ровный.
- pprof-эндпоинт открыт для внутреннего контура, снимок goroutine проверяется после тестового прогона.
- Обёртки и воркеры покрыты goleak-тестами, включая сценарий остановки.
Соберите тикер без утечки
Впишите создание тикера, освобождение ресурса и чтение отмены контекста.
Расставьте защищённый вызов API
Расставьте строки в порядке файла: клиент с таймаутом, запрос с контекстом, выполнение, обработка ошибки и закрытие тела.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: утечки горутин
Механику утечки песочница показывает честно: каналы, select, close и горутины там работают, и предикт с висящим отправителем исполняется один в один. Пакетов runtime и net/http/pprof в песочнице нет, поэтому счётчик горутин замените собственным целочисленным счётчиком в замыкании: увеличили при старте горутины, уменьшили по завершении через defer — идея та же, что у метрики. Пакет time в песочнице есть: тикеры и таймеры с defer Stop проверяются на месте. Полный pprof-снимок и goleak выполняются локально из модуля второго урока.
Утечка горутин в задачах — арифметика расписания, помноженная на дефект синхронизации: заблокированная горутина не возвращается, 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-тест на пакет задач, включая сценарий с остановкой по контексту. Финал — сравните график горутин до и после: после фикса он обязан быть ровным при том же расписании.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему сборщик мусора не убирает зависшие горутины?
GC освобождает объекты, до которых нет путей из живых корней. Зависшая горутина — живой поток исполнения: её стек и захваченные переменные доступны runtime, поэтому память остаётся занятой до завершения горутины или процесса. Утечка горутин — это одновременно утечка стека и всего, что горутина держит.
Чем pprof-снимок горутин помогает в поиске утечки?
Снимок /debug/pprof/goroutine?debug=2 печатает полный стек каждой горутины. Утёкшие горутины зависают в одном и том же месте — на отправке в канал или чтении из него, — поэтому сотни одинаковых стеков сходятся в одну строку кода. Это быстрее всего приводит к месту утечки, чем счётчик в метриках.
Достаточно ли ограничить параллелизм пулом воркеров?
Пул ставит потолок числу одновременно живых горутин, но не устраняет причину: зависшие воркеры занимают слоты до конца работы, и фоновая работа останавливается. Пул полезен как ограничитель после дедлайнов и дисциплины каналов, но сам по себе утечку не лечит.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Логирование в Go: структурные логи на slog
Курс по slog в Go: уровни и атрибуты, JSON-логи, свой Handler, производительность, секреты и прод. 25 уроков с примерами и проектом.
- Логировать структурированно: уровни, ключи-атрибуты, группы
- Выбирать формат вывода: TextHandler, JSONHandler, свой Handler
- Мигрировать со старого пакета log без переписывания всей кодовой базы
- Держать производительность: Enabled, предвычисление, горячий путь