vet молчит, -race молчит: горутины, которые переживают CI
- пятница, 25 сентября 2026 г. в 00:00:12
В прошлой статье я писал, что компилятор — первый ревьюер кода, который написал агент. У этой идеи есть слепое пятно, и я в него наступил: конкурентность. Компилятор проверяет типы, go vet проверяет десяток известных шаблонов, -race ловит гонки данных. А горутина, которая никогда не завершится, — это не ошибка типов и не гонка. Код, который не слышит отмену, — тоже.
Такой код собирается, проходит vet и go test -race, под нагрузкой тоже живёт. Вылезает проблема позже: на graceful shutdown, когда клиент отвалился по таймауту или когда тормозит реплика. На ревью при этом всё чисто: select на месте, defer cancel() на месте.
Дальше — четыре таких бага, чем их ловить и анализатор строк на сто, который две из них превращает в красный CI. Все примеры запускаются, вывод команд скопирован из терминала.
Сначала коротко о том, что они вообще проверяют:
go vet знает lostcancel — ловит cancel, который не вызвали ни на одном пути. Больше про контексты он ничего не проверяет.
-race ловит одновременный доступ к памяти без синхронизации. Горутина, заблокированная на канале навсегда, ничего не читает и не пишет — для детектора гонок её нет.
Тесты обычно проверяют результат функции. Результат у всех примеров ниже правильный. Неправильно то, что происходит после возврата.
Вот вывод прогона по пакету с четырьмя багами:
$ go vet ./leak/ $ go test -race ./leak/ ok example.com/ctxbugs/leak 1.993s
Пусто и зелёно.
Классика: опросить несколько зеркал и вернуть самый быстрый ответ.
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() даёт медленным запросам шанс прерваться, а не доделывать ненужную работу.
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 } } } }
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, почти всегда означает, что контекст потеряли случайно.
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 внутри.
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 это значит: тест должен отменить контекст и не закрыть канал. Такие тесты кто-то должен написать.
Ошибка 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 и без повторных проверок с задержкой.
Тесты ловят то, до чего дошли. Анализатор смотрит на весь код, включая пути, до которых тесты не доходят. Ошибки 2 и 3 имеют синтаксическую форму, которую легко проверить:
В функции, которая получает context.Context, вызван context.Background() или context.TODO().
В такой функции есть блокирующий 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, анализатор его не видит.
ctxcheck запускается рядом с go vet через go vet -vettool=$(which ctxcheck) ./... или как плагин golangci-lint. Ошибка анализатора — красный CI, агент видит сообщение и правит.
goleak.VerifyTestMain включён во всех пакетах, где есть горутины. Новые пакеты получают его из шаблона.
Для кода с таймаутами и ретраями — тесты на synctest. Это единственное место, где нужен человек: агент охотно пишет такие тесты, но проверять, что тест действительно отменяет контекст на нужном шаге, приходится глазами.
Компилятор, vet и race-детектор смотрят на то, что происходит, пока функция работает. Все четыре бага выше проявляются после того, как она вернула правильный ответ: горутины висят, соседние запросы крутятся, трейс оборван. Поэтому и проверять приходится то, что осталось после: goleak смотрит на горутины, synctest — на время, анализатор — на код, до которого тесты не дошли.
Если у вас есть свои «зелёные, но висящие» шаблоны — пишите в комментариях, добавлю в анализатор. Новые правила и находки, которые не тянут на отдельную статью, коротко выкладываю в телеграм-канале.