← назад к разделу

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

Что такое горутина

Раньше для параллельной работы создавали потоки операционной системы. Поток — тяжёлый: занимает несколько мегабайт памяти, запускается медленно, и переключение между потоками дорого стоит.

Go решает проблему иначе: горутина — это задача внутри рантайма Go. Она занимает несколько килобайт памяти и запускается почти мгновенно. Рантайм сам распределяет горутины по реальным потокам ОС, и программист об этом не думает.

go doWork() // запустить doWork параллельно — одно слово

Но здесь есть ловушка: если запустить горутину «и забыть», она становится утечкой. Если в ней случится паника и никто её не перехватит — весь процесс упадёт. Поэтому у каждой горутины должен быть хозяин, который её дождётся.

Каналы — как горутины общаются

Горутины передают данные друг другу через каналы. Канал — это типизированная очередь с синхронизацией: отправитель ждёт, пока получатель не заберёт значение (для небуферизованного канала).

results := make(chan string)

go func() {
    results <- "готово" // отправить результат
}()

msg := <-results // дождаться и получить

Каналы удобны, но для большинства задач есть более высокоуровневые инструменты — errgroup и sync.WaitGroup.

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()
}

Как это работает:

  • g.Go(...) запускает каждую задачу в отдельной горутине.
  • Если одна задача вернёт ошибку — контекст ctx отменяется для всех остальных.
  • g.Wait() дожидается завершения всех горутин и возвращает первую ошибку.

Никаких потерянных горутин: errgroup гарантирует, что все они завершились до того, как вернётся Wait.

Подробнее об отмене через контекст — в статье Контекст и отмена операций.

Worker pool: ограничиваем параллелизм

Иногда задач много, но запускать горутину на каждую нельзя — например, если каждая горутина открывает соединение к базе данных. Тысяча горутин = тысяча соединений, что быстро исчерпает пул.

Решение — фиксированный набор «воркеров», которые читают задачи из общей очереди:

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()
}

Здесь workers горутин запускается один раз. Каждая читает из канала jobs, пока он не закроется. sync.WaitGroup дожидается завершения всех воркеров.

Важное ограничение: горутина живёт только пока жив процесс. Если сервис упадёт или перезапустится — задачи в памяти потеряются. Для работы, которую нельзя терять (например, отправка уведомлений), используют внешние очереди вроде Kafka или RabbitMQ.

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: ":8080", Handler: router}

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

    <-ctx.Done() // ждём SIGTERM или SIGINT

    shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()

    return srv.Shutdown(shutdownCtx)
}

Разберём по шагам:

  1. signal.NotifyContext создаёт контекст, который отменится при получении SIGTERM или SIGINT.
  2. Сервер запускается в отдельной горутине — ListenAndServe блокирует, поэтому основная горутина ждёт.
  3. <-ctx.Done() блокируется до сигнала остановки.
  4. srv.Shutdown(shutdownCtx) — останавливает приём новых соединений и ждёт завершения текущих. Таймаут 10 секунд — если запросы не завершились за это время, сервер останавливается принудительно.

В Kubernetes это критично: при выкате под получает SIGTERM, и у него есть terminationGracePeriodSeconds (обычно 30 секунд), чтобы завершить работу. Без graceful shutdown часть запросов получит обрыв соединения.

Соединение с базой данных закрывают следом — обычно через defer pool.Close() в main.

Коротко

  • Горутина — лёгкая задача внутри Go-рантайма; стоит копейки, но требует хозяина, который её дождётся.
  • Каналы позволяют горутинам передавать данные; для большинства задач удобнее errgroup или sync.WaitGroup.
  • errgroup запускает параллельные задачи с отменой по первой ошибке и гарантирует, что все горутины завершились.
  • Worker pool через sync.WaitGroup и канал-очередь ограничивает параллелизм — фиксированное число воркеров читают задачи по мере готовности.
  • Горутины живут в процессе — при перезапуске задачи в памяти теряются; для надёжности используют внешние очереди.
  • Graceful shutdown через signal.NotifyContext + http.Server.Shutdown даёт текущим запросам завершиться перед остановкой сервиса.

Что почитать дальше

  • Контекст и отмена операций — как передавать отмену через цепочку вызовов.
  • Наблюдаемость — как видеть состояние горутин и запросов в работающем сервисе.
  • Структура проекта и сборка — как организовать main и корректно закрыть ресурсы при выходе.