Все главы учебника
Содержание учебника

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

15 мин чтенияКонтент v0.12.1

О чём глава

Сервис неделю работает без перезапуска, и график памяти медленно ползёт вверх. В профиле — сорок тысяч горутин, застывших на одной и той же строке. Каждая когда-то запустилась, чтобы выполнить запрос с таймаутом, запрос ушёл по таймауту, а горутина осталась ждать. Это утечка горутин.

Другой сервис раз в несколько дней падает с непонятной паникой внутри map. Две горутины без синхронизации писали в одну карту. Это гонка данных (data race).

Обе ошибки не видны на «счастливом пути»: код проходит ручную проверку, а проблема проявляется под нагрузкой, на другой машине или раз в тысячу запусков. Поэтому ловят их инструментами: race detector, тестами на утечки, профилем горутин. Глава о том, как эти ошибки выглядят, откуда берутся и чем их находить.

Перед чтением

Нужны правила ожидания и закрытия каналов, Mutex, context и happens-before. Для локальных проверок понадобятся go.mod, файлы _test.go и команда go test из базового курса.

Метод поиска утечки один и тот же: найти место, где горутина может ждать, и назвать событие, которое её разбудит. Если такого события может не случиться, это утечка.

Стек горутины в отчётах — список вызовов, по которым горутина пришла в текущую точку, от самой глубокой функции к той, что её запустила. В стеке видно и место ожидания: например, [chan send] означает, что горутина ждёт отправки в канал.

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

Горутина утекла, если она ждёт события, которое никогда не наступит: отправки в канал, который никто не читает; приёма из канала, в который никто не пишет и который никто не закроет; мьютекса, который никто не отпустит; select, ни одна ветка которого не станет готовой.

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

Откуда берутся утечки

Отправитель без получателя. Самый частый случай. Получатель ушёл по таймауту или отмене, а горутина ждёт на ch <- v:

func leaky(ctx context.Context) int {
	ch := make(chan int)            // небуферизованный
	go func() { ch <- slow() }()    // после ухода получателя зависнет здесь
	select {
	case v := <-ch:
		return v
	case <-ctx.Done():
		return -1                   // ушли, горутина осталась ждать отправки
	}
}

Пока slow() работает, всё в порядке. Но если сначала сработал ctx.Done(), функция вернулась, и принимать из ch больше некому. Когда slow() закончится, горутина попытается отправить в небуферизованный канал и будет ждать вечно.

for range по каналу, который не закрыли. range выходит только после закрытия канала. Если отправитель забыл close, потребитель ждёт вечно.

Ожидание таймера или тикера без выхода. ticker.Stop() не закрывает ticker.C. Горутина, которая крутится в for range ticker.C, после Stop просто перестанет получать тики и останется ждать. Ей нужен отдельный сигнал выхода через select.

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

Профилактика — вопрос из первой главы: у каждого 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)
}
буфер: получили 7
буфер: ушли, отправитель всё равно завершится
ctx: отмена, отправитель выйдет через ctx.Done

Последняя строка не зависит от планирования. Контекст отменён заранее, и обе стороны видят уже закрытый ctx.Done(). Ветка с ch готова, только если на другой стороне кто-то ждёт, а ждать никому не приходится: и отправитель, и получатель сразу выбирают ctx.Done().

Буфер 1 подходит, когда результат ровно один. Если горутина отправляет поток значений, буфер лишь откладывает зависание, и нужен select с отменой на каждой отправке.

Как находить утечки

Тест с testing/synctest

С Go 1.25 в стандартной библиотеке есть пакет testing/synctest. Функция synctest.Test запускает тест в изолированном «пузыре»: все горутины, запущенные внутри, принадлежат ему, а время в пузыре фиктивное и идёт вперёд мгновенно, когда все горутины ждут. Если к концу теста в пузыре остались горутины, которые ждут и которых некому разбудить, тест падает. Для поиска утечек это ровно то, что нужно, а для задач с таймерами — способ проверить «прошло 500 мс» без реального ожидания.

Проверим leaky и исправленный вариант fixed (тот же код, но make(chan int, 1)). Пусть slow спит секунду и возвращает 42:

func check(t *testing.T, f func(context.Context) int) {
	synctest.Test(t, func(t *testing.T) {
		ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
		defer cancel()
		if got := f(ctx); got != -1 {
			t.Fatalf("got %d, want -1", got)
		}
		// Даём фоновой работе закончиться. Часы в пузыре фиктивные:
		// две «секунды» проходят мгновенно.
		time.Sleep(2 * time.Second)
	})
}
 
func TestLeaky(t *testing.T) { check(t, leaky) }
func TestFixed(t *testing.T) { check(t, fixed) }

TestFixed проходит за 0,00 с, хотя внутри «прошло» две секунды. TestLeaky падает и показывает, где застряла горутина:

--- FAIL: TestLeaky (0.00s)
panic: deadlock: main bubble goroutine has exited but blocked goroutines remain [recovered, repanicked]
...
goroutine 24 [chan send (durable), synctest bubble 1]:
demo/leak.leaky.func1()
	/tmp/demo/leak/leak.go:16 +0x40

[chan send (durable)] — горутина ждёт отправки, и разбудить её внутри пузыря некому. Ограничение: пузырь отслеживает только ожидания на каналах, созданных внутри него, sync.Cond, WaitGroup и time.Sleep. Ожидание мьютекса или сетевого ввода-вывода «вечным» не считается, такие зависания этим способом не поймать.

Счётчик горутин и goleak

Самый простой ручной способ — сравнить 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)
	}
}

Он хрупкий: пауза подобрана на глаз, а другие тесты пакета могут оставить свои горутины. Поэтому до появления synctest стандартом де-факто была библиотека go.uber.org/goleak. goleak.VerifyNone(t) в конце теста или goleak.VerifyTestMain(m) на весь пакет роняет тест, если остались лишние горутины, и печатает их стеки:

func TestMain(m *testing.M) {
	goleak.VerifyTestMain(m)
}

Профиль горутин в работающем сервисе

В рабочем сервисе подключают import _ "net/http/pprof" и смотрят профиль: go tool pprof http://host/debug/pprof/goroutine. Главный признак утечки — число горутин с одинаковым стеком, которое растёт со временем. Адрес /debug/pprof/goroutine?debug=2 отдаёт полные стеки всех горутин текстом; у горутин, которые ждут дольше минуты, рядом с причиной ожидания указано время, например [chan send, 42 minutes]. По строке стека находится точное место зависания.

Гонка данных и race detector

Гонка данных (подробно — в главе о модели памяти) — обращения двух горутин к одной переменной, хотя бы одно из которых запись, не упорядоченные happens-before. Для значений больше машинного слова (строки, срезы, интерфейсы) гонка может дать «разорванное» значение и повреждение памяти.

Две горутины увеличивают общий счётчик:

package main
 
import (
	"fmt"
	"sync"
)
 
func main() {
	var counter int
	var wg sync.WaitGroup
	for i := 0; i < 2; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter++ // чтение и запись без синхронизации
		}()
	}
	wg.Wait()
	fmt.Println(counter)
}

counter++ — это «прочитать, прибавить, записать». Две горутины могут прочитать одно и то же старое значение и затереть результат друг друга. С двумя увеличениями окно для этого крошечное, и программа почти всегда печатает 2. Но запуск с флагом -race показывает ошибку независимо от того, совпало число или нет:

$ go run -race main.go
==================
WARNING: DATA RACE
Read at 0x00c000114038 by goroutine 8:
  main.main.func1()
      /tmp/demo/main.go:15 +0x68
 
Previous write at 0x00c000114038 by goroutine 7:
  main.main.func1()
      /tmp/demo/main.go:15 +0x78
 
Goroutine 8 (running) created at:
  main.main()
      /tmp/demo/main.go:13 +0x6c
 
Goroutine 7 (finished) created at:
  main.main()
      /tmp/demo/main.go:13 +0x6c
==================
2
Found 1 data race(s)
exit status 66

Читается отчёт так. Первый блок — какое обращение и где: чтение по адресу 0x...038 в горутине 8, строка 15 (counter++). Второй — с каким обращением оно конфликтует: запись по тому же адресу в горутине 7, та же строка. Последние два блока — где эти горутины были запущены (строка 13, go func()). Обратите внимание: программа напечатала правильное 2, а детектор всё равно нашёл гонку. Правильное число не доказывает отсутствие ошибки. Код выхода 66 означает, что детектор сработал; в go test такой тест помечается как упавший.

Исправления: sync/atomic, sync.Mutex или вовсе не делить переменную, а считать в одной горутине и присылать ей значения через канал (см. sync).

Race detector включается флагом -race:

go test -race ./...
go run -race main.go
go build -race

С этим флагом компилятор добавляет проверку к каждому обращению к памяти, а рантайм отслеживает happens-before между горутинами. Если два обращения к одному адресу не упорядочены и хотя бы одно — запись, печатается отчёт, как выше. Грейдер тренажёра запускает решения именно через go test -race.

Ограничения детектора:

  • Он проверяет только то, что выполнилось. Код, который не выполнялся в этом запуске, не проверен. Поэтому -race запускают на тестах с хорошим покрытием и с реальной конкуренцией, а -count=20 повышает шанс поймать редкое перемешивание.
  • Он дорогой. По документации Go память вырастает в 5–10 раз, время выполнения — в 2–20 раз. В рабочую сборку -race не включают; это инструмент для тестов и CI.
  • Он видит только гонки данных. Логические гонки, вроде «проверил, потом создал» из главы о модели памяти, он не находит.
  • Он работает не везде. Нужны поддерживаемые ОС и архитектура: Linux и macOS на amd64 и arm64, Windows на amd64 и ещё несколько платформ; на части из них нужен включённый cgo. Список — в документации к флагу -race.

Ошибки с блокировками

Взаимная блокировка (deadlock). Горутина A держит mu1 и ждёт mu2, горутина B держит mu2 и ждёт mu1. Ни одна не продвинется. Если в такой ситуации оказались все горутины программы, рантайм завершит её с fatal error: all goroutines are asleep - deadlock!. Но если где-то жива хотя бы одна горутина (HTTP-сервер, тикер), рантайм ничего не заметит: две горутины просто тихо зависнут, и это та же утечка. Профилактика — захватывать мьютексы всегда в одном и том же порядке.

Забытый Unlock. Ранний return или паника между Lock и Unlock оставляют мьютекс занятым навсегда, и все следующие желающие его захватить ждут. Лечится привычкой писать defer mu.Unlock() сразу после mu.Lock().

Частые ошибки

  • Небуферизованный канал результата в функции с таймаутом. После ухода по ctx.Done() отправитель висит вечно. Буфер 1 или select с ctx.Done() в самой горутине.
  • range по каналу, который никто не закроет. У канала должен быть владелец закрытия, и это отправитель.
  • Вера в зелёный -race. Он не проверял невыполненные пути и не видит логических гонок.
  • time.Sleep как лекарство. Пауза меняет вероятность того, что гонка проявится, но не добавляет happens-before. Гонка остаётся.
  • Разный порядок захвата мьютексов. Рано или поздно — взаимная блокировка.

Попробуйте сами

Возьмите тест Counter из главы sync. Временно уберите мьютекс из Inc, оставив ожидание через WaitGroup, и выполните go test -race -count=1 .. Найдите в отчёте два конфликтующих обращения. Верните мьютекс и повторите. Если бы первый запуск без -race напечатал 1000, доказывало бы это отсутствие гонки?

Затем проверьте завершение после отмены: оберните функцию leaky из этой главы в тест с synctest.Test и убедитесь, что он падает, а с буфером 1 — проходит.

Проверка

Отчёт указывает на чтение и запись c.n в строке c.n++ из разных горутин. Число 1000 могло получиться случайно: совпадение не добавляет happens-before между увеличениями. После возврата мьютекса отчёта нет.

Для теста на утечку в конце пузыря должно пройти достаточно фиктивного времени, чтобы slow() закончилась (иначе упадут оба варианта: горутина будет ждать в Sleep после выхода из теста). С небуферизованным каналом тест падает с [chan send (durable)], с буфером 1 проходит.

При чтении любого отчёта -race сначала найдите файл и строку двух обращений, затем места запуска горутин. Исправление должно связать эти два обращения синхронизацией; Sleep между ними — не исправление.

Что дальше

Утечка — это горутина без выхода, гонка данных — обращение без happens-before. Обе незаметны на «счастливом пути», поэтому полагаться приходится на инструменты: -race, synctest, goleak, профиль горутин.

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

В задачах курса эта глава — блок 6 (задачи 21–22: найти гонку и утечку в чужом коде и исправить так, чтобы -race молчал). Она нужна и в блоке 4 (13–15): после отмены контекста все горутины должны завершиться. Грейдер прогоняет все решения через go test -race, поэтому «работает, но течёт» и «работает, но гонка» не проходят.

Продолжить

Остались вопросы?