Утечки горутин, дедлоки, 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
    }
    Лечится буфером на 1 (make(chan int, 1)), чтобы отправка не блокировалась, либо веткой ctx.Done().
  2. Чтение из канала, который никто не закроетfor v := range ch висит, если отправитель забыл close.
  3. Нет ветки отмены — долгоживущая горутина без case <-ctx.Done().
  4. WaitGroup без DoneWait не вернётся никогда.
  5. Сетевой вызов без таймаута — горутина зависает на 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) и локализация причины по агрегированным стекам.