Справочник

Параллелизм — справочник

Горутины, каналы, 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 / 00 / 0сколько лежит / ёмкость
Действие с закрытым каналомРезультат
получение v, ok := <-chzero 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 приёма этого значения
MutexUnlock happens before последующего Lock
RWMutexUnlock писателя before Lock/RLock; RUnlock before Lock
Onceвозврат из Do(f) before возврата любого другого Do(f)
WaitGroupDone 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-doneselect на рабочий канал и 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 vetcopylocks: копирование мьютексов и WaitGroup
runtime/pprof, профиль goroutineleakзаблокированные горутины; с Go 1.27 профиль общедоступен
-count, -shuffleповторяемость и другой порядок тестов

10. Первоисточники