lenec ru

← все посты

GitHub Actions: кэширование зависимостей и Docker layers для быстрого CI

16K

Горутины — одна из главных суперсил 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-клиенты. Утечки горутин — не баг, а архитектурный долг, который проще не допускать, чем чинить в проде.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.