Конкурентность — родная сила Go: goroutine стоит копейки, и параллелить работу легко. Но та же лёгкость требует дисциплины — запущенную goroutine нужно дождаться, ошибку из неё получить, а при остановке сервиса всё аккуратно свернуть. UCP-сервис на Go опирается на несколько проверенных приёмов.

Goroutines и каналы

Goroutine запускается словом go; общаются они через каналы. Но запускать goroutine «и забыть» нельзя — потерянная goroutine это утечка, а её паника уронит весь процесс.

results := make(chan Result)
go func() {
    results <- doWork()
}()

Главное правило: у каждой goroutine должен быть тот, кто дождётся её завершения и обработает её ошибку. Для этого есть инструменты лучше «голого» go.

errgroup: группа задач

Когда нужно запустить несколько задач параллельно и дождаться всех (с первой ошибкой и отменой остальных), берут errgroup.

import "golang.org/x/sync/errgroup"

func enrich(ctx context.Context, ids []int) error {
    g, ctx := errgroup.WithContext(ctx)
    for _, id := range ids {
        id := id
        g.Go(func() error {
            return process(ctx, id)
        })
    }
    return g.Wait()
}

errgroup.WithContext отменит контекст для всех задач, как только одна вернёт ошибку; g.Wait() дождётся всех и вернёт первую ошибку. Это безопасный способ параллелить — без потерянных goroutine.

Worker pool

Когда задач много, но параллелизм надо ограничить (не запускать тысячу goroutine на базу), делают пул воркеров через канал-очередь.

func runPool(ctx context.Context, jobs <-chan Job, workers int) {
    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for job := range jobs {
                handle(ctx, job)
            }
        }()
    }
    wg.Wait()
}

Фиксированное число воркеров читает из общего канала — нагрузка ограничена. Тяжёлый и надёжный фон (который нельзя терять при рестарте) всё равно выносят во внешнюю очередь, как и в других биндингах, — goroutine живёт в процессе и его не переживёт.

Graceful shutdown

Сервис нельзя обрывать на полуслове: при остановке надо дослужить текущие запросы и закрыть соединения. В Go это signal.NotifyContext плюс http.Server.Shutdown.

func run() error {
    ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    srv := &http.Server{Addr: cfg.Addr, Handler: router}

    go func() {
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            slog.Error("server", "err", err)
        }
    }()

    <-ctx.Done() // дождались SIGTERM
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    return srv.Shutdown(shutdownCtx)
}

srv.Shutdown перестаёт принимать новые соединения и ждёт завершения текущих (до таймаута). В Kubernetes это критично: под получает SIGTERM при выкате, и graceful shutdown даёт дослужить запросы, прежде чем уйти. Пул базы закрывают следом (defer pool.Close() в main).

Где это в UCP

Конкурентность и остановка — инфраструктура, не бизнес-логика: Handler выражает сценарий, а goroutines/errgroup/shutdown — то, как он исполняется и как сервис гасится. Go делает это явным — видно, кто кого ждёт и что происходит при SIGTERM, — там, где Spring прячет пулы и lifecycle за абстракциями. Аккуратная остановка без потерянных запросов и goroutine — часть владения, которое несёт продукт-инженер; состояние этих процессов он видит через наблюдаемость.