Конкурентный код в Go ломается двумя характерными способами, и оба коварны тем,
что в «счастливом пути» их не видно. Первый — утечка горутины (goroutine
leak): горутина запустилась и навсегда зависла, рантайм её не уберёт, а она тихо
держит память и захваченные ресурсы. Второй — data race: две горутины лезут к
одной ячейке памяти без синхронизации, и поведение становится неопределённым.
Объединяет их то, что глазами вы их почти не поймаете: код «вроде работает», а
проблема всплывает под нагрузкой, на другой машине или раз в тысячу прогонов.
Поэтому ловят их не вычиткой, а инструментами — race detector, goleak,
pprof. Эта глава про то, как утечки и гонки выглядят, почему возникают и чем их
обнаруживать. Сразу честно: настоящий параллелизм и -race в браузерном
плейграунде недоступны, поэтому такие примеры здесь статичные, с разбором — а
запускаемые примеры показывают то, что в одном потоке воспроизводится корректно
(блокировки, отмена через context, починка утечки буфером).
Горутина утекает, когда блокируется навсегда и никогда не продвинется: висит на
отправке в канал, на приёме из канала, на мьютексе или на select, у которого ни
одна ветка не сработает. С точки зрения рантайма у неё «есть незавершённая
работа», поэтому сборщик мусора её не тронет — горутину нельзя собрать, пока она
не вернулась из своей функции.
В отличие от утечки памяти в обычном смысле, утечка горутины утаскивает за собой и
всё, что эта горутина держит в своих локальных переменных и замыканиях: буферы,
соединения, ссылки на большие структуры. Поэтому одна забытая горутина в горячем
коде за сутки может «съесть» заметную долю памяти процесса.
Покажем саму механику блокировки на маленьком запускаемом примере. Тут утечки
нет — наоборот, мы видим, как горутина корректно разблокируется, когда появляется
читатель. Если бы читателя не было, отправитель завис бы навсегда.
package mainimport ( "fmt" "sync")func main() { ch := make(chan int) // небуферизованный: отправка ждёт приёма var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() ch <- 42 // повиснет здесь, пока кто-то не прочитает fmt.Println("отправитель разблокировался") }() fmt.Println("получили:", <-ch) // вот этот приём и спасает горутину wg.Wait()}
Отправитель без получателя. Самый частый случай. Получатель ушёл по таймауту
или по отмене, а горутина вечно висит на ch <- v. Классика — обёртка с
select:
func leaky(ctx context.Context) int { ch := make(chan int) // небуферизованный go func() { ch <- slow() }() // если сработает ctx.Done — горутина зависнет select { case v := <-ch: return v case <-ctx.Done(): return -1 // ушли, а горутина осталась на ch <- v навсегда }}
Тут засада именно в небуферизованном канале: после возврата по ctx.Done()
читателя у ch больше нет, и горутина с ch <- slow() зависает навсегда.
for range по незакрытому каналу.range по каналу ждёт его закрытия. Если
канал закрыть забыли, горутина-потребитель будет ждать вечно.
Забытый ticker/timer.time.Tick без остановки или горутина, висящая на
<-ticker.C уже после того, как тикер никому не нужен.
Воркер без сигнала остановки. Пул воркеров, у которого нет ни закрытия
входного канала, ни ctx — после основной работы воркеры остаются висеть на
пустом канале.
Правило-вакцина из главы про горутины: на каждый go должен
быть явный ответ на вопрос, кто и когда его остановит. Обычно ответ — закрытие
входного канала или ctx.Done().
Два рабочих приёма. Первый — буфер на 1: отправитель кладёт значение в буфер и
сразу уходит, даже если читателя уже нет. Второй — передать горутине ctx/done,
чтобы она могла отказаться от отправки через select.
Сравним оба подхода в запускаемом примере. Имитируем отмену так, что горутина не
зависает ни в одном из вариантов.
package mainimport ( "context" "fmt")func withBuffer(canceled bool) { ch := make(chan int, 1) // буфер 1: отправка не заблокируется go func() { ch <- 7 }() // уйдёт в любом случае if canceled { fmt.Println("буфер: ушли по отмене, горутина не зависла") return } fmt.Println("буфер: получили", <-ch)}func withCtx(ctx context.Context) { ch := make(chan int) go func() { select { case ch <- 9: // отправили, если есть читатель case <-ctx.Done(): // или отказались от отправки } }() select { case v := <-ch: fmt.Println("ctx: получили", v) case <-ctx.Done(): fmt.Println("ctx: отмена, горутина вышла через ctx.Done") }}func main() { withBuffer(false) withBuffer(true) ctx, cancel := context.WithCancel(context.Background()) cancel() // сразу отменяем withCtx(ctx)}
Ключевая идея варианта с select: у горутины-отправителя появляется второй выход.
Если читатель ушёл, срабатывает <-ctx.Done(), и горутина спокойно завершается
вместо вечного зависания на ch <- 9.
runtime.NumGoroutine() — счётчик живых горутин. В тесте логика простая:
замерить число до операции и после её полного завершения; если выросло — что-то
осталось висеть.
func TestNoLeak(t *testing.T) { before := runtime.NumGoroutine() doWork() time.Sleep(10 * time.Millisecond) // дать горутинам шанс завершиться if after := runtime.NumGoroutine(); after > before { t.Fatalf("утечка: %d -> %d", before, after) }}
Минус ручного подхода — хрупкость: time.Sleep подобран на глаз, а другие тесты
могут оставить свои горутины. Поэтому на практике берут библиотеку.
go.uber.org/goleak автоматизирует ровно эту проверку. Вызов
goleak.VerifyNone(t) в конце теста (или goleak.VerifyTestMain(m) на весь
пакет) уронит тест, если остались лишние горутины, и распечатает их стеки — сразу
видно, где именно зависли. Это де-факто индустриальный стандарт проверки на
утечки.
pprof goroutine profile — инструмент для прода. Подключаем
import _ "net/http/pprof", затем смотрим
go tool pprof http://host/debug/pprof/goroutine. Главная улика утечки —
растущее со временем число горутин с одинаковым стеком: значит, что-то
запускается и не завершается. Эндпойнт /debug/pprof/goroutine?debug=2 отдаёт
полные стеки всех горутин и показывает, как давно каждая заблокирована, — по
строчке стека вы найдёте точное место зависания.
Data race (подробнее — в главе про модель памяти) — это
конкурентный доступ к одной ячейке памяти без синхронизации, где хотя бы один
доступ является записью. Результат по спецификации — неопределённое поведение:
может «работать», может выдать порванное значение, может уронить программу.
Важное предупреждение: пример ниже нельзя запускать в браузерном плейграунде.
Во-первых, здесь нет настоящего параллелизма (всё исполняется в одном потоке), а
во-вторых, в браузере нет race detector. Поэтому это статичный блок —
посмотрите на него и разберите глазами, но воспроизводить «вживую» бессмысленно:
он печатает не то, что покажет реальный многопоточный прогон с -race.
var counter intfunc main() { var wg sync.WaitGroup for i := 0; i < 2; i++ { wg.Add(1) go func() { defer wg.Done() counter++ // конкурентная запись без синхронизации — data race }() } wg.Wait() fmt.Println(counter)}
counter++ — это «прочитать, прибавить, записать»: три операции, не атомарные.
Две горутины могут прочитать одно и то же старое значение и затереть друг друга.
На практике двух инкрементов мало, чтобы поймать потерю глазами — окно гонки на
counter++ слишком узкое, и почти всегда вы увидите 2. Но под -race детектор
всё равно зафиксирует нарушение happens-before, а при тысячах горутин потерянные
инкременты становятся видны и в самом выводе. Ценность здесь — диагностика
детектором, а не «неправильное число» на экране. Починка —
sync/atomic, sync.Mutex или вообще не шарить переменную, а считать через канал
(см. sync-примитивы).
Race detector — встроенный в Go инструмент, включается флагом -race:
go test -race ./...go run -race main.gogo build -race
Под -race рантайм инструментирует каждое обращение к памяти и отслеживает
отношение happens-before между горутинами. Если он находит два доступа без
happens-before, где хотя бы один — запись, то печатает оба стека: где прочитали,
где записали и какие горутины замешаны. Именно так грейдер тренажёра проверяет
решения — прогоняет go test -race.
Ограничения детектора, которые важно понимать:
Он динамический: ловит только те гонки, что реально произошли на этом
конкретном прогоне. Код, который не выполнился, не проверен. Поэтому -race
гоняют на тестах с хорошим покрытием и под нагрузкой — иначе гонка просто не
«выстрелит».
Он дорогой: замедляет код примерно в 2–20 раз и увеличивает расход памяти
примерно в 5–10 раз. Отсюда повышенные лимиты в песочнице грейдера. В прод с -race не
катят — это инструмент CI и стейджинга.
Он ловит только data race, но не race condition (логические гонки).
Чистый прогон под -race не доказывает, что логика верна, — только что нет
неконтролируемого доступа к памяти.
Deadlock. Две горутины ждут друг друга крест-накрест: A держит mu1 и хочет
mu2, B держит mu2 и хочет mu1. Рантайм Go умеет ловить глобальный
deadlock — когда заблокированы вообще все горутины: тогда падает
fatal error: all goroutines are asleep - deadlock!. А вот частичный deadlock
(часть горутин висит, но программа в целом жива) рантайм не заметит — и это
превращается в утечку. Профилактика — всегда брать мьютексы в одном и том же
порядке.
Забытый Unlock при раннем return или панике оставляет мьютекс захваченным
навсегда, и все следующие желающие его взять зависают. Лечится привычкой писать
defer mu.Unlock() сразу после mu.Lock() (см.
sync-примитивы).
Небуферизованный канал в обёртке с таймаутом/отменой. После возврата по
ctx.Done() отправитель зависает навсегда. Лечится буфером 1 или select с
ctx.Done() в самой горутине.
range по каналу, который никто не закроет. Потребитель ждёт закрытия
вечно. У канала должен быть один владелец, который его закрывает, — и это
отправитель, не получатель.
Вера, что чистый -race означает корректность. Детектор не выполнял
непокрытые пути и не видит логических гонок. Зелёный -race — необходимое, но
не достаточное условие.
Забытый Unlock или разный порядок взятия мьютексов. Первое вешает всех на
мьютексе, второе даёт deadlock. defer Unlock и единый порядок захвата
закрывают оба.
Утечка — это горутина без выхода; data race — это доступ без happens-before. Обе
невидимы на «счастливом пути» и всплывают под нагрузкой или на другой архитектуре,
поэтому полагаться надо на инструменты (-race, goleak, pprof), а не на
вычитку: человек систематически переоценивает свою способность заметить гонку
глазами.
Это прямая тема топика 6 (21–22): увидеть data race, объяснить его через
happens-before и исправить так, чтобы -race молчал. Она же пронизывает топик 4
(13–15) — проверку, что после отмены context горутины
действительно завершились. Грейдер тренажёра прогоняет все решения через
go test -race, поэтому «работает, но течёт» и «работает, но гонка» не пройдут —
эта глава про то, как писать так, чтобы проходило.
Дальше стоит закрепить смежные темы: модель памяти —
почему happens-before вообще существует; sync-примитивы —
чем синхронизировать; context — как корректно сигналить
горутинам об остановке.