---
title: "Утечки горутин и гонки"
description: "Сервис неделю работает без перезапуска, и график памяти медленно ползёт вверх. В профиле — сорок тысяч горутин, застывших на одной и той же строке. Каждая когда-то запустилась…"
canonical: https://graphlms.ru/go/book/leaks-and-races
course: "Конкурентность Go"
updated: 2026-10-12
lang: ru
publisher: GraphLMS
---

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

> Источник: https://graphlms.ru/go/book/leaks-and-races — курс «Конкурентность Go», GraphLMS.

## О чём глава

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

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

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

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

Нужны правила ожидания и закрытия каналов, `Mutex`, context и
[happens-before](/go/book/memory-model). Для локальных проверок понадобятся `go.mod`,
файлы `_test.go` и команда `go test` из [базового курса](/go-basics/book/packages-modules).

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

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

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

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

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

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

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

```go
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/book/goroutines): у каждого `go` должен быть
явный ответ, кто и когда эту горутину остановит. Обычно ответ — закрытие входного канала
или `ctx.Done()`.

## Как чинить утечку отправителя

Есть два рабочих приёма. Первый — **буфер 1**: единственное значение ляжет в буфер, и
горутина завершится, даже если получателя уже нет. Второй — дать горутине `ctx` или
`done`, чтобы она могла **отказаться** от отправки через [select](/go/book/select).

```go play
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)
}
```

```text
буфер: получили 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:

```go
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` падает и
показывает, где застряла горутина:

```text
--- 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()` до и после операции:

```go
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)`
на весь пакет роняет тест, если остались лишние горутины, и печатает их стеки:

```go
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

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

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

```go
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`
показывает ошибку независимо от того, совпало число или нет:

```text
$ 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](/go/book/sync-primitives)).

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

```bash
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](/go/book/sync-primitives). Временно уберите
мьютекс из `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`, профиль горутин.

Последняя глава, [«Планировщик глубже»](/go/book/scheduler-deep), объясняет, как рантайм
распределяет горутины по потокам, и её можно читать в любой момент после первых задач.

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