GraphLMS

Go
0/32 решеноНачать
Глава 09 · Основы~14 мин чтения

Утечки горутин и гонки

О чём глава

Конкурентный код в Go ломается двумя характерными способами, и оба коварны тем, что в «счастливом пути» их не видно. Первый — утечка горутины (goroutine leak): горутина запустилась и навсегда зависла, рантайм её не уберёт, а она тихо держит память и захваченные ресурсы. Второй — data race: две горутины лезут к одной ячейке памяти без синхронизации, и поведение становится неопределённым.

Объединяет их то, что глазами вы их почти не поймаете: код «вроде работает», а проблема всплывает под нагрузкой, на другой машине или раз в тысячу прогонов. Поэтому ловят их не вычиткой, а инструментами — race detector, goleak, pprof. Эта глава про то, как утечки и гонки выглядят, почему возникают и чем их обнаруживать. Сразу честно: настоящий параллелизм и -race в браузерном плейграунде недоступны, поэтому такие примеры здесь статичные, с разбором — а запускаемые примеры показывают то, что в одном потоке воспроизводится корректно (блокировки, отмена через context, починка утечки буфером).

Что такое утечка горутины

Горутина утекает, когда блокируется навсегда и никогда не продвинется: висит на отправке в канал, на приёме из канала, на мьютексе или на select, у которого ни одна ветка не сработает. С точки зрения рантайма у неё «есть незавершённая работа», поэтому сборщик мусора её не тронет — горутину нельзя собрать, пока она не вернулась из своей функции.

В отличие от утечки памяти в обычном смысле, утечка горутины утаскивает за собой и всё, что эта горутина держит в своих локальных переменных и замыканиях: буферы, соединения, ссылки на большие структуры. Поэтому одна забытая горутина в горячем коде за сутки может «съесть» заметную долю памяти процесса.

Покажем саму механику блокировки на маленьком запускаемом примере. Тут утечки нет — наоборот, мы видим, как горутина корректно разблокируется, когда появляется читатель. Если бы читателя не было, отправитель завис бы навсегда.

package main
 
import (
	"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 main
 
import (
	"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) на весь пакет) уронит тест, если остались лишние горутины, и распечатает их стеки — сразу видно, где именно зависли. Это де-факто индустриальный стандарт проверки на утечки.

func TestMain(m *testing.M) {
	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

Data race (подробнее — в главе про модель памяти) — это конкурентный доступ к одной ячейке памяти без синхронизации, где хотя бы один доступ является записью. Результат по спецификации — неопределённое поведение: может «работать», может выдать порванное значение, может уронить программу.

Важное предупреждение: пример ниже нельзя запускать в браузерном плейграунде. Во-первых, здесь нет настоящего параллелизма (всё исполняется в одном потоке), а во-вторых, в браузере нет race detector. Поэтому это статичный блок — посмотрите на него и разберите глазами, но воспроизводить «вживую» бессмысленно: он печатает не то, что покажет реальный многопоточный прогон с -race.

var counter int
 
func 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.go
go 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 — как корректно сигналить горутинам об остановке.