---
title: "sync: Mutex, RWMutex, Once, Cond, WaitGroup, Pool"
description: "Сервис держит в памяти кэш: карту «ключ → значение». Её читают сотни горутин-обработчиков, и изредка какая-нибудь из них обновляет запись. Передавать карту из рук в руки через…"
canonical: https://graphlms.ru/go/book/sync-primitives
course: "Конкурентность Go"
updated: 2026-10-12
lang: ru
publisher: GraphLMS
---

# sync: Mutex, RWMutex, Once, Cond, WaitGroup, Pool

> Источник: https://graphlms.ru/go/book/sync-primitives — курс «Конкурентность Go», GraphLMS.

## О чём глава

Сервис держит в памяти кэш: карту «ключ → значение». Её читают сотни горутин-обработчиков,
и изредка какая-нибудь из них обновляет запись. Передавать карту из рук в руки через
канал здесь неудобно: данные не движутся, они лежат на месте, и к ним по очереди
обращаются многие. Для такого случая в пакете `sync` есть примитивы, которые защищают
общее состояние на месте.

В главе разберём каждый из них: `Mutex`, `RWMutex`, `Once`, `WaitGroup`, `Cond` и `Pool`,
а заодно `sync.Map`. Для каждого — что он гарантирует, когда уместен и где на нём обычно
ошибаются.

## Перед чтением

Нужны структуры, методы с получателем-указателем, карты, `defer` и горутины. Полезно
помнить правило из [модели памяти](/go/book/memory-model): если хотя бы одна горутина
пишет в переменную, все обращения к ней из разных горутин должны быть синхронизированы.
Для `sync.Pool` понадобится приведение типа `x.(T)` из главы
[«Интерфейсы»](/go-basics/book/interfaces).

Два термина. **Общее состояние** — данные, к которым обращаются несколько горутин.
**Критическая секция** — участок кода, который работает с общим состоянием и поэтому
должен выполняться под одной блокировкой.

На первом проходе изучите `Mutex`, `WaitGroup` и `Once`, затем `RWMutex`. `Cond`
понадобится к задаче 08. `Pool` — оптимизация, к которой приходят после того, как код
уже корректен и измерения показали, что аллокации мешают.

## Мьютекс или канал

Перед выбором примитива полезно спросить себя: мне нужно передать данные из одной
горутины в другую или дать многим горутинам по очереди поработать с одними и теми же
данными?

- **Канал** — когда данные движутся: раздать задачи воркерам, собрать результаты,
  сообщить о событии.
- **Мьютекс** — когда данные лежат на месте: счётчик, кэш, карта, поля структуры, к
  которым обращаются многие.

Обратный выбор обычно получается неуклюжим. Защищать карту через выделенную
горутину-владельца, которой по каналу присылают запросы «прочитай» и «запиши», можно,
но это больше кода и медленнее, чем один `sync.Mutex`. А раздавать задачи воркерам через
срез под мьютексом с флагами «взято» хуже, чем через канал.

## sync.Mutex

Mutex (mutual exclusion, взаимное исключение) гарантирует, что в критической секции
одновременно находится не больше одной горутины. Остальные, вызвавшие `Lock`, ждут,
пока владелец не вызовет `Unlock`.

```go
type Counter struct {
    mu sync.Mutex
    n  int
}

func (c *Counter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.n++
}
```

Мьютекс защищает не только от одновременных записей. `Unlock` happens-before следующего
`Lock`, поэтому всё, что одна горутина записала под мьютексом, следующая горутина,
захватившая его, гарантированно увидит.

```go play
package main

import (
	"fmt"
	"sync"
)

type Counter struct {
	mu sync.Mutex
	n  int
}

func (c *Counter) Inc() {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.n++
}

func main() {
	var wg sync.WaitGroup
	c := &Counter{}
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			c.Inc()
		}()
	}
	wg.Wait()
	fmt.Println("итог:", c.n)
}
```

```text
итог: 1000
```

Без мьютекса `c.n++` содержал бы гонку данных: это три шага (прочитать, прибавить,
записать), и две горутины могут прочитать одно и то же старое значение. Мьютекс делает
эти три шага неделимыми относительно других горутин. Как выглядит отчёт race detector
для такой ошибки, показано в главе [«Утечки горутин и гонки»](/go/book/leaks-and-races).

Три правила, которые экономят часы отладки:

- **Мьютекс нельзя копировать после первого использования.** Если передать структуру с
  мьютексом по значению, у копии будет свой независимый мьютекс, и защита перестанет
  работать без единой ошибки компиляции. Поэтому методы объявляют на указателе
  (`*Counter`). Команда `go vet` находит такие копирования (проверка `copylocks`).
- **Критическая секция должна быть короткой.** Сетевой вызов или запись на диск под
  `Lock` заставляет всех остальных ждать, пока идёт ввод-вывод. Сначала подготовьте
  данные, потом коротко захватите мьютекс, чтобы их сохранить.
- **`Unlock` через `defer` сразу после `Lock`.** Тогда мьютекс освободится при любом
  `return` и при панике.

## sync.RWMutex

`RWMutex` различает читателей и писателей. Одновременно мьютекс могут держать либо
сколько угодно читателей (`RLock`/`RUnlock`), либо один писатель (`Lock`/`Unlock`).

```go
type Cache struct {
    mu sync.RWMutex
    m  map[string]string
}

func (c *Cache) Get(k string) (string, bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    v, ok := c.m[k]
    return v, ok
}

func (c *Cache) Set(k, v string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.m[k] = v
}
```

Выигрыш появляется, когда чтений намного больше, чем записей, и чтение занимает
заметное время: читатели не ждут друг друга. Но `RWMutex` сложнее устроен: он ведёт
учёт читателей, и этот учёт сам становится точкой, за которую соревнуются ядра. Если
критическая секция крошечная (прочитать одно поле), обычный `Mutex` или
[atomic](/go/book/atomic) часто оказываются быстрее. Решают это бенчмарком
(`go test -bench`), а не на глаз.

Ещё одна деталь: если писатель ждёт `Lock`, новые `RLock` тоже ждут, пока писатель не
отработает. Это защищает писателей от голодания, но означает, что рекурсивный `RLock` в
той же горутине может привести к дедлоку.

## sync.Map

Для кэша есть и готовая потокобезопасная карта `sync.Map` с методами `Load`, `Store`,
`LoadOrStore`, `Delete`, `Range`. Она хорошо подходит для двух случаев, описанных в
документации: ключ записывается один раз и потом много раз читается, либо разные
горутины работают с непересекающимися наборами ключей. Для остальных случаев обычная
`map` под `Mutex` или `RWMutex` проще и часто быстрее. У `sync.Map` нет типов: значения
хранятся как `any`, и при чтении нужно приведение типа.

## sync.Once

`Once` выполняет функцию ровно один раз, сколько бы горутин ни вызвали `Do`
одновременно. Выполнение функции happens-before возврата из любого `Do`, поэтому все
вызывающие видят полностью завершённую инициализацию.

```go
var (
    once sync.Once
    conn *DB
)

func GetDB() *DB {
    once.Do(func() { conn = connect() }) // connect выполнится один раз
    return conn
}
```

Если сто горутин одновременно вызовут `GetDB`, `connect` выполнится один раз, а остальные
99 дождутся его окончания и получат тот же `conn`.

```go play
package main

import (
	"fmt"
	"sync"
)

func main() {
	var once sync.Once
	var wg sync.WaitGroup
	calls := 0

	for i := 0; i < 5; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			once.Do(func() {
				calls++
				fmt.Println("инициализация выполнена")
			})
		}()
	}
	wg.Wait()
	fmt.Println("тело Do отработало раз:", calls)
}
```

```text
инициализация выполнена
тело Do отработало раз: 1
```

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

```go
var loadConfig = sync.OnceValues(func() (*Config, error) {
    return readConfig("app.yaml")
})

cfg, err := loadConfig() // при любом числе вызовов readConfig выполнится один раз
```

Ошибка здесь тоже запоминается: все вызывающие получат одну и ту же ошибку. Если нужен
повтор после неудачи, `Once` не подходит, понадобится мьютекс и флаг.

## sync.WaitGroup

`WaitGroup` ждёт завершения группы горутин. Внутри у неё счётчик: `Add(n)` увеличивает
его, `Done()` уменьшает на единицу, `Wait()` ждёт, пока счётчик не станет нулём.

```go
var wg sync.WaitGroup
for _, job := range jobs {
    wg.Add(1)
    go func(j Job) {
        defer wg.Done()
        process(j)
    }(job)
}
wg.Wait() // здесь все горутины точно завершились
```

Два правила, которые нарушают чаще всего:

- **`Add` вызывают до `go`**, в запускающем коде. Если вызвать `Add(1)` внутри горутины,
  `Wait` может выполниться раньше, увидеть ноль и вернуться, пока горутины ещё не
  начались.
- **`Done` вызывают через `defer`**, чтобы счётчик уменьшился и при раннем `return`, и
  при панике. Пропущенный `Done` — это `Wait`, который не вернётся никогда.

С Go 1.25 у `WaitGroup` есть метод `Go`: он сам делает `Add(1)`, запускает функцию в
новой горутине и вызывает `Done`, когда она вернётся. Ошибиться с порядком `Add` при
этом невозможно. Функция, переданная в `wg.Go`, не должна паниковать.

```go
var wg sync.WaitGroup
for _, job := range jobs {
    wg.Go(func() { process(job) })
}
wg.Wait()
```

`WaitGroup` отвечает только за «дождаться всех». Если горутины пишут в общую переменную,
её всё равно нужно защищать:

```go play
package main

import (
	"fmt"
	"sync"
)

func main() {
	var wg sync.WaitGroup
	var mu sync.Mutex
	total := 0

	for i := 1; i <= 5; i++ {
		wg.Add(1)
		go func(n int) {
			defer wg.Done()
			mu.Lock()
			total += n * n
			mu.Unlock()
		}(i)
	}

	wg.Wait()
	fmt.Println("сумма квадратов 1..5:", total)
}
```

```text
сумма квадратов 1..5: 55
```

## sync.Cond

`Cond` (условная переменная) позволяет горутинам спать, пока не выполнится условие над
общим состоянием, и просыпаться по сигналу. Её создают через `sync.NewCond(&mu)` и
привязывают к мьютексу, который защищает это состояние.

```go
type Queue struct {
    mu   sync.Mutex
    cond *sync.Cond // создаётся как sync.NewCond(&q.mu)
    data []int
}

func (q *Queue) Pop() int {
    q.mu.Lock()
    defer q.mu.Unlock()
    for len(q.data) == 0 { // цикл, а не if
        q.cond.Wait()
    }
    v := q.data[0]
    q.data = q.data[1:]
    return v
}

func (q *Queue) Push(v int) {
    q.mu.Lock()
    q.data = append(q.data, v)
    q.mu.Unlock()
    q.cond.Signal() // разбудить одного ждущего
}
```

`Wait` делает три вещи: отпускает мьютекс, усыпляет горутину и, проснувшись, снова
захватывает мьютекс. Отпускать мьютекс нужно, иначе `Push` никогда бы не смог добавить
данные.

Почему цикл, а не `if`? В Go `Wait` не возвращается сам по себе, без `Signal` или
`Broadcast`. Но между сигналом и моментом, когда проснувшаяся горутина снова захватит
мьютекс, другая горутина может успеть забрать данные. Цикл заново проверяет условие под
мьютексом и не даёт работать с пустой очередью.

`Signal` будит одного ждущего, `Broadcast` — всех. Производитель и потребитель на `Cond`:

```go play
package main

import (
	"fmt"
	"sync"
)

func main() {
	var mu sync.Mutex
	cond := sync.NewCond(&mu)
	queue := []int{}
	done := false

	var wg sync.WaitGroup
	wg.Add(1)
	go func() { // потребитель
		defer wg.Done()
		for {
			mu.Lock()
			for len(queue) == 0 && !done {
				cond.Wait()
			}
			if len(queue) == 0 && done {
				mu.Unlock()
				return
			}
			v := queue[0]
			queue = queue[1:]
			mu.Unlock()
			fmt.Println("получено:", v)
		}
	}()

	for i := 1; i <= 3; i++ { // производитель
		mu.Lock()
		queue = append(queue, i)
		mu.Unlock()
		cond.Signal()
	}
	mu.Lock()
	done = true
	mu.Unlock()
	cond.Broadcast() // разбудить, чтобы потребитель увидел done и вышел

	wg.Wait()
	fmt.Println("готово")
}
```

```text
получено: 1
получено: 2
получено: 3
готово
```

Порядок значений здесь гарантирован: очередь одна, потребитель один, и он забирает
значения с начала среза. Потребитель выходит, только когда очередь пуста и выставлен
`done`, поэтому ни одно значение не теряется.

У `Cond` есть серьёзное ограничение: ожидание `Wait` нельзя поставить веткой в `select`,
значит, его нельзя совместить с таймаутом или отменой через контекст. Поэтому многие
задачи, где напрашивается `Cond`, чище решаются каналами. `Cond` уместен, когда ждущих
много, у них общее состояние под мьютексом и нужно будить то одного (`Signal`), то всех
(`Broadcast`). Задача 08 (своя `WaitGroup`) как раз такая.

## sync.Pool

`Pool` — набор временных объектов для повторного использования. Он снижает число
выделений памяти и нагрузку на сборщик мусора там, где одни и те же короткоживущие
объекты создаются тысячи раз в секунду, например буферы в HTTP-обработчиках.

```go
var bufPool = sync.Pool{
    New: func() any { return new(bytes.Buffer) },
}

func handle() {
    b := bufPool.Get().(*bytes.Buffer)
    b.Reset()          // объект мог вернуться с данными прошлого использования
    defer bufPool.Put(b)
    // ... используем b как временный буфер
}
```

`Get` достаёт объект из пула или вызывает `New`, если пул пуст. `Put` возвращает объект
в пул. Рантайм может выбросить объекты из пула в любой момент, обычно при сборке мусора,
поэтому `Pool` — не пул соединений и не кэш: класть туда то, что обязано сохраниться
(соединения, открытые файлы), нельзя.

Два правила: сбрасывайте состояние объекта после `Get` (как `b.Reset()`) и не добавляйте
`Pool` без измерений. На редких выделениях он ничего не даст, а код усложнит. Запускаемого
примера здесь нет: эффект `Pool` виден только в бенчмарках под нагрузкой.

## Частые ошибки

- **Копирование `Mutex` или `WaitGroup` по значению.** Две независимые копии — нет защиты.
  Находится через `go vet`.
- **`Add` внутри горутины.** `Wait` может вернуться раньше времени.
- **`cond.Wait()` под `if`.** Условие нужно перепроверять в цикле.
- **Долгий ввод-вывод под `Lock`.** Все остальные ждут, пока идёт сетевой вызов.
- **`RWMutex` на крошечной секции.** Учёт читателей стоит дороже, чем экономия.
- **Чтение без мьютекса «потому что только читаю».** Если кто-то пишет, читатель тоже
  должен брать блокировку. Иначе это гонка данных.

## Мысленная модель

Каждый примитив отвечает на свой вопрос:

- `Mutex` — «по одному, пожалуйста»;
- `RWMutex` — «читать могут многие, писать — один»;
- `WaitGroup` — «дождаться, пока все закончат»;
- `Once` — «сделать один раз и запомнить»;
- `Cond` — «спать, пока не изменится состояние»;
- `Pool` — «не выделять заново то, что можно переиспользовать».

Сначала сформулируйте, какая синхронизация нужна, и только потом выбирайте инструмент.

## Попробуйте сами

Добавьте к `Counter` метод `Value() int`, который читает `n` под тем же мьютексом, что и
`Inc`. Напишите тест: 10 горутин делают по 100 вызовов `Inc`, тест ждёт всех и проверяет
`Value`. Запустите `go test -race .`. Какие обращения нужно защищать, если `Value`
вызывают, пока горутины ещё работают?

### Проверка

```go
func (c *Counter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.n
}
```

Тест в файле `counter_test.go` того же пакета:

```go
func TestCounter(t *testing.T) {
    c := &Counter{}
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for j := 0; j < 100; j++ {
                c.Inc()
            }
        }()
    }
    wg.Wait()
    if got := c.Value(); got != 1000 {
        t.Fatalf("Value() = %d; хотим 1000", got)
    }
}
```

Тесту нужны импорты `testing` и `sync`. Защищать нужно и запись в `Inc`, и чтение в
`Value`, одним и тем же мьютексом. `Wait` в тесте определяет момент итоговой проверки,
но не заменяет защиту отдельных обращений.

## Что дальше

Для одиночного счётчика или флага мьютекс избыточен: следующая глава,
[atomic](/go/book/atomic), показывает, как обойтись одной атомарной операцией и где
эта экономия заканчивается.

В задачах курса этой главе соответствует [блок 2](/go/topics/2) (задачи 05–08: счётчик, кэш с `RWMutex`,
однократная инициализация, своя `WaitGroup`) и [блок 5](/go/topics/5) (шардированная карта,
singleflight). В [блоке 7](/go/topics/7) мьютексы и `WaitGroup` работают вместе с каналами и
[context](/go/book/context) внутри пулов воркеров.
