Модуль: Concurrency · Уровень: Senior

TL;DR#

Главные болезни конкурентного Go: deadlock (взаимная блокировка — никто не двигается), livelock (горутины активны, но прогресса нет), starvation (горутина не получает ресурс), и goroutine leak (горутина навсегда заблокирована и не собирается GC). Чаще всего утечки происходят из-за каналов: отправка без получателя, чтение из канала, который никто не закроет, отсутствие ветки ctx.Done(). Диагностика — runtime.NumGoroutine, goroutine-профиль pprof, дамп стеков (SIGQUIT), go test -race и встроенный детектор полного дедлока.

Теория#

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 висит, если writer забыл close.
  3. Нет ветки отмены. Долгоживущая горутина без case <-ctx.Done() в select.
  4. WaitGroup без DoneWait висит вечно.
  5. Сетевой вызов без таймаута/контекста — горутина зависает на I/O.

Диагностика#

  • runtime.NumGoroutine() — мониторить рост числа горутин (метрика/алерт). Постоянный рост = утечка.
  • pprof goroutine profile:
    import _ "net/http/pprof"
    // go tool pprof http://host/debug/pprof/goroutine
    // или ?debug=2 для полного дампа стеков с местом блокировки
    Дамп покажет, на какой строке (канал/мьютекс) висят сотни одинаковых горутин — это и есть точка утечки.
  • SIGQUIT / kill -QUIT (или паника) печатает стеки всех горутин — видно, где заблокированы.
  • GODEBUG=schedtrace=1000 — телеметрия планировщика.
  • Block profile (runtime.SetBlockProfileRate) — где горутины блокируются дольше всего.
  • Mutex profile (runtime.SetMutexProfileFraction) — contention на мьютексах.
  • -race — гонки (косвенно связаны).
  • Тесты на утечки: сравнить NumGoroutine до/после теста, либо go.uber.org/goleak (goleak.VerifyNone(t)).

Подводные камни / 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) и локализация причины по агрегированным стекам.