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

GO КАК ФУНДАМЕНТ Конкурентность и остановка

Обязательно

Конкурентность и graceful shutdown в Go

Go

Горутины, каналы, errgroup, worker pool и остановка сервиса без потери задач и утечек.

1. Горутина - независимая задача

1 / 5

go func() запускает функцию параллельно. Runtime распределяет горутины по небольшому числу потоков ОС.

  • main не ждёт остальные автоматически.
  • Синхронизация нужна через каналы или WaitGroup.
  • Завершение каждой горутины проектируется заранее.
maingoroutine Agoroutine Bgoroutine Cwg.Wait

2. Канал передаёт значение и синхронизирует

2 / 5

Отправитель и получатель встречаются в небуферизованном канале. Буфер развязывает скорость до заполнения.

  • ch <- value отправляет, <-ch принимает.
  • close сигналит окончание потока.
  • Закрывает только отправитель и один раз.
goroutine A42goroutine Bch: передача и рукопожатие

3. errgroup отменяет группу при ошибке

3 / 5

Каждая задача возвращает error. Первая ошибка отменяет общий контекст, остальные завершаются, затем ошибка возвращается.

  • g.WithContext связывает отмену группы.
  • g.Go запускает задачи.
  • g.Wait ждёт всех и возвращает первую ошибку.
errgrouptask Atask B: errortask C: cancelg.Wait

4. Worker pool ограничивает параллелизм

4 / 5

Фиксированные воркеры читают jobs и отправляют результаты. Тысячи задач не создают тысячи горутин.

  • Jobs закрывается после выдачи.
  • Воркеры завершаются по jobs closed или ctx.Done.
  • Results закрывается после wg.Wait.
jobsworker 1worker 2worker 3results

5. Остановка завершает работу без потерь

5 / 5

Сигнал отменяет приём новых запросов, текущие получают время завершиться, фоновые задачи останавливаются через контекст.

  • HTTP server Shutdown имеет дедлайн.
  • Каналы jobs не закрываются до прекращения выдачи.
  • Ждём воркеры, закрываем результаты и ресурсы.
SIGTERMShutdownwg.Waitstop intake → finish work → close

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

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

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

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

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

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

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

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

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

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 {
        g.Go(func() error { // с Go 1.22 копировать id перед замыканием не нужно
            return process(ctx, id)
        })
    }

    return g.Wait()
}

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

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

Никаких потерянных горутин: errgroup гарантирует, что все они завершились до того, как вернётся Wait. Число одновременно работающих задач ограничивают прямо в группе: g.SetLimit(10) — и одиннадцатый g.Go подождёт, пока освободится место. Для многих задач этого хватает вместо отдельного пула воркеров.

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

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,
        ReadHeaderTimeout: 5 * time.Second, // без него медленный клиент держит соединение вечно
    }

    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 секунд — если за это время они не завершились, Shutdown вернёт ошибку контекста, а оставшиеся соединения сервер сам не рвёт: для этого следом вызывают srv.Close().

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

Соединение с базой данных закрывают следом — обычно через defer pool.Close() в main. Как уложить остановку в бюджет Kubernetes — readiness, preStop, порядок остановки потребителей и очередей — разобрано в цикле про graceful shutdown.

Дополнительно: при первом чтении можно пропустить

Глубже: конвейер, слияние каналов и семафоррасширенное

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

Обратная задача, слияние нескольких каналов в один (fan-in), встречается на каждом втором собеседовании по Go: несколько источников, один потребитель. На каждый вход запускают горутину-переливалку, а общий выход закрывают только после того, как закончились все входы, и считает их WaitGroup:

живой пример

package main

import (
	"fmt"
	"sync"
)

func generate(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for _, n := range nums {
			out <- n
		}
	}()
	return out
}

func square(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		defer close(out)
		for n := range in {
			out <- n * n
		}
	}()
	return out
}

func merge(ins ...<-chan int) <-chan int {
	out := make(chan int)
	var wg sync.WaitGroup
	for _, in := range ins {
		wg.Add(1)
		go func(c <-chan int) {
			defer wg.Done()
			for v := range c {
				out <- v
			}
		}(in)
	}
	go func() {
		wg.Wait()
		close(out)
	}()
	return out
}

func main() {
	a := square(generate(1, 2, 3))
	b := square(generate(10, 20))
	sum := 0
	for v := range merge(a, b) {
		sum += v
	}
	fmt.Println(sum) // 514
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Третий приём из той же коробки, семафор: буферизованный канал на n мест ограничивает число горутин, которые одновременно делают что-то дорогое, например ходят во внешний сервис. Перед работой кладут пустую структуру sem <- struct{}{}, после забирают <-sem; когда все места заняты, следующая горутина ждёт. В отличие от пула воркеров, горутин здесь может быть сколько угодно, ограничено только число работающих одновременно. Взвешенный вариант с разной ценой задач лежит в golang.org/x/sync/semaphore.

Глубже: RWMutex, sync.Once и sync.Mapрасширенное

Мьютекс закрывает участок для всех, кроме одной горутины, даже если остальные хотели только прочитать. Когда чтений в разы больше, чем записей, берут sync.RWMutex: RLock пускает читателей одновременно, Lock ждёт, пока они выйдут, и держит участок один. Две оговорки: ждущий писатель останавливает новых читателей, чтобы его не заморили голодом, и читателю нельзя внутри RLock звать код, который возьмёт Lock на тот же мьютекс, иначе взаимная блокировка. Для чтения одного числа обычный мьютекс или atomic быстрее, чем RWMutex.

Изнутри sync.Mutex это восемь байт: слово состояния с флагами «занят», «есть ждущие», «режим голодания» и счётчиком ждущих плюс семафор рантайма. Быстрый путь это одна атомарная операция compare-and-swap на свободном замке, без обращения к ядру. Если замок занят, горутина немного крутится на месте в надежде, что он освободится, потом паркуется на семафоре и будит её Unlock. Горутина, прождавшая больше миллисекунды, переводит мьютекс в режим голодания: замок отдаётся ей напрямую, а не тому, кто пришёл последним и ещё не спит. Поэтому мьютекс дешёв без конкуренции и честен под ней.

sync.Once решает задачу «сделать ровно один раз, сколько бы горутин ни пришли одновременно»: once.Do(f) выполняет f в первой горутине, остальные ждут её завершения и дальше проходят мимо. Это ленивая инициализация без гонки, например разбор конфигурации или открытие пула соединений. С Go 1.21 есть sync.OnceValue, который заодно запоминает результат.

sync.Map это не «потокобезопасная map на все случаи». Она выгодна в двух ситуациях: ключ пишут один раз и дальше только читают, как в кэше, или разные горутины работают с непересекающимися наборами ключей. В остальных случаях обычная map под RWMutex проще, быстрее и сохраняет типы: sync.Map хранит any и возвращает значения без проверки компилятором. Правило для ответа на собеседовании: сначала map плюс мьютекс, sync.Map только после профиля, который показал конкуренцию за замок.

Коротко

  • Горутина — лёгкая задача внутри Go-рантайма; стоит копейки, но требует хозяина, который её дождётся.
  • Каналы позволяют горутинам передавать данные; для большинства задач удобнее errgroup или sync.WaitGroup.
  • errgroup запускает параллельные задачи с отменой по первой ошибке и гарантирует, что все горутины завершились; g.SetLimit(n) ограничивает параллелизм.
  • Worker pool через sync.WaitGroup и канал-очередь ограничивает параллелизм — фиксированное число воркеров читают задачи по мере готовности.
  • Горутины живут в процессе — при перезапуске задачи в памяти теряются; для надёжности используют внешние очереди.
  • Graceful shutdown через signal.NotifyContext + http.Server.Shutdown даёт текущим запросам завершиться перед остановкой сервиса; по истечении таймаута оставшиеся соединения рвёт srv.Close(), у сервера выставлен ReadHeaderTimeout.
  • Конвейер: стадии соединены каналами, закрытие бежит по цепочке; слияние каналов (fan-in) закрывает выход после wg.Wait(); семафор это буферизованный канал на n мест.
  • RWMutex при чтениях в разы чаще записей, sync.Once для ленивой инициализации, sync.Map только для «записал один раз, читай много» или непересекающихся ключей; иначе map плюс мьютекс.

Что пощупать

В практикуме remodov/marketplace-system-go плавная остановка сервера через server.Shutdown с таймаутом и сто параллельных резервов, которые упираются в блокировку строки в базе, а не в мьютекс в памяти.

Код: main.go, reserve_concurrency_test.go.

Сделаем сами

Ветка step-05-stock-and-locking.

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