golang

vet молчит, -race молчит: горутины, которые переживают CI

  • пятница, 25 сентября 2026 г. в 00:00:12
https://habr.com/ru/articles/1085788/

В прошлой статье я писал, что компилятор — первый ревьюер кода, который написал агент. У этой идеи есть слепое пятно, и я в него наступил: конкурентность. Компилятор проверяет типы, go vet проверяет десяток известных шаблонов, -race ловит гонки данных. А горутина, которая никогда не завершится, — это не ошибка типов и не гонка. Код, который не слышит отмену, — тоже.

Такой код собирается, проходит vet и go test -race, под нагрузкой тоже живёт. Вылезает проблема позже: на graceful shutdown, когда клиент отвалился по таймауту или когда тормозит реплика. На ревью при этом всё чисто: select на месте, defer cancel() на месте.

Дальше — четыре таких бага, чем их ловить и анализатор строк на сто, который две из них превращает в красный CI. Все примеры запускаются, вывод команд скопирован из терминала.

Почему vet и -race этого не видят

Сначала коротко о том, что они вообще проверяют:

  • go vet знает lostcancel — ловит cancel, который не вызвали ни на одном пути. Больше про контексты он ничего не проверяет.

  • -race ловит одновременный доступ к памяти без синхронизации. Горутина, заблокированная на канале навсегда, ничего не читает и не пишет — для детектора гонок её нет.

  • Тесты обычно проверяют результат функции. Результат у всех примеров ниже правильный. Неправильно то, что происходит после возврата.

Вот вывод прогона по пакету с четырьмя багами:

$ go vet ./leak/
$ go test -race ./leak/
ok  	example.com/ctxbugs/leak	1.993s

Пусто и зелёно.

Ошибка 1. Первый ответ победил, остальные повисли

Классика: опросить несколько зеркал и вернуть самый быстрый ответ.

func FirstBad(ctx context.Context, f Fetcher, urls []string) (Result, error) {
	ch := make(chan Result)
	for _, u := range urls {
		go func() {
			r, err := f(ctx, u)
			if err == nil {
				ch <- r
			}
		}()
	}
	select {
	case r := <-ch:
		return r, nil
	case <-ctx.Done():
		return Result{}, ctx.Err()
	}
}

Функция вернула результат первой горутины и ушла. Остальные доходят до ch <- r, а читать из канала больше некому. Канал небуферизованный — они висят до конца жизни процесса. На сервисе, который делает так на каждый запрос, это медленная утечка памяти и горутин, которую видно только на графике go_goroutines через неделю.

Обиднее всего, что ctx.Done() в select есть. Об отмене автор подумал — но только со своей стороны.

Исправление в две строки: буфер на число отправителей и отмена проигравших.

func FirstGood(ctx context.Context, f Fetcher, urls []string) (Result, error) {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()
	ch := make(chan Result, len(urls))
	// ... дальше то же самое
}

Буфер гарантирует, что отправка не заблокируется. cancel() даёт медленным запросам шанс прерваться, а не доделывать ненужную работу.

Ошибка 2. select, который не слышит отмену

func ConsumeBad(ctx context.Context, jobs <-chan int, out chan<- int) {
	for {
		select {
		case j, ok := <-jobs:
			if !ok {
				return
			}
			out <- j * 2
		}
	}
}

ctx в сигнатуре есть, но не используется. Воркер завершится, только если продюсер закроет jobs. На пути shutdown продюсер часто сам выходит по отмене и канал не закрывает — и воркер ждёт вечно.

Ещё настораживает select с одной веткой: это просто <-jobs, переодетый в select. Руками так почти не пишут, а агент вставляет select охотно — видимо, потому что конкурентный код «так выглядит».

Правильная версия слушает ctx.Done() дважды: при чтении и при записи, потому что out тоже может никогда не освободиться.

func ConsumeGood(ctx context.Context, jobs <-chan int, out chan<- int) {
	for {
		select {
		case <-ctx.Done():
			return
		case j, ok := <-jobs:
			if !ok {
				return
			}
			select {
			case out <- j * 2:
			case <-ctx.Done():
				return
			}
		}
	}
}

Ошибка 3. Потерянный контекст

func SaveBad(ctx context.Context, save func(context.Context) error) error {
	// запрос могут отменить, но запись должна доехать
	ctx2, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	return save(ctx2)
}

Намерение разумное: клиент отвалился, а запись в базу всё равно нужно завершить. Но вместе с отменой потерялось всё, что лежало в ctx: trace id, данные аутентификации, request id для логов. В трейсинге запись в базу превращается в сироту без родительского спана.

С Go 1.21 для этого есть отдельная функция:

ctx2, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)

Значения сохраняются, отмена отвязывается, и намерение видно в коде. context.Background() внутри функции, которая уже получила ctx, почти всегда означает, что контекст потеряли случайно.

Ошибка 4. errgroup с чужим контекстом

func AllBad(ctx context.Context, f Fetcher, urls []string) error {
	g, _ := errgroup.WithContext(ctx)
	for _, u := range urls {
		g.Go(func() error {
			_, err := f(ctx, u) // внешний ctx, а не контекст группы
			return err
		})
	}
	return g.Wait()
}

Весь смысл errgroup.WithContext в том, что первая ошибка отменяет остальные задачи. Здесь контекст группы выброшен в _, и задачи получают внешний ctx. Одна задача упала сразу, а Wait() ждёт, пока доработают все остальные. Результат правильный — ошибка вернулась. Неправильно время: вместо мгновенного ответа клиент ждёт самый медленный запрос.

Особенно легко это пропустить, когда параметр называется ctx, а переменная группы — тоже ctx в соседней функции. Исправление — g, gctx := errgroup.WithContext(ctx) и gctx внутри.

goleak: кто остался после теста

go.uber.org/goleak сравнивает список горутин до и после теста. Для первой ошибки:

func TestFirst(t *testing.T) {
	before := goleak.IgnoreCurrent()
	_, err := FirstBad(context.Background(), fastSlow, []string{"fast", "slow1", "slow2"})
	if err != nil {
		t.Fatal(err)
	}
	if err := goleak.Find(before); err != nil {
		t.Fatal(err) // сработает: две горутины висят на ch <- r
	}
}

Проще всего включить его для пакета целиком:

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

После этого любой тест в пакете, который оставил горутину, роняет весь пакет. На старом коде это больно: первый прогон почти наверняка покраснеет. Я включал по одному пакету и добавлял goleak.IgnoreTopFunction для известных фоновых горутин сторонних библиотек.

goleak ловит ошибки 1 и 2, если тест вообще доводит код до нужного пути. Для ошибки 2 это значит: тест должен отменить контекст и не закрыть канал. Такие тесты кто-то должен написать.

testing/synctest: время, которого нет

Ошибка 4 не течёт — горутины в итоге завершаются. Она просто медленная. Проверять это через реальный time.Sleep в тесте — путь к флакающему CI.

В Go 1.25 пакет testing/synctest стал стабильным (в 1.24 он был экспериментом за GOEXPERIMENT=synctest). Внутри «пузыря» время фиктивное: оно идёт вперёд мгновенно, когда все горутины пузыря заблокированы. Поэтому можно точно измерить, сколько функция «ждала»:

func TestAllCancelsSiblings(t *testing.T) {
	f := func(ctx context.Context, url string) (Result, error) {
		if url == "broken" {
			return Result{}, ErrBoom
		}
		select {
		case <-time.After(10 * time.Second):
			return Result{Body: url}, nil
		case <-ctx.Done():
			return Result{}, ctx.Err()
		}
	}
	synctest.Test(t, func(t *testing.T) {
		start := time.Now()
		_ = AllBad(context.Background(), f, []string{"a", "broken", "b"})
		if el := time.Since(start); el != 0 {
			t.Fatalf("ждали соседей %v после первой ошибки", el)
		}
	})
}

Результат прогона:

synctest_test.go:28: good: waited 0s of fake time
synctest_test.go:28: bad: waited 10s of fake time
--- PASS: TestAllCancelsSiblings (0.00s)

Десять секунд фиктивного времени за ноль реальных. Тест детерминированный: 0s против ровно 10s, без допусков и Eventually.

И ещё: если к концу теста в пузыре остались заблокированные горутины, synctest падает с deadlock. Так что часть утечек он находит сам, без goleak и без повторных проверок с задержкой.

Свой анализатор: две ошибки из четырёх — в CI

Тесты ловят то, до чего дошли. Анализатор смотрит на весь код, включая пути, до которых тесты не доходят. Ошибки 2 и 3 имеют синтаксическую форму, которую легко проверить:

  1. В функции, которая получает context.Context, вызван context.Background() или context.TODO().

  2. В такой функции есть блокирующий select (без default), в котором нет ветки <-x.Done(), где x — контекст.

Ядро анализатора на golang.org/x/tools/go/analysis:

func run(pass *analysis.Pass) (any, error) {
	ins := pass.ResultOf[inspect.Analyzer].(*inspector.Inspector)
	ins.Preorder([]ast.Node{(*ast.FuncDecl)(nil)}, func(n ast.Node) {
		fn := n.(*ast.FuncDecl)
		if fn.Body == nil || !hasCtxParam(pass, fn.Type) {
			return
		}
		ast.Inspect(fn.Body, func(n ast.Node) bool {
			switch n := n.(type) {
			case *ast.CallExpr:
				f, ok := typeutil.Callee(pass.TypesInfo, n).(*types.Func)
				if ok && f.Pkg() != nil && f.Pkg().Path() == "context" &&
					(f.Name() == "Background" || f.Name() == "TODO") {
					pass.Reportf(n.Pos(), "context.%s() in %s, which already receives a ctx: "+
						"use ctx, or context.WithoutCancel(ctx) if detaching is intended",
						f.Name(), fn.Name.Name)
				}
			case *ast.SelectStmt:
				if blocking(n) && !hasDoneCase(pass, n) {
					pass.Reportf(n.Pos(), "select in %s has no case on ctx.Done() and no default: "+
						"it will not react to cancellation", fn.Name.Name)
				}
			}
			return true
		})
	})
	return nil, nil
}

hasDoneCase проверяет по типам, а не по имени переменной: <-cctx.Done() для производного контекста засчитывается, а <-done для обычного канала chan struct{} — нет. Полная версия — с вспомогательными функциями hasCtxParam, blocking, hasDoneCase и тестами на analysistest — занимает около ста строк.

Отдельно про текст сообщения. В прошлой статье я писал, что формулировка ошибки влияет на исход правки сильнее, чем промпт. Здесь то же самое: сообщение называет не только проблему, но и правильную замену (context.WithoutCancel(ctx)). Без подсказки легко получить «исправление», которое заменяет Background() на ctx и молча ломает намерение дописать запись после отмены.

Прогон по тому же пакету, на котором vet и race молчали:

$ ctxcheck ./leak/
leak/leak.go:65:3: select in ConsumeBad has no case on ctx.Done() and no default: it will not react to cancellation
leak/leak.go:97:38: context.Background() in SaveBad, which already receives a ctx: use ctx, or context.WithoutCancel(ctx) if detaching is intended

Где анализатор ошибается

Если об этом промолчать, его отключат через неделю, так что сразу:

  • Ложные срабатывания. select внутри функции с ctx, который ждёт канал, гарантированно закрываемый в той же функции (например, done от собственной горутины). Отмена там не нужна. Для таких мест — //nolint:ctxcheck с обязательным комментарием почему.

  • main и тесты. context.Background() в main и в TestXxx законен — там у функции нет входящего ctx, анализатор их не трогает. Но хелпер теста вида func setup(t *testing.T, ctx context.Context) с Background() внутри будет помечен.

  • Пропуски. Ошибки 1 и 4 анализатор не видит. Небуферизованный канал плюс ранний возврат — это свойство потока управления, а не одной конструкции; ловить его статически дорого и шумно. Для них остаются goleak и synctest.

  • Межпроцедурные случаи. Если ctx передали в структуру, а select живёт в методе без параметра ctx, анализатор его не видит.

Как это встроить в пайплайн

  1. ctxcheck запускается рядом с go vet через go vet -vettool=$(which ctxcheck) ./... или как плагин golangci-lint. Ошибка анализатора — красный CI, агент видит сообщение и правит.

  2. goleak.VerifyTestMain включён во всех пакетах, где есть горутины. Новые пакеты получают его из шаблона.

  3. Для кода с таймаутами и ретраями — тесты на synctest. Это единственное место, где нужен человек: агент охотно пишет такие тесты, но проверять, что тест действительно отменяет контекст на нужном шаге, приходится глазами.

Вместо вывода

Компилятор, vet и race-детектор смотрят на то, что происходит, пока функция работает. Все четыре бага выше проявляются после того, как она вернула правильный ответ: горутины висят, соседние запросы крутятся, трейс оборван. Поэтому и проверять приходится то, что осталось после: goleak смотрит на горутины, synctest — на время, анализатор — на код, до которого тесты не дошли.

Если у вас есть свои «зелёные, но висящие» шаблоны — пишите в комментариях, добавлю в анализатор. Новые правила и находки, которые не тянут на отдельную статью, коротко выкладываю в телеграм-канале.