16 мар. 2025 г.·8 мин чтения

Утечки горутин в Go-сервисах: скрытые паттерны, которые нужно исправить

Узнайте, как утечки горутин в Go-сервисах прячутся за нормальными метриками. Раньше замечайте проблемы с пулами воркеров, каналами и пропущенной отменой context.

Утечки горутин в Go-сервисах: скрытые паттерны, которые нужно исправить

Почему даже здоровые сервисы текут

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

Вот почему утечки горутин в Go-сервисах так легко пропустить. Всплеск CPU замечают быстро. Утечка обычно молчит. Она понемногу увеличивает память, держит живым таймер, не даёт закрыться сокету или оставляет одного воркера ждать на канале, который больше никто не использует. Одна зависшая горутина редко мешает. Несколько тысяч — уже да.

Высокая нагрузка и зависшие горутины — это не одно и то же. При реальном трафике число горутин растёт, а потом возвращается вниз, когда нагрузка падает. Время ответа может ухудшиться на какое-то время, но форма графика соответствует спросу. Утечка так не выглядит. Число продолжает медленно расти даже ночью, потому что какие-то горутины так и остаются висеть на получении, отправке, блокировке или context, который никто не отменяет.

Сборщик мусора это за вас не исправит. Если горутина всё ещё жива, её стек тоже остаётся живым. А значит, объекты, привязанные к этой горутине, часто дольше остаются в памяти, чем вы ожидаете. Снаружи сервис выглядит здоровым, но внутри становится тяжелее.

Первые признаки обычно очень простые:

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

Сначала можно заметить и более мелкие сигналы. Перезапуск занимает больше времени, чем раньше. Тест иногда зависает раз в несколько запусков. Пул воркеров кажется полным, хотя объём запросов выглядит обычным. Всё это легко списать со счетов, потому что сервис по-прежнему отвечает.

Вот что делает утечки такими раздражающими. Успешные ответы говорят только о том, что путь запроса сработал. Они не показывают, завершилась ли каждая горутина, запущенная для этого запроса.

Паттерны утечек в пулах воркеров

Пул воркеров часто течёт тихо. Запросы по-прежнему завершаются, CPU остаётся в норме, и сервис выглядит исправным. А где-то в фоне несколько горутин продолжают ждать на каналах или бесконечно повторять работу, которую никто уже не прочитает.

Один из типичных паттернов начинается с воркеров, которые сидят в цикле вроде for job := range jobs. Такой код работает только если кто-то вовремя закроет jobs. Если производитель остановится раньше или при завершении работы забудут закрыть канал, эти воркеры будут ждать вечно. В малом масштабе это почти незаметно. В нагруженном API каждый деплой или смена конфигурации может оставлять ещё одну порцию зависших горутин.

Простой пример — пул, который обрабатывает доставку вебхуков. Сервис запускает 20 воркеров при старте. Позже на одном из путей обработки ошибки меняется производитель, но старый канал jobs так и не закрывается. Трафик при этом идёт по новому пути, поэтому никто не замечает старых воркеров. Именно так утечки горутин в Go-сервисах проходят мимо обычных проверок здоровья.

Когда отправители живут дольше получателей

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

Становится ещё хуже, когда код пытается «починить» блокировку через go func() { jobs <- item }(). Это убирает backpressure, но теперь каждый медленный или умерший воркер может оставить ещё одну горутину зависшей на отправке. Всплеск трафика превращает маленькую ошибку в сотни паркующихся горутин.

Ретраи, которые никогда не заканчиваются

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

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

Несколько проверок ловят большинство таких случаев:

  • Назначьте одного владельца, который отвечает за закрытие канала задач.
  • Останавливайте производителей до выхода воркеров или используйте один и тот же context для обеих сторон.
  • Ограничивайте число повторов или задавайте deadline для каждой задачи.
  • Никогда не прячьте блокирующую отправку внутри новой горутины.
  • Считайте активных воркеров и смотрите, возвращается ли это число к базовому уровню после снижения нагрузки.

Пулы воркеров полезны, но они не очищаются сами. Если у пула нет чётких правил старта, остановки и повторов, он обычно начинает течь задолго до того, как проблема становится заметной по памяти.

Как неправильная работа с каналами ловит горутины в ловушку

Многие утечки горутин в Go-сервисах начинаются с одной заблокированной операции с каналом. Сервис по-прежнему отвечает на запросы, панели мониторинга выглядят нормально, а утечка медленно растёт в фоне.

Отправка может зависнуть навсегда, если из канала больше никто не читает. Это часто случается после таймаута, раннего выхода или когда воркер остановился из-за ошибки, а производители продолжили отправлять данные. Буферизированный канал лишь какое-то время скрывает проблему. Когда буфер заполняется, каждый отправитель паркуется и остаётся в памяти.

Обратная сторона встречается так же часто. Горутина, которая ждёт <-ch или крутится в for v := range ch, нуждается либо в отправителе, либо в закрытии канала. Если ничего из этого не происходит, она никогда не завершится. Одно забытое закрытие в схеме fan-out может оставить десятки читателей в подвешенном состоянии. Это часто видно в конвейерах обработки запросов, где один этап останавливается раньше, а следующий всё ещё ждёт новую работу.

Есть три ловушки, которые повторяются снова и снова:

  • Переменная канала остаётся nil, поэтому любая отправка или получение на ней блокируется навсегда.
  • Производитель возвращается, не закрыв выходной канал, и downstream-горутины продолжают идти по range.
  • select ждёт на ветках, которые больше не могут продвинуться вперёд.

Nil-каналы особенно коварны. Внутри select nil-канал просто выключает соответствующую ветку. Это может быть полезно, если вы делаете это специально. Но если канал никогда не был инициализирован или был обнулён слишком рано, одна ветка логики просто молча перестаёт работать.

Зависшие select-выражения тоже создают утечки, если в них нет пути выхода. Если горутина ждёт results <- item или <-jobs, но никогда не слушает ctx.Done(), она может пережить запрос, который её создал. Запрос заканчивается, вызывающая сторона уходит, а горутина продолжает ждать событие, которое уже никогда не случится.

Одна простая привычка очень помогает: у каждой горутины должен быть понятный способ остановки. Для кода с каналами это обычно означает одно из трёх правил: отправитель закрывает канал, получатель слушает отмену context, либо обе стороны завершаются через общий путь остановки. Если вы не можете показать этот выход, скорее всего, его нет.

Забытые отмены и фоновые задачи

Многие утечки начинаются с работы, которая переживает запрос, создавший её. Обработчик уже вернулся, клиент ушёл, а дочерняя горутина всё ещё опрашивает, повторяет попытку или ждёт ввода-вывода. Сервис выглядит нормально часами, потому что каждая утёкшая горутина почти ничего не делает. Потом трафик держится высоким, и таких горутин становится тысячи.

Одна из самых частых причин — потеря request context по пути вниз. Обработчик получает r.Context(), но вспомогательная функция запускает работу с context.Background() или вообще не передаёт context. Такой небольшой разрыв означает, что дочерняя задача больше не знает, когда запрос истёк, завершился с ошибкой или был отменён.

Простой пример: API-запрос запускает горутину, которая получает данные из двух backend-ов и пишет результат в канал. Если клиент отключится раньше, а горутина игнорирует исходный context, она может зависнуть на отправке или продолжать ретраить несколько минут. В коротком тесте вы этого не заметите. В продакшене — заметите.

Таймеры и ticker-ы создают такой же медленный тип утечки. time.NewTicker продолжает тикать, пока вы не вызовете Stop(). Цикл повторов с ticker внутри горутины может жить вечно, если его ничто не остановит. time.After внутри цикла тоже создаёт нагрузку, потому что каждый раз создаёт новый таймер.

Долгоживущим фоновым задачам тоже нужен понятный сигнал остановки. Обновители кэша, poller-ы очередей, отправители метрик и циклы очистки должны слушать ctx.Done() и быстро завершаться. Если у горутины есть for-цикл, посмотрите, как она из него выходит. Если ответ — «обычно продолжает работать», это проблема.

Несколько привычек помогают ловить это раньше:

  • Передавайте один и тот же context от запроса во все дочерние вызовы, которые должны завершаться вместе с ним.
  • Используйте context.WithCancel или context.WithTimeout и обязательно вызывайте cancel.
  • Останавливайте каждый ticker и таймер, когда работа заканчивается.
  • Добавляйте case <-ctx.Done(): return в длинные циклы, которые ждут, опрашивают или повторяют попытки.
  • В тестах сравнивайте число горутин до и после отменённых запросов.

Команды, которые держат Go-сервисы в тонусе, быстро это усваивают. Одна забытая отмена может потратить больше памяти, чем медленный запрос, потому что она никогда не уходит.

Простой пример из реального сервиса

Исправить зависшие горутины
Разберитесь с блокирующими отправками, пропущенной отменой и ошибками веерного разветвления с опытным CTO.

Частая производственная ошибка начинается с запроса, который разветвляется на несколько помощников. Представьте Go API, который собирает один ответ из PostgreSQL, Redis и запроса к AI. Чтобы страница открывалась быстрее, обработчик запускает три горутины и ждёт их ответов.

Код выглядит безобидно:

func handle(ctx context.Context, id string) (Resp, error) {
    results := make(chan Part)
    errs := make(chan error, 1)

    go loadProfile(ctx, id, results, errs)
    go loadCache(ctx, id, results, errs)
    go loadSummary(ctx, id, results, errs)

    var resp Resp
    for i := 0; i < 3; i++ {
        select {
        case part := <-results:
            resp.Merge(part)
        case err := <-errs:
            return Resp{}, err
        }
    }

    return resp, nil
}

Утечка появляется, когда одна ветка быстро падает. Допустим, loadCache ловит таймаут и отправляет ошибку. Обработчик сразу возвращает ответ. На первый взгляд всё нормально, но две другие горутины всё ещё могут работать. Если позже они попытаются сделать results <- part, получателя уже не останется. Они блокируются и остаются в памяти.

Ситуация становится хуже, если воркеры игнорируют отмену. Если обработчик не создаёт дочерний context и не вызывает cancel(), эти фоновые вызовы продолжают ждать базу данных, сеть или API модели, хотя сам запрос уже мёртв. Одна плохая ветка оставляет позади две горутины.

На спокойном сервисе этого никто не замечает. Несколько зависших горутин ничего не ломают. CPU остаётся в норме. Процент успешных ответов тоже может выглядеть хорошим. Вот почему утечки горутин в Go-сервисах часто прячутся в системах, которые внешне кажутся здоровыми.

Но если держать на этом endpoint-е стабильный трафик несколько часов, математика меняется. Если 2 % запросов завершаются раньше и каждый из них оставляет две горутины, сервис продолжает накапливать зависшую работу. Память растёт медленно. Сборка мусора запускается чаще. Пулы соединений заняты дольше, чем должны. И сервис начинает ощущаться странным: немного медленнее, немного шумнее, ему труднее доверять.

Вот на что стоит смотреть: fan-out, ранний возврат, отсутствие cancel и горутины, которые всё ещё ждут отправки.

Как найти утечку шаг за шагом

Утечку легче исправить, если превратить её в счётчик, stack trace и повторяемый тест. Гадание только тратит время. Небольшой порядок действий помогает найти точное место, где горутины перестают двигаться вперёд.

Начните с чисел. Зафиксируйте число горутин в простое, затем под нагрузкой и ещё раз через несколько минут после окончания нагрузки. runtime.NumGoroutine() даёт быстрый взгляд, а профиль горутин показывает заблокированные стеки за этим числом. В сервисе с Prometheus, Grafana или pprof это обычно настраивается за несколько минут.

Если число растёт во время трафика и не возвращается близко к базовому уровню после простоя, у вас, скорее всего, реальная утечка. Здоровый сервис может резко подскакивать под нагрузкой, но эти горутины должны завершить работу и исчезнуть.

Что проверять в первую очередь

Смотрите на стеки горутин группами. Обычно вы увидите одну и ту же заблокированную строку много раз подряд. Этот паттерн подсказывает, куда копать:

  • воркеры, которые бесконечно ждут получение
  • отправители, заблокированные на полном канале
  • горутины, застрявшие в select без пути отмены
  • HTTP-вызовы, обращения к БД или таймеры без timeout
  • фоновые циклы, которые не останавливаются при завершении работы

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

Затем проследите поток context. Каждая горутина, зависящая от запроса, должна наследовать context, а код, который создаёт context.WithCancel или context.WithTimeout, должен вызывать cancel. Забытые отмены часто прячутся в ретраях, fan-out вызовах и фоновых помощниках.

Добавьте защиту, пока вы отлаживаете. Поставьте timeout на внешние вызовы. Добавьте логи или метрики, когда воркеры запускаются и останавливаются. Напишите тест на завершение: он запускает сервис, прогоняет через него немного трафика, останавливает его, ждёт несколько секунд и проверяет, вернулось ли число горутин близко к базовому уровню.

Запускайте ту же нагрузку снова после каждого исправления. Сравнивайте число не только на пике, но и в простое. Если после успокоения сервиса всё ещё видны лишние горутины, читайте новый профиль и повторяйте. Это скучно, но так утечки находятся быстрее, чем если часами смотреть в код.

Ошибки, которые создают утечки

Проверить retry-циклы
Найдите циклы, тикеры и таймеры, которые продолжают работать задолго после завершения запроса.

Большинство утечек горутин в Go-сервисах появляется из обычного кода, а не из редких edge case-ов. Сервис выглядит исправным, запросы проходят, логов почти нет. Тем временем несколько зависших горутин копятся каждый час, пока память и файловые дескрипторы не начинают незаметно ползти вверх.

Первая ошибка — это владение. Если вы запускаете горутину, кто-то в коде должен владеть всем её жизненным циклом. Этот владелец должен понимать, зачем она существует, когда должна остановиться и какой сигнал говорит ей выходить. Если ей никто не владеет, уборка превращается в надежду на авось.

Типичный пример — код запроса, который запускает фоновую работу «просто на время». Запрос заканчивается, а горутина всё ещё ждёт на канале или ретраит вызов. После достаточного трафика у вас уже сотни хвостов от задач, за которыми никто не следит.

Ещё одна тихая утечка связана с обращениями к базе данных и сети. Если пропустить передачу context, медленный запрос или зависший HTTP-вызов может держать горутину заблокированной намного дольше, чем живёт запрос, который её создал. Для этого не нужен полный outage. Достаточно одной проблемной зависимости, крошечной ошибки в timeout или мёртвого TCP-соединения.

Фоновые циклы повторов заслуживают особого подозрения. Многие команды пишут цикл, который бесконечно повторяет попытки со sleep, а потом забывают добавить условие остановки. Такой код кажется безопасным, потому что он «продолжает пытаться», но часто он привязывает горутину к жизни всего процесса. Ограничивайте число повторов, используйте backoff или и то и другое, и пусть context завершает цикл.

Ticker-ы и таймеры выглядят безобидно, но утечки через них встречаются чаще, чем люди готовы признать. Ticker, запущенный во воркере, обновителе кэша или цикле метрик, будет тикать, пока вы его не остановите. Если горутина завершится без Stop(), вы оставите работу после себя. Если горутина вообще не завершится, ticker будет поддерживать её жизнь.

Короткий чек-лист помогает:

  • Дайте каждой горутине одного понятного владельца и один понятный путь завершения.
  • Передавайте context.Context в запросы к БД, HTTP-вызовы и любые блокирующие операции.
  • Ограничивайте ретраи в фоновых циклах или останавливайте их при завершении context.
  • Останавливайте каждый ticker и таймер в том же пути кода, который их создал.

Я бы воспринимал любую строку go func() как небольшое обязательство, пока не станет очевиден путь выхода. Эта привычка ловит больше утечек, чем сложные инструменты, и делает это раньше.

Быстрые проверки, пока память не начала расти

Получить поддержку fractional CTO
Привлеките senior-помощь по Go-архитектуре, наблюдаемости и продакшен-операциям.

Утечки часто не видны при ровном трафике. Проблема проявляется после всплеска, когда запросы падают, а горутины — нет. Это один из самых быстрых способов заметить утечки горутин в Go-сервисах до того, как память начнёт медленно ползти вверх.

Запустите короткий нагрузочный тест, а потом просто оставьте сервис в покое. Если через несколько минут число горутин всё ещё высокое, значит что-то продолжает ждать канал, таймер, сетевой вызов или context, который так и не заканчивается.

Несколько проверок заранее ловят много утечек:

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

Скорость завершения говорит больше, чем многие логи. Здоровый сервис обычно останавливается быстро, потому что воркеры выходят при закрытии context, очереди перестают принимать работу, а фоновые циклы завершаются. Если завершение зависает, какая-то горутина всё ещё ждёт input, который уже никогда не придёт.

Код воркеров должен быть скучным. Это хороший знак. Воркер читает из одного места, обрабатывает одну задачу и выходит, когда канал закрывается или context заканчивается. Когда воркер может выйти пятью разными способами, кто-то обязательно забудет один. Именно там и прячутся утечки.

Timeout-ы заслуживают той же дисциплины. Если вы запускаете горутину для запроса к базе данных, вебхука, retry-цикла или обновления кэша, задайте ей deadline. Если создаёте context с cancel, вызывайте cancel на каждом пути, а не только при ошибках.

Полезен и простой тест: отправьте всплеск задач, дождитесь, пока очередь опустеет, а затем проверьте, успокаивается ли процесс. В здоровом сервисе показатели выравниваются. В утечном — после каждого всплеска они продолжают понемногу расти. Такой медленный подъём легко не заметить в продакшене и дешево поймать на staging.

Если одна из этих проверок не проходит, остановитесь и исправьте это до того, как добавлять ещё трафик. Утечки редко остаются маленькими.

Что делать дальше

Воспринимайте предотвращение утечек как часть релизной работы, а не как задачу на потом. Большинство утечек горутин в Go-сервисах долго молчат, а потом проявляются ростом памяти, более медленными перезапусками или pod-ами, которые так и не приходят в спокойное состояние после снижения трафика.

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

Отслеживайте число горутин рядом с памятью и задержкой. Эти три числа вместе рассказывают историю лучше, чем каждое по отдельности. Если память медленно растёт, а горутины скачут после каждого деплоя, скорее всего, у вас есть фоновая работа, которая не завершается.

Короткого чек-листа достаточно:

  • Запускайте в CI тесты, ориентированные на утечки, для пулов воркеров, ретраев и путей завершения.
  • Разместите число горутин на той же панели, где память, CPU и задержка.
  • Настройте алерты на устойчивый рост со временем, а не только на жёсткий предел.
  • На постмортемах пересматривайте, как останавливаются пулы, кто закрывает каналы и где заканчиваются context.
  • Тестируйте поведение при перезапуске, потому что многие утечки проявляются только когда код стартует и останавливается не один раз.

Делайте разбор инцидентов конкретным. Не останавливайтесь на фразе «мы увидели слишком много горутин». Запишите, какая именно горутина запустилась, чего она ждала и какой сигнал должен был её остановить. Это даёт следующему инженеру задачу, которую реально можно исправить.

Небольшие команды часто дважды пропускают одну и ту же утечку, потому что никто не останавливается и не пересматривает саму конструкцию пула. А это важно. Пул воркеров может выглядеть аккуратно в коде, но всё равно скрывать зависшие отправки, бесконечные ретраи или одну пропущенную отмену.

Если вашей команде нужна внешняя помощь, Oleg Sotnikov предлагает практические CTO-консультации по Go-сервисам и продакшен-операциям. Его опыт охватывает Go, инфраструктуру, observability и lean production systems, поэтому он обычно фокусируется на скучных вещах, которые и вызывают утечки: на завершении работы, владении каналами и фоновых задачах, которые переживают свой запрос.

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