Утечки горутин, дедлоки, livelock, starvation#
Модуль: Concurrency · Уровень: Senior
TL;DR#
Главные болезни конкурентного Go: deadlock (взаимная блокировка — никто не двигается), livelock (горутины активны, но прогресса нет), starvation (горутина не получает ресурс), и goroutine leak (горутина навсегда заблокирована и не собирается GC). Чаще всего утечки происходят из-за каналов: отправка без получателя, чтение из канала, который никто не закроет, отсутствие ветки ctx.Done(). Диагностика — runtime.NumGoroutine, goroutine-профиль pprof, дамп стеков (SIGQUIT), go test -race и встроенный детектор полного дедлока.
Простыми словами#
Четыре типовые болезни конкурентного кода: дедлок, ливлок, голодание и утечка горутин.
- Дедлок — все чего-то ждут по кругу, никто не двигается. Go умеет ловить только полный дедлок, когда спят вообще все горутины; частичный (две горутины из двухсот) рантайм не заметит.
- Ливлок — горутины активно работают, но полезного прогресса нет: постоянно уступают друг другу и начинают заново.
- Голодание (starvation) — горутина всё время оказывается в конце очереди и до ресурса не доходит.
- Утечка горутин — горутина заблокирована навсегда, разбудить её некому, и сборщик мусора её не заберёт: она живая, у неё есть стек и ссылки на данные.
Утечки почти всегда одного происхождения: отправка в канал, который больше никто не читает; чтение из канала, который никто не закроет; отсутствие ветки ctx.Done() в select.
Как ловить: вывести runtime.NumGoroutine() в метрики (монотонный рост — течёт), снять goroutine-профиль через pprof или дамп стеков сигналом SIGQUIT.
Если ты с другого языка. Всё то же самое, что с потоками в Java, с одной разницей: горутины настолько дёшевы, что их заводят тысячами, и утечка десятка тысяч горутин может месяцами не быть заметна — просто медленно растущая память.
Словарь этой статьи:
- дамп стеков — снимок того, где сейчас стоит каждая горутина; главный инструмент диагностики зависаний;
- goroutine-профиль — тот же снимок, сгруппированный по одинаковым стекам: сразу видно «5000 горутин стоят на этой строке».
Теория#
Deadlock#
Все горутины (в подграфе) ждут друг друга — прогресса нет.
- Полный дедлок (все горутины процесса спят) рантайм детектит и роняет процесс:
fatal error: all goroutines are asleep - deadlock!. - Частичный дедлок (живёт хоть одна горутина, например, обслуживает HTTP) рантайм не обнаружит — это тихая утечка/зависание подсистемы.
Классика:
ch := make(chan int) // небуферизованный
ch <- 1 // некому принять → блок навсегда (полный дедлок, если один)Inversion lock order (две блокировки в разном порядке):
// G1: A.Lock(); B.Lock()
// G2: B.Lock(); A.Lock() → циклическое ожиданиеРешение: единый глобальный порядок захвата блокировок (как lockorder в selectgo).
Livelock#
Горутины не заблокированы, постоянно что-то делают, но не продвигаются — реагируют друг на друга и откатываются. Пример: два процесса вежливо уступают ресурс и оба снова пытаются. CPU занят, прогресса нет. Часто возникает в наивных схемах «попробовать-откатиться» без рандомизированного backoff. Лечится экспоненциальным backoff с джиттером, очередями, отказом от busy-retry.
Starvation#
Горутина систематически не получает ресурс/CPU, хотя могла бы.
- На уровне мьютекса — barging в normal mode (решено starvation mode Go 1.9, см. mutex-rwmutex.md).
- На уровне select — закрытый/всегда-готовый канал перетягивает выбор.
- На уровне планировщика — длинный CPU-bound цикл без точек переключения (до асинхронной вытесняемости 1.14 мог монополизировать P).
- Приоритетные схемы, где высокоприоритетный поток вечно опережает низкоприоритетный.
Goroutine leak#
Горутина навсегда заблокирована (на канале, мьютексе, сетевом вызове без таймаута) и никогда не завершится. GC не собирает заблокированные горутины — их стек и захваченные объекты живут вечно. Со временем это рост памяти и числа горутин, то есть деградация без единой ошибки в логах.
Здесь дедлок и утечка смыкаются: полный дедлок роняет процесс сразу, а частичный — это и есть утечка группы горутин.
Краткий чек-лист причин (почти все — каналы):
- Отправка в канал, который никто не читает — получатель ушёл по таймауту или ошибке:Лечится буфером на 1 (
func leak() <-chan int { ch := make(chan int) // небуферизованный go func() { ch <- expensive() }() // вызывающий не дочитал — горутина висит return ch }make(chan int, 1)), чтобы отправка не блокировалась, либо веткойctx.Done(). - Чтение из канала, который никто не закроет —
for v := range chвисит, если отправитель забылclose. - Нет ветки отмены — долгоживущая горутина без
case <-ctx.Done(). WaitGroupбезDone—Waitне вернётся никогда.- Сетевой вызов без таймаута — горутина зависает на I/O.
Разбор каждой причины с примерами, механика парковки горутины, полный набор инструментов диагностики (goroutine-профиль, debug=2, diff профилей, goleak) и профилактика — в отдельной статье «Утечки горутин» модуля Runtime и память.
Диагностика дедлоков и зависаний#
Для утечек инструментарий описан в статье выше; здесь — то, что относится именно к блокировкам:
fatal error: all goroutines are asleep— рантайм сам сообщает о полном дедлоке. Частичный он не увидит.- SIGQUIT (
kill -QUIT) печатает стеки всех горутин: видно, кто на каком мьютексе или канале стоит. Первый инструмент при зависшем процессе. - Block profile (
runtime.SetBlockProfileRate) — где горутины блокируются дольше всего. - Mutex profile (
runtime.SetMutexProfileFraction) — конкуренция за мьютексы; именно здесь видно starvation. GODEBUG=schedtrace=1000— состояние очередей планировщика раз в секунду: живой признак livelock (P заняты, полезной работы нет).
Подводные камни / gotchas#
- Частичный дедлок не ловится рантаймом. «Программа не падает» ≠ «всё хорошо»; подсистема могла тихо зависнуть. Нужны таймауты/мониторинг.
- Небуферизованный канал в «отдал и забыл». Producer-горутина зависнет, если consumer ушёл. Буфер 1 или ctx спасают.
time.Afterв цикле держит таймеры до срабатывания — не утечка горутин, но утечка памяти/таймеров (см. select.md).- Забытый
close→ вечныйrange. Закрывает только writer. - context передан, но не проверяется. Передать ctx мало — горутина должна реагировать на
ctx.Done(), иначе отмена бесполезна. - defer cancel() пропущен у
context.WithCancel/Timeout→ утечка контекста (внутренняя горутина таймера/связи живёт). go vet (lostcancel) предупреждает. - Рост горутин под нагрузкой, стабильный в покое — классический признак утечки на запрос (на каждый запрос порождается горутина, которая не завершается).
- GC не спасёт. Заблокированная горутина недостижима для сборки — память течёт, пока процесс жив.
Вопросы на собеседовании#
В: Чем отличаются deadlock, livelock и starvation? О: Deadlock — все участники заблокированы, ждут друг друга, ноль активности. Livelock — участники активны (жгут CPU), но прогресса нет (бесконечно реагируют/откатываются). Starvation — конкретная горутина не получает ресурс/CPU, хотя система в целом работает.
В: Когда Go-рантайм обнаруживает дедлок, а когда нет?
О: Только полный дедлок — когда все горутины процесса спят и работы нет: fatal error: all goroutines are asleep - deadlock!. Частичный (живёт хоть одна горутина) не детектится — это тихое зависание/утечка, требующее таймаутов и мониторинга.
В: Почему утечки горутин опасны, ведь есть GC? О: GC не собирает заблокированные горутины — они достижимы через рантайм и удерживают свой стек и все захваченные объекты. Утечка растит память и число горутин, постепенно деградируя процесс вплоть до OOM.
В: Назовите самую частую причину утечки на каналах.
О: Отправка в небуферизованный канал, который никто не дочитает (получатель ушёл по таймауту/ошибке), и чтение range из канала, который writer забыл закрыть. Лечится буфером на 1, веткой ctx.Done() и дисциплиной закрытия (закрывает writer).
В: Как продиагностировать утечку горутин в проде?
О: Мониторить runtime.NumGoroutine() (алерт на рост), снять goroutine-профиль через net/http/pprof (?debug=2 — полные стеки): сотни горутин, висящих на одной строке канала/мьютекса, укажут точку. SIGQUIT даёт дамп стеков. В тестах — goleak.
В: Как избежать инверсии порядка блокировок?
О: Установить и соблюдать единый глобальный порядок захвата мьютексов во всём коде. Так циклического ожидания не возникнет. Именно так рантайм блокирует каналы в selectgo (по адресам).
В: Как защитить «отдал и забыл» горутину от утечки?
О: Дать каналу буфер на 1 (отправка не заблокируется, даже если получатель ушёл) или добавить select с case <-ctx.Done(): return. Тогда при уходе потребителя горутина завершится, а не зависнет.
В: Что такое livelock на практике и как лечить? О: Например, два потока берут ресурсы, обнаруживают конфликт, оба откатываются и сразу повторяют синхронно — бесконечно. CPU занят, прогресса нет. Лечится рандомизированным экспоненциальным backoff с джиттером, очередью с справедливым порядком, отказом от busy-retry.
В: Передал context в горутину, но отмена не работает. Почему?
О: Передать ctx недостаточно — горутина должна проверять ctx.Done() (в select на каждой блокирующей операции). Без этого она не узнает об отмене. Плюс не забыть defer cancel() у WithCancel/Timeout, иначе утечёт сам контекст.
В: Какие профили pprof помогают с конкурентностью?
О: goroutine (где висят), block (SetBlockProfileRate — где дольше всего блокируются), mutex (SetMutexProfileFraction — contention). Плюс schedtrace через GODEBUG для телеметрии планировщика.
На что копают на senior+#
- Почему частичный дедлок невидим рантайму и какие архитектурные средства (таймауты, дедлайны, мониторинг NumGoroutine) его компенсируют.
- Жизненный цикл заблокированной горутины и почему GC её не собирает (достижимость через рантайм, удержание стека).
- Инверсия порядка блокировок, граф ожидания, единый lock ordering; аналогия с
lockorderв selectgo. - Backoff-стратегии против livelock: экспоненциальный + джиттер, и почему синхронный retry создаёт livelock.
- Тестирование на утечки: goleak, сравнение NumGoroutine, стресс с -race; ограничения каждого подхода.
- Интерпретация goroutine-дампа: чтение состояний (
chan receive,semacquire,select,IO wait) и локализация причины по агрегированным стекам.