Параллелизм — справочник
Горутины, каналы, select, пакет sync, context, модель памяти, паттерны и тестирование конкурентного кода.
Собрано из модели памяти Go, документации пакетов sync и context, Go Concurrency Patterns: Pipelines and cancellation и release notes. Версия языка — Go 1.27.
Что появилось когда: sync.OnceFunc, OnceValue, OnceValues — 1.21; sync.WaitGroup.Go — 1.25; testing/synctest — 1.25; профиль утечек горутин — 1.26 экспериментальный, 1.27 общедоступный.
1. Горутины
go f(x) запускает вызов f(x) в новой горутине. Сама функция и её аргументы вычисляются в текущей горутине, а go возвращает управление сразу, не дожидаясь завершения. Горутина — не поток ОС: планировщик раскладывает готовые горутины по рабочим потокам.
У горутины нет идентификатора, её нельзя остановить или дождаться извне. Завершение организуют явно: каналом, sync.WaitGroup или context. Горутина, заблокированная навсегда, — утечка: память под стек и захваченные значения не освобождается.
| Понятие | Что означает |
|---|---|
G | горутина: стек, состояние, привязка к M |
M | рабочий поток ОС; выполняет код Go, только имея P |
P | ресурс выполнения: локальная очередь горутин, кэш аллокатора |
GOMAXPROCS | число P, то есть одновременно выполняемого кода Go |
По умолчанию GOMAXPROCS равно числу логических CPU. С Go 1.25 на Linux учитывается лимит CPU bandwidth у cgroup, а значение периодически пересчитывается при изменении числа CPU или лимита. Ручная установка через переменную окружения или runtime.GOMAXPROCS отключает оба поведения.
func main() {
var wg sync.WaitGroup
for i := 0; i < 3; i++ {
wg.Go(func() { // Go 1.25
fmt.Println("горутина", i)
})
}
wg.Wait()
}
До Go 1.25 тот же код писали через wg.Add(1), go func() { defer wg.Done(); … }(). WaitGroup.Go сам делает Add и Done; если функция паникует, паника не глушится.
2. Каналы
Канал — типизированный канал связи между горутинами. Направление можно ограничить в типе: chan T, chan<- T (только отправка), <-chan T (только получение). Закрытие канала — сигнал «значений больше не будет»; закрывает отправитель, а не получатель.
| Операция | nil-канал | Без буфера | С буфером |
|---|---|---|---|
отправка ch <- v | блокирует навсегда | ждёт получателя | ждёт места в буфере |
получение <-ch | блокирует навсегда | ждёт отправителя | ждёт значения или закрытия |
close(ch) | паника | разрешает получение zero value | разрешает дочитать буфер |
len(ch) / cap(ch) | 0 / 0 | 0 / 0 | сколько лежит / ёмкость |
| Действие с закрытым каналом | Результат |
|---|---|
получение v, ok := <-ch | zero value, ok == false |
for v := range ch | дочитывает буфер, затем выходит |
отправка ch <- v | паника |
повторное close(ch) | паника |
ch := make(chan int) // без буфера
go func() {
ch <- 42
close(ch)
}()
for v := range ch {
fmt.Println(v) // 42
}
v, ok := <-ch
fmt.Println(v, ok) // 0 false: канал закрыт
Буферизованный канал развязывает отправителя и получателя на размер буфера, но не заменяет синхронизацию: переполнение и опустошение снова блокируют. len(ch) — мгновенный снимок, на него нельзя опираться как на условие в конкурентной логике.
sem := make(chan struct{}, 2) // семафор на два места
sem <- struct{}{}
// ...
<-sem
3. select
select ждёт одну из операций связи. Если готовы несколько, выбирается одна равномерно псевдослучайно. default выполняется, когда не готова ни одна операция; без default select блокируется. Операции на nil-каналах не готовы никогда, поэтому nil-канал в select выключает ветку.
При входе в select каналы и правые части отправок вычисляются ровно один раз, в порядке исходного текста. Левые части получающих присваиваний вычисляются только в выбранной ветке.
select {
case v := <-ch:
fmt.Println("получено", v)
case ch <- 42:
fmt.Println("отправлено")
case <-ctx.Done():
return ctx.Err()
default:
fmt.Println("ничего не готово")
}
Таймаут на один шаг:
select {
case v := <-ch:
return v, nil
case <-time.After(2 * time.Second):
return 0, errors.New("timeout")
}
time.After создаёт таймер, который сработает даже если ветка не выбрана. В цикле и на горячем пути предпочтительнее context.WithTimeout с явным cancel или переиспользуемый time.Timer.
4. sync: мьютексы, WaitGroup, Once, Pool, Map
Пакет sync даёт низкоуровневые примитивы. Его собственные типы нельзя копировать после первого использования, а нулевое значение большинства из них готово к работе.
Mutex и RWMutex
| Тип | Что делает | Когда брать |
|---|---|---|
sync.Mutex | взаимное исключение: Lock, Unlock, TryLock | короткая критическая секция, общий инвариант |
sync.RWMutex | много читателей или один писатель | чтений заметно больше, чем записей |
Мьютекс не привязан к горутине: заблокировать может одна, разблокировать — другая. Повторный Lock из той же горутины даёт deadlock: мьютекс не реентерабельный. Unlock незаблокированного мьютекса — ошибка времени выполнения.
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}
WaitGroup
Add увеличивает счётчик, Done уменьшает, Wait блокируется до нуля. Положительный Add при нулевом счётчике должен произойти до Wait; иначе Wait может вернуться раньше. Отрицательный счётчик — паника.
var wg sync.WaitGroup
for _, url := range urls {
wg.Go(func() { fetch(url) }) // Go 1.25: Add + Done внутри
}
wg.Wait()
Once и OnceFunc
Once.Do(f) выполняет f ровно один раз; остальные вызовы ждут завершения первого. Если f паникует, Do считает её завершённой и больше не вызывает. С Go 1.21 есть обёртки OnceFunc, OnceValue, OnceValues: они возвращают функцию, которая кэширует результат и повторяет панику при каждом вызове.
var loadConfig = sync.OnceValue(func() *Config {
cfg, err := readConfig()
if err != nil {
panic(err)
}
return cfg
})
cfg := loadConfig() // чтение один раз, дальше — кэш
Pool
Pool — набор временных объектов для повторного использования, чтобы снизить давление на GC. Любой элемент может быть удалён в любой момент без уведомления; Pool не является кэшем и не подходит для долгоживущих объектов.
var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
b := bufPool.Get().(*bytes.Buffer)
b.Reset()
defer bufPool.Put(b)
Map
sync.Map специализирован: документация рекомендует обычную мапу с отдельной блокировкой, а sync.Map — для двух случаев: запись по ключу происходит один раз, а чтений много; либо горутины работают с непересекающимися наборами ключей. Нулевое значение готово к использованию.
var cache sync.Map
cache.Store("key", 42)
v, ok := cache.Load("key")
cache.Delete("key")
Cond и atomic
sync.Cond — переменная условия с Wait, Signal, Broadcast; для большинства задач каналы проще. Пакет sync/atomic даёт атомарные операции и типизированные обёртки atomic.Int64, atomic.Bool, atomic.Pointer[T]: они безопасны для конкурентного доступа без мьютекса.
var hits atomic.Int64
hits.Add(1)
fmt.Println(hits.Load())
5. context
context.Context несёт дедлайн, сигнал отмены и значения, ограниченные временем жизни запроса. Контекст образует дерево: отмена родителя отменяет всех потомков.
| Функция | Что создаёт |
|---|---|
context.Background() | корневой контекст; не отменяется и не имеет дедлайна |
context.TODO() | заглушка, когда контекст ещё не определён |
WithCancel(parent) | дочерний контекст и CancelFunc |
WithTimeout(parent, d) | отмена через d |
WithDeadline(parent, t) | отмена в момент t |
WithValue(parent, key, val) | значение, привязанное к контексту |
WithCancelCause(parent) | отмена с причиной; причина читается через context.Cause |
WithDeadlineCause, WithTimeoutCause | дедлайн или таймаут с причиной |
Правила из документации пакета: не хранить контекст в структуре, а передавать первым параметром ctx; не передавать nil — есть TODO; использовать значения только для данных уровня запроса, а не для необязательных параметров функции. CancelFunc нужно вызывать на всех путях: иначе дочерний контекст и его таймер живут до отмены родителя.
func (s *Service) GetUser(ctx context.Context, id int) (*User, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
row := s.db.QueryRowContext(ctx, "select id, name from users where id = $1", id)
var u User
if err := row.Scan(&u.ID, &u.Name); err != nil {
return nil, fmt.Errorf("get user %d: %w", id, err)
}
return &u, nil
}
ctx.Done() закрывается при отмене; ctx.Err() возвращает context.Canceled или context.DeadlineExceeded. Значение, положенное через WithValue, должно быть сравнимо и не должно быть встроенного типа: ключи-строки легко collide между пакетами, поэтому ключ объявляют собственным типом.
6. Модель памяти и data race
Data race — это запись в ячейку памяти, происходящая одновременно с другим чтением или записью той же ячейки, если все эти обращения не атомарны. Программа без гонок ведёт себя так, как будто все горутины выполнялись на одном процессоре: это свойство называется DRF-SC (data-race-free — sequentially consistent).
Дословно: «A data race is defined as a write to a memory location happening concurrently with another read or write to that same location, unless all the accesses involved are atomic data accesses as provided by the sync/atomic package.» · «In the absence of data races, Go programs behave as if all the goroutines were multiplexed onto a single processor.»
Отношение happens before задаёт, какие записи видны чтению. Оно возникает, например, при отправке и приёме по каналу, при Unlock и последующем Lock, при Once.Do, WaitGroup.Wait и атомарных операциях. Без такого ребра два обращения не упорядочены, и результат не определён.
var counter int
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Go(func() { counter++ }) // data race
}
wg.Wait()
Гонку находит детектор: go test -race, go run -race, go build -race. Он видит только реально исполненные обращения, поэтому тест должен задевать конкурентный путь. Детектор замедляет программу и увеличивает расход памяти.
| Средство | Что упорядочивает |
|---|---|
| канал | отправка happens before приёма этого значения |
Mutex | Unlock happens before последующего Lock |
RWMutex | Unlock писателя before Lock/RLock; RUnlock before Lock |
Once | возврат из Do(f) before возврата любого другого Do(f) |
WaitGroup | Done before возврата Wait |
atomic | атомарная операция не создаёт гонки; порядок задаётся правилами пакета |
Атомарность не заменяет мьютекс автоматически: несколько атомарных полей не образуют одну транзакцию. Если инвариант охватывает больше одной переменной, нужен мьютекс.
7. Паттерны
Worker pool
Фиксированное число горутин читает задачи из общего канала. Закрытие канала задач — сигнал рабочим завершиться; закрытие канала результатов делает отдельная горутина после Wait.
func process(ctx context.Context, items []Item) ([]Result, error) {
jobs := make(chan Item)
results := make(chan Result)
var wg sync.WaitGroup
for i := 0; i < 4; i++ {
wg.Go(func() {
for item := range jobs {
results <- processItem(item)
}
})
}
go func() {
defer close(jobs)
for _, item := range items {
select {
case jobs <- item:
case <-ctx.Done():
return
}
}
}()
go func() {
wg.Wait()
close(results)
}()
var out []Result
for r := range results {
out = append(out, r)
}
return out, ctx.Err()
}
Pipeline
Стадии соединяются каналами: каждая читает из входного канала и пишет в выходной. Отмена передаётся через ctx.Done(), иначе ранний выход получателя оставит отправителей заблокированными навсегда.
func gen(ctx context.Context, nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for _, n := range nums {
select {
case out <- n:
case <-ctx.Done():
return
}
}
}()
return out
}
func square(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * n:
case <-ctx.Done():
return
}
}
}()
return out
}
errgroup
golang.org/x/sync/errgroup — группа горутин с первой ошибкой и общим контекстом. WithContext отменяет контекст при первой ошибке, SetLimit ограничивает число одновременных горутин, TryGo запускает только если есть свободное место.
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(4)
for _, u := range urls {
g.Go(func() error {
return fetch(ctx, u)
})
}
return g.Wait()
Fan-out, fan-in, семафор
| Паттерн | Из чего состоит |
|---|---|
| fan-out | несколько горутин читают один входной канал |
| fan-in | несколько входных каналов сводятся в один; на каждый вход — своя горутина |
| семафор | буферизованный канал: отправка занимает место, приём освобождает |
| or-done | select на рабочий канал и ctx.Done() |
8. Ошибки, паника и завершение
Паника в любой горутине завершает всю программу, а не только эту горутину. recover работает только в отложенной функции той горутины, где случилась паника; из Wait или родительской горутины её не поймать.
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("panic: %v", r)
}
}()
work()
}()
Первая ошибка из группы горутин обычно возвращается через errgroup; несколько независимых ошибок можно собрать errors.Join. Отмена — это не ошибка сама по себе: context.Canceled часто означает штатное завершение по запросу клиента.
g, ctx := errgroup.WithContext(ctx)
for _, shard := range shards {
g.Go(func() error {
return syncShard(ctx, shard)
})
}
if err := g.Wait(); err != nil {
return fmt.Errorf("sync: %w", err)
}
Завершение сервиса: signal.NotifyContext превращает сигнал в отмену контекста, затем http.Server.Shutdown ждёт активные запросы. Подробности — в листе «HTTP-сервис».
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go func() { _ = srv.ListenAndServe() }()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
9. Тестирование конкурентного кода
Основной инструмент — детектор гонок: go test -race ./.... Он не доказывает отсутствие гонок, но находит исполненные. Тесты с time.Sleep хрупкие: они зависят от планировщика и загрузки машины.
С Go 1.25 пакет testing/synctest запускает тест в изолированном «пузыре» с виртуальным временем. Пока все горутины пузыря заблокированы, время перескакивает к следующему таймеру; synctest.Wait ждёт, пока все горутины пузыря окажутся в состоянии, из которого их может разбудить только внешнее событие.
func TestTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
synctest.Sleep(2 * time.Minute) // виртуальное время
if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
t.Fatalf("ctx.Err() = %v", ctx.Err())
}
})
}
| Инструмент | Что проверяет |
|---|---|
go test -race | гонки данных в исполненных путях |
testing/synctest | конкурентный код с виртуальным временем, без реальных задержек |
go vet | copylocks: копирование мьютексов и WaitGroup |
runtime/pprof, профиль goroutineleak | заблокированные горутины; с Go 1.27 профиль общедоступен |
-count, -shuffle | повторяемость и другой порядок тестов |
10. Первоисточники
- The Go Memory Model — data race, happens before, DRF-SC
- Спецификация: Channel types — отправка, приём, закрытие, nil-канал
- Спецификация: Select statements — случайный выбор готовой ветки
- pkg.go.dev/sync — Mutex, RWMutex, WaitGroup, Once, Pool, Map, Cond
- pkg.go.dev/sync/atomic — атомарные операции и типизированные обёртки
- pkg.go.dev/context — правила передачи контекста, отмена, причины
- Go Concurrency Patterns: Pipelines and cancellation — стадии, fan-out, fan-in, отмена
- Go Concurrency Patterns: Context — контекст в сервере
- pkg.go.dev/golang.org/x/sync/errgroup — группа горутин с первой ошибкой
- pkg.go.dev/testing/synctest — виртуальное время в тестах
- Release notes Go 1.25 —
WaitGroup.Go, container-awareGOMAXPROCS,synctest - Release notes Go 1.26 — экспериментальный профиль
goroutineleak - Release notes Go 1.27 — профиль
goroutineleakстал общедоступным