Утечки горутин в Go
Сервис работает неделю, память растёт ровно, ошибок нет, нагрузка та же. Смотрим runtime.NumGoroutine - там сорок тысяч вместо обычных двухсот. Каждая держит стек и всё, на что ссылается, и не уйдёт уже никогда. Это утечка горутин, и она почти всегда следствие блокирующей операции без выхода.
Стержень: горутина живёт до возврата из функции; заблокированная навсегда не завершится никогда, и сборщик мусора тут не помощник.
// Формулировки: «что такое утечка горутин?», «как её найти?», «зачем в горутине контекст?»
Откуда берётся
Проверим на минимальном примере: функция создаёт канал и читает из него, а отправлять некому. Запускаем пять таких горутин, ждём и смотрим счётчик - было 1, стало 6. Они не завершатся никогда, потому что ждут события, которого не будет.
В реальном коде это выглядит приличнее. Горутина шлёт результат в небуферизованный канал, а получатель уже ушёл по таймауту и не читает - отправка виснет. Или воркер читает из канала, который забыли закрыть. Или select без ветки отмены ждёт ответа от сервиса, который никогда не ответит.
Отдельный источник - HTTP-обработчики. Клиент отвалился, соединение закрыто, а горутина продолжает ходить в базу и ждать ответа, потому что про отмену ей никто не сказал. На пиковой нагрузке такие горутины копятся тысячами.
// Сборщик мусора тут бессилен принципиально. Заблокированная горутина - живой объект, на неё ссылается планировщик, и всё, что она удерживает, тоже живое. Утечка горутин - это утечка памяти, которую не найти в профиле кучи по типам.
func leak() {
ch := make(chan int)
<-ch // отправлять некому - висим вечно
}
// было горутин: 1
// после пяти вызовов go leak(): 6
// и так до конца жизни процессаКак не допускать
Первое правило: у каждой блокирующей операции есть выход. В долгоживущей горутине это ветка case <-ctx.Done() в select. Проверено на том же примере: пять горутин с контекстом и таймаутом в 50 миллисекунд после срабатывания таймаута завершились все, счётчик вернулся к прежнему.
Второе: контекст передают вниз по всей цепочке вызовов, первым аргументом. Отмена сверху должна доезжать до самого нижнего сетевого вызова, иначе горутина не узнает, что её работа уже никому не нужна.
Третье: тот, кто запустил горутину, отвечает за её завершение. У каждой должно быть понятное условие выхода - контекст, закрытие входного канала или явный сигнал остановки. Горутина, запущенная без плана завершения, рано или поздно повиснет.
// Мелкий, но частый приём: если результат отправляется в канал, который может никто не забрать, делай канал буферизованным на один элемент. Тогда отправитель положит значение и уйдёт, даже если получатель уже отвалился по таймауту.
Как обнаружить
Самое дешёвое - метрика. runtime.NumGoroutine отдаётся одной строкой, кладётся на график рядом с памятью, и утечка видна как ровно растущая линия, не зависящая от нагрузки. Это первый и главный сигнал.
Дальше - профиль горутин через pprof. Он показывает не число, а стеки: сколько горутин и где именно они стоят. Обычно картина однозначная: тридцать тысяч горутин в одной строчке файла, и это ровно тот блокирующий вызов, который нужно чинить.
На дампе стеков видно и состояние каждой горутины: chan receive, chan send, select, semacquire. Состояние сразу говорит, чего она ждёт - данных из канала, места в канале или мьютекса.
// В тестах утечку ловят иначе: замеряют NumGoroutine до и после проверяемого кода и сверяют. Есть готовые библиотеки для этого, но и десять строк руками работают - главное дать горутинам долю секунды на завершение перед вторым замером.
- pprof
- штатный профилировщик Go; профиль goroutine показывает стеки всех живых горутин
Как отвечать: «Что такое утечка горутин и как её найти?»
Это горутина, которая заблокировалась навсегда и никогда не вернётся из своей функции. Она продолжает держать стек - около двух с половиной килобайт - и всё, на что ссылается, а сборщик мусора её не тронет, потому что заблокированная горутина живая. Причина почти всегда одна: блокирующая операция без выхода. Отправка в канал, который никто уже не читает, потому что получатель ушёл по таймауту. Приём из канала, который забыли закрыть. select без ветки отмены. Или обработчик, который продолжает ждать базу, хотя клиент давно отвалился. Ищу в три шага. Метрика runtime.NumGoroutine на графике рядом с памятью - утечка видна как ровно растущая линия, не связанная с нагрузкой. Потом профиль goroutine через pprof: он показывает стеки, и обычно там сразу видно тридцать тысяч горутин на одной строчке кода. Профилактика простая и работающая: контекст первым аргументом через всю цепочку, ветка ctx.Done() в каждом select долгоживущей горутины и буфер на один элемент в канале результата, чтобы отправитель мог уйти, даже если его уже никто не ждёт. Я проверял: те же пять горутин с контекстом и таймаутом завершаются все до одной.
Названа причина класса, перечислены четыре типовых источника, дан порядок диагностики от дешёвого к точному и три конкретных приёма профилактики.
На чём валят
- −Считают, что сборщик мусора уберёт зависшую горутину - она живая для планировщика.
- −Пишут блокирующую отправку без ветки отмены и теряют горутину при таймауте получателя.
- −Не пробрасывают контекст вниз, и отмена не доезжает до сетевого вызова.
- −Запускают горутину без плана завершения: непонятно, кто и когда её остановит.
- −Не смотрят NumGoroutine на графике и узнают об утечке от нехватки памяти.
- −Ищут утечку в профиле кучи по типам, хотя видна она в профиле goroutine.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 4, остальные разбираются в тренажёре.
- Что называют утечкой горутины (goroutine leak)?A)Горутину, которая вечно заблокирована и потому не завершаетсяB)Горутину, съедающую слишком много CPU в бесконечном циклеC)Утечку памяти из-за того, что горутина не вызвала runtime.GC()D)Ситуацию, когда горутин создано больше, чем значение GOMAXPROCS
показать ответ и разбор
+A)Горутину, которая вечно заблокирована и потому не завершается// разбор: Утечка — горутина, которая никогда не завершится, потому что вечно ждёт события, которое не наступит: приём из канала без отправителя, отправку в канал без читателя, ожидание на sync-примитиве. Она держит свой стек и захваченные переменные, и память не освобождается. GC заблокированную горутину не собирает — формально она «жива». Копятся тихо, всплывают под нагрузкой.
- Горутина в цикле шлёт в канал, а читатель может уйти раньше. Как избежать утечки?A)Убрать буфер у канала — тогда отправка не сможет зависнутьB)Вызвать runtime.Goexit() из читателя, чтобы завершить писателяC)Ничего: GC со временем соберёт зависшего писателяD)Слать через select с веткой <-ctx.Done() и выходить по отмене
показать ответ и разбор
+D)Слать через select с веткой <-ctx.Done() и выходить по отмене// разбор: У писателя должен быть путь выхода на случай, если читатель пропал. Канонично — select с веткой <-ctx.Done() (или <-done) рядом с отправкой: как только контекст отменён, горутина выходит и не виснет на ch <- v. Убрать буфер только ускорит блокировку, Goexit завершает лишь свою горутину, а GC заблокированную не собирает.
- Как обнаружить утечку горутин в работающем сервисе?A)Ждать OutOfMemory: иначе утечка горутин никак не проявляетсяB)Смотреть на рост runtime.NumGoroutine и профиль goroutine в pprofC)Включить go run -race — детектор гонок считает живые горутиныD)Проверить логи: рантайм пишет предупреждение о зависшей горутине
показать ответ и разбор
+B)Смотреть на рост runtime.NumGoroutine и профиль goroutine в pprof// разбор: Утечка выглядит как монотонно растущее число живых горутин при стабильной нагрузке, поэтому NumGoroutine выводят метрикой и смотрят тренд. Профиль goroutine в pprof показывает стеки: сразу видно, что тысячи горутин стоят на одной строке с отправкой в канал. Детектор гонок тут не поможет — он про конкурентный доступ к памяти, а не про зависшие горутины.
- Функция делает ctx, cancel := context.WithTimeout(...) и не вызывает cancel. Что произойдёт?A)Ничего: по истечении таймаута контекст освободит ресурсы самB)Контекст останется без отмены, и таймаут не сработаетC)Внутренний таймер и связанная горутина проживут до срабатывания таймаутаD)Компилятор не соберёт код: cancel обязателен к вызову
показать ответ и разбор
+C)Внутренний таймер и связанная горутина проживут до срабатывания таймаута// разбор: Отмена по времени сработает, но ресурсы держатся до самого дедлайна: таймер и связка с родительским контекстом живут всё это время, даже если работа закончилась через миллисекунду. На горячем пути это заметная утечка, и потому cancel ставят через defer сразу после создания — вызов после завершения работы безвреден и просто освобождает всё раньше.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.