Параллельная работа в 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)
}
Разберём по шагам:
signal.NotifyContextсоздаёт контекст, который отменится при полученииSIGTERMилиSIGINT.- Сервер запускается в отдельной горутине —
ListenAndServeблокирует, поэтому основная горутина ждёт. <-ctx.Done()блокируется до сигнала остановки.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и корректно закрыть ресурсы при выходе.