GitHub Actions: кэширование зависимостей и Docker layers для быстрого CI
Горутины — одна из главных суперсил Go. Лёгкие, дешёвые, запускаются за микросекунды. Но именно эта лёгкость создаёт ловушку: забытая горутина не падает с ошибкой, а тихо висит в памяти, пока сервис не начнёт OOM-килиться в продакшене. Разберём, почему горутины утекают, как это диагностировать и какие паттерны гарантируют чистое завершение.
Почему горутины утекают
Горутина живёт, пока не завершится функция, в которой она запущена. Если функция заблокирована навсегда — горутина утекла. Три классических сценария:
1. Забытый канал без читателя:
func process() {
ch := make(chan int)
go func() {
result := heavyComputation()
ch <- result // блокировка навсегда, если никто не читает
}()
// ранний return из-за ошибки — канал никто не прочитает
if err := validate(); err != nil {
return
}
val := <-ch
fmt.Println(val)
}
2. Бесконечный select без выхода:
go func() {
for {
select {
case msg := <-msgChan:
handle(msg)
// нет case <-ctx.Done() — горутина не завершится никогда
}
}
}()
3. HTTP-клиент без таймаута:
go func() {
// дефолтный http.Client без Timeout
resp, err := http.Get("https://slow-api.example.com/data")
if err != nil {
return
}
defer resp.Body.Close()
// если сервер не отвечает — горутина висит вечно
}()
Диагностика: находим утечки
Первый индикатор — мониторинг количества горутин. Добавьте метрику в Prometheus:
import "runtime"
func goroutineCount() float64 {
return float64(runtime.NumGoroutine())
}
Если график runtime.NumGoroutine() монотонно растёт — у вас утечка. Для детального анализа используйте pprof:
import _ "net/http/pprof"
// в main:
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
Затем в терминале:
go tool pprof http://localhost:6060/debug/pprof/goroutine
(pprof) top 10
(pprof) traces
Команда traces покажет стектрейсы всех горутин — сразу видно, где они заблокированы (channel send, channel receive, select, IO wait).
goleak от Uber — автотесты на утечки
Ручной мониторинг ловит проблему в проде, но лучше не допускать её до деплоя. Пакет go.uber.org/goleak проверяет, что после теста не осталось лишних горутин:
import (
"testing"
"go.uber.org/goleak"
)
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
// или для отдельного теста:
func TestProcess(t *testing.T) {
defer goleak.VerifyNone(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
result := process(ctx)
assert.NotNil(t, result)
}
Если тест оставляет висящую горутину — goleak роняет его с подробным стектрейсом. Это ловит утечки на этапе CI, до того как код попадёт в прод.
Паттерны предотвращения
context.WithCancel — универсальный стоп-сигнал:
func worker(ctx context.Context, jobs <-chan Job) {
for {
select {
case <-ctx.Done():
return // чистый выход
case job := <-jobs:
process(job)
}
}
}
// вызывающий код:
ctx, cancel := context.WithCancel(context.Background())
go worker(ctx, jobs)
// ...
cancel() // все воркеры завершатся
errgroup — запуск N горутин с гарантией завершения:
import "golang.org/x/sync/errgroup"
func fetchAll(ctx context.Context, urls []string) error {
g, ctx := errgroup.WithContext(ctx)
for _, url := range urls {
url := url
g.Go(func() error {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return processResponse(resp)
})
}
return g.Wait() // ждём все горутины
}
done-channel для legacy-кода без context:
func startWorker(done <-chan struct{}) {
go func() {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ticker.C:
doWork()
}
}
}()
}
// завершение:
done := make(chan struct{})
startWorker(done)
// ...
close(done) // сигнал всем воркерам
Реальный кейс: HTTP-клиент без таймаута
Типичная ситуация в микросервисах: сервис A вызывает сервис B через HTTP. Дефолтный http.Client не имеет таймаута — если B завис, горутина в A висит вечно. При каждом новом запросе создаётся новая горутина, и за час их накапливается тысячи.
Исправление:
var client = &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
MaxConnsPerHost: 10,
},
}
func callServiceB(ctx context.Context, path string) (*Response, error) {
req, err := http.NewRequestWithContext(ctx, "GET", "https://service-b"+path, nil)
if err != nil {
return nil, err
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("service-b call failed: %w", err)
}
defer resp.Body.Close()
// ...
}
Три уровня защиты: http.Client.Timeout ограничивает весь запрос, context позволяет отменить извне, а MaxConnsPerHost не даёт создать сотни соединений к одному хосту.
Главное правило: каждая горутина должна иметь путь завершения — через context, done-channel или конечный цикл. Мониторьте runtime.NumGoroutine(), используйте goleak в CI, и всегда ставьте таймауты на HTTP-клиенты. Утечки горутин — не баг, а архитектурный долг, который проще не допускать, чем чинить в проде.