Конкурентность — родная сила 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 — часть владения, которое несёт продукт-инженер; состояние этих процессов он видит через наблюдаемость.