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, который гармонично объединяет ожидание завершения группы задач, контекст кооперативной отмены и агрегацию ошибок.
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) читает задачи из одного общего канала, разделяя нагрузку без бесконечного порождения горутин.
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.
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. Итог
- Фоновые задачи в Go — это управляемые горутины с контекстом, а не просто
go func. - Используйте
errgroupдля батчевой обработки с ограничением параллелизма и агрегацией ошибок. - Всегда проверяйте
ctx.Doneв циклах и блокирующих операциях для предотвращения утечек. - Оборачивайте точки входа воркеров в
defer recover, чтобы паники не нарушали работу остальных задач. - Интегрируйте
sync.WaitGroupиcontextв graceful shutdown для детерминированного завершения. - Ограничивайте количество горутин семафорами или
SetLimit, чтобы избежать starvation планировщика и OOM. - Для надежной доставки и масштабирования переходите к внешним брокерам и очередям задач.
Следующая статья: 27. Очереди задач