26. Background jobs

Программа, не способная выполнять фоновую работу параллельно с обслуживанием клиентов, подобна повару, который замирает у плиты в ожидании закипания воды, не притрагиваясь к нарезке овощей.

В традиционных веб-стеках вроде PHP или классического Python (WSGI) запуск фоновых задач всегда был болезненным компромиссом: жизненный цикл процесса жестко привязан к одному HTTP-запросу, из-за чего любую тяжелую операцию приходилось выталкивать во внешние демоны (Cron, Supervisord, Celery). В Java тяжеловесный ExecutorService плодит системные потоки операционной системы, каждый из которых требует мегабайты зарезервированной виртуальной памяти.

Язык Go предоставил разработчикам потрясающий инструмент: ключевое слово go запускает новую горутину со стеком размером всего 2 КБ за считанные десятки наносекунд. Но эта невероятная легкость таит в себе коварную ловушку. Неопытные инженеры начинают бездумно рассыпать go doWork() по кодовой базе, наивно полагая, что рантайм сам решит проблемы отказоустойчивости, утечек памяти, отмены операций и упорядоченного завершения работы.

Философия фоновых задач в Go

В PHP фоновые задачи обычно делегируются системным демонам вроде supervisord или кроном, так как модель запрос-ответ синхронна и ограничена временем жизни скрипта. В Java используется тяжелый ExecutorService с пулами тредов ОС. В Go горутины позволяют запускать миллионы параллельных задач с минимальным оверхедом, но эта сила требует строгой дисциплины. Фоновая задача в Go — это не просто go func(), а управляемый процесс с контекстом, обработкой ошибок, возможностью отмены и интеграцией в graceful shutdown.

1. Fire-and-Forget против Reliable Processing

Паттерн go doWork() («выстрелил и забыл») подходит исключительно для неблокирующих, некритичных операций: отправка вспомогательных метрик, фоновое структурированное логирование или ленивое прогревание локального кэша.

Для критических бизнес-процессов — генерации финансовых отчетов, списания средств, отправки транзакционных email или надежной обработки вебхуков сторонних платежных систем — безоглядный go func() категорически неприемлем. Здесь требуются гарантии доставки, строгий учет ошибок, повторные попытки (retries) с экспоненциальной задержкой, ограничение параллелизма и интеграция с внешними брокерами сообщений.

2. Синхронизация через errgroup и WaitGroup

Для управления пачкой фоновых задач стандартный инструмент — sync.WaitGroup. Однако в production-коде де-факто стандартом стал пакет golang.org/x/sync/errgroup, который гармонично объединяет ожидание завершения группы задач, контекст кооперативной отмены и агрегацию ошибок.

Go
package worker

import (
    "context"
    "fmt"

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

type Item struct {
    ID int64
}

func processItem(ctx context.Context, item Item) error {
    select {
    case <-ctx.Done():
        return ctx.Err()
    default:
        // Бизнес-логика обработки элемента
        return nil
    }
}

func processBatch(ctx context.Context, items []Item) error {
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(10) // Ограничение параллелизма: не более 10 горутин одновременно

    for _, item := range items {
        item := item // Capture для замыкания (в Go < 1.22)
        g.Go(func() error {
            // Автоматическая отмена всей группы при первой ошибке
            return processItem(ctx, item)
        })
    }

    // Блокирует до завершения всех задач или первой ошибки
    return g.Wait()
}
flowchart TD
    A["Вызов errgroup.WithContext"] --> B["Создание cancelCtx<br>и семафора"]
    B --> C["Вызов g.Go"]
    C --> D{"Слот в лимите<br>доступен?"}
    D -->|"Да"| E["Запуск горутины<br>в планировщике"]
    D -->|"Нет"| F["Блокировка на канале<br>ожидание слота"]
    E --> G["Выполнение задачи"]
    G --> H{"Ошибка или успех?"}
    H -->|"Ошибка"| I["Вызов cancel<br>сигнал всем дочерним"]
    H -->|"Успех"| J["Освобождение слота<br>в семафоре"]
    I --> K["Остальные горутины<br>читают ctx.Done и выходят"]
    J --> L["g.Wait разблокируется"]

    classDef normal fill:#1e293b,stroke:#38bdf8,color:#f8fafc;
    classDef decision fill:#334155,stroke:#f59e0b,color:#f8fafc;
    classDef action fill:#0f172a,stroke:#10b981,color:#f8fafc;
    classDef fail fill:#4c0519,stroke:#f43f5e,color:#ffffff;
    class A,B,C,E,G,J,L normal;
    class D,H decision;
    class F,K action;
    class I fail;
  

3. Worker Pool для потоковой обработки

Для непрерывных потоков входящих событий (например, чтение из очереди RabbitMQ или Kafka) применяется классический паттерн Worker Pool. Набор фиксированных горутин-потребителей (consumers) читает задачи из одного общего канала, разделяя нагрузку без бесконечного порождения горутин.

Go
package worker

import (
    "context"
    "sync"
)

type Job struct {
    Data string
}

type Result struct {
    Success bool
}

func process(j Job) Result {
    return Result{Success: true}
}

func StartWorkerPool(ctx context.Context, numWorkers int, jobs <-chan Job, results chan<- Result) {
    var wg sync.WaitGroup
    for i := 0; i < numWorkers; i++ {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            for {
                select {
                case <-ctx.Done():
                    return // Graceful shutdown
                case job, ok := <-jobs:
                    if !ok {
                        return // Канал закрыт продюсером
                    }
                    res := process(job)
                    select {
                    case results <- res:
                    case <-ctx.Done():
                        return
                    }
                }
            }
        }(i)
    }
    go func() {
        wg.Wait()
        close(results)
    }()
}

4. Под капотом. Жизненный цикл горутины и память

Каждая фоновая задача — это структура runtime.g с собственным стеком по умолчанию два килобайта. Если задача блокируется надолго или рекурсивно вызывает функции, стек динамически растет до одного гигабайта на 64-битных системах.

  • Stack Growth (Рост стека): При нехватке места в стеке Go копирует весь стек в новую область памяти, обновляет указатели и передает управление. Это дорогая операция. В долгоживущих воркерах избегайте глубокой рекурсии и огромных локальных переменных.
  • Goroutine Leaks (Утечки горутин): Если горутина блокируется на чтении из канала, который никто не закроет, или на time.Sleep без проверки контекста, она никогда не завершится. Рантайм не собирает заблокированные горутины через GC. Это утечка памяти и файловых дескрипторов.
  • Scheduler (Планировщик): Долгоживущая задача без syscall или sync точки может удерживать тред ОС M, мешая планировщику переключать контекст. Используйте runtime.Gosched или select с ctx.Done для cooperative preemption. Go 1.14+ использует async preemption через сигналы ОС, но точки отмены все равно обязательны для корректной бизнес-логики.

5. Интеграция с Graceful Shutdown

Фоновые задачи должны корректно завершаться при получении сигнала ОС. Это достигается через общий context.Context и sync.WaitGroup.

Go
func runBackgroundWorker(ctx context.Context, wg *sync.WaitGroup) {
    wg.Add(1)
    go func() {
        defer wg.Done()
        for {
            if err := doWork(ctx); err != nil {
                if errors.Is(err, context.Canceled) {
                    return // Shutdown initiated
                }
                log.Printf("worker error: %v", err)
                time.Sleep(backoff(ctx))
            }
        }
    }()
}

// В main:
var bgWg sync.WaitGroup
workerCtx, cancelWorkers := context.WithCancel(context.Background())
runBackgroundWorker(workerCtx, &bgWg)

// При shutdown:
cancelWorkers() // Отмена контекста
bgWg.Wait()     // Ожидание завершения

6. Сравнение с другими языками

Аспект PHP / Cron Java ExecutorService Python Celery Go Background Jobs
Модель выполнения Отдельный процесс на запуск Пул тредов ОС, тяжелый Отдельный воркер-процесс + брокер Горутины в одном процессе, легкие
Конкурентность Нет по умолчанию Thread-per-task, ограничен RAM Async IO или multiprocessing M:N планировщик, тысячи goroutines
Отмена задачи SIGKILL / SIGTERM Future.cancel Revoke task context.Context cooperative
Управление памятью Process termination per request Heap GC, тяжелый оверхед Process restarts Precise GC, stack growth, minimal overhead

7. Итог

  1. Фоновые задачи в Go — это управляемые горутины с контекстом, а не просто go func.
  2. Используйте errgroup для батчевой обработки с ограничением параллелизма и агрегацией ошибок.
  3. Всегда проверяйте ctx.Done в циклах и блокирующих операциях для предотвращения утечек.
  4. Оборачивайте точки входа воркеров в defer recover, чтобы паники не нарушали работу остальных задач.
  5. Интегрируйте sync.WaitGroup и context в graceful shutdown для детерминированного завершения.
  6. Ограничивайте количество горутин семафорами или SetLimit, чтобы избежать starvation планировщика и OOM.
  7. Для надежной доставки и масштабирования переходите к внешним брокерам и очередям задач.

Следующая статья: 27. Очереди задач