Модуль: 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 }make(chan int, 1)), чтобы отправка не блокировалась, или веткаctx.Done(). - Чтение из канала, который никто не закроет.
for v := range chвисит, если writer забылclose. - Нет ветки отмены. Долгоживущая горутина без
case <-ctx.Done()в select. - WaitGroup без Done →
Waitвисит вечно. - Сетевой вызов без таймаута/контекста — горутина зависает на 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) и локализация причины по агрегированным стекам.