Блокировки и атомики в Go: где sync.Mutex стоит дороже atomic
Счётчик метрик под нагрузкой в 500 горутин упёрся в contention на одном мьютексе. Разбираем, когда sync.Mutex, sync/atomic и sharding дают разную цену и как это измерить самому.

Сервис считал попадания в кеш простым счётчиком под sync.Mutex. При десятке параллельных запросов всё работало. При пятистах горутинах, дёргающих один и тот же счётчик, профиль go tool pprof показал, что заметная доля времени уходит не в бизнес-логику, а в runtime.lock2 — горутины стоят в очереди за одним замком. Заменили инкремент на atomic.Int64.Add — очередь исчезла, но появился новый эффект: cache line contention между ядрами. Разбираем, где какой примитив реально дешевле, чем платишь за каждый и как померить это на своей машине, а не поверить на слово.
Материал написан для Go 1.22+. Типизированные атомики (atomic.Int64, atomic.Pointer[T] и прочие) доступны с Go 1.19 — в более старых версиях придётся использовать функции atomic.AddInt64(&x, 1) напрямую.
Что вообще происходит под капотом
Три механизма закрывают разные задачи, и путать их — типичная причина того, что «атомик не помог».
sync.Mutex
Мьютекс в Go — гибридный. В неоспариваемом случае захват и освобождение — это одна атомарная CAS-операция1 над полем состояния, без вызова планировщика. Дорого становится под конкуренцией: если замок занят, горутина сначала крутится в spin-цикле несколько итераций, а потом паркуется через futex-подобный механизм рантайма. Парковка и пробуждение горутины — это уже переключение контекста и работа планировщика Go.
С Go 1.9 у мьютекса есть starvation mode: если горутина ждёт замок дольше 1 мс, мьютекс переключается в честный режим и передаёт владение напрямую ждущей горутине, чтобы избежать голодания. Это спасает от подвисаний, но не ускоряет hot path.
sync/atomic
Атомарные операции работают на уровне процессорных инструкций (на amd64 — LOCK XADD и аналоги, на arm64 — LSE-инструкции или LL/SC-цикл). Планировщик Go не участвует вообще: нет парковки, нет очереди горутин. Но у атомика своя цена — при записи из разных ядер в одну и ту же переменную работает протокол когерентности кеша MESI: cache line с переменной постоянно перегоняется между L1/L2 разных ядер. Это и есть contention, только на уровне железа, а не рантайма.
sync.RWMutex
Отдельный случай для «много читателей, редкий писатель». RLock тоже требует атомарной операции над счётчиком читателей, поэтому под очень высокой частотой чтений RWMutex сам становится точкой contention — читатели дерутся за один счётчик. Об этом ниже.
Атомик не «всегда быстрее мьютекса». Он убирает участие планировщика, но переносит конкуренцию на шину памяти. Если запись идёт в одну переменную из многих ядер — вы просто поменяли одну очередь на другую.
Замеры: один счётчик, разные примитивы
Меряем инкремент общего счётчика из N параллельных горутин через testing.B с b.RunParallel. Это стандартный способ нагрузить примитив конкуренцией, а не последовательными вызовами.
package counter
import (
"sync"
"sync/atomic"
"testing"
)
// Мьютекс вокруг обычного int64
type mutexCounter struct {
mu sync.Mutex
v int64
}
func (c *mutexCounter) Inc() {
c.mu.Lock()
c.v++
c.mu.Unlock()
}
// Атомарный счётчик
type atomicCounter struct {
v atomic.Int64
}
func (c *atomicCounter) Inc() {
c.v.Add(1)
}
func BenchmarkMutex(b *testing.B) {
c := &mutexCounter{}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
c.Inc()
}
})
}
func BenchmarkAtomic(b *testing.B) {
c := &atomicCounter{}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
c.Inc()
}
})
}
Запускаете с ростом параллелизма, чтобы увидеть, как деградирует каждый примитив под конкуренцией:
go test -bench=. -benchmem -cpu=1,2,4,8 -benchtime=2sФлаг -cpu=1,2,4,8 прогонит каждый бенчмарк с GOMAXPROCS равным 1, 2, 4 и 8. Смотреть надо не на абсолютное ns/op, а на то, как оно растёт при увеличении числа ядер: у мьютекса и атомика на одну переменную число будет расти, у шардированного счётчика (см. ниже) — оставаться почти плоским.
Конкретные цифры зависят от процессора, поэтому приводить их как истину бессмысленно — на вашем железе они будут другими. Важна форма кривой. Типичная картина на 8-ядерной amd64-машине: на -cpu=1 мьютекс и атомик близки (мьютекс без конкуренции — это одна CAS), а на -cpu=8 мьютекс проседает в разы сильнее атомика, потому что добавляется парковка горутин.
Три подхода к общему счётчику
| Подход | Что выигрываете | Чем платите |
|---|---|---|
| sync.Mutex | Простота, защищает любую составную операцию | Парковка горутин под конкуренцией, участие планировщика |
| atomic на одну переменную | Нет планировщика, простой инкремент дешёв | Cache line contention между ядрами при записи |
| Шардированный счётчик | Почти нет contention при записи | Чтение требует суммы по шардам, больше памяти |
Шардирование: убрать contention совсем
Если счётчик пишется часто, а читается редко (метрики, статистика), уберите общую точку записи. Раскладываете счётчик по шардам, каждый на своей cache line, чтобы избежать false sharing2:
package counter
import (
"runtime"
"sync/atomic"
)
// 64 байта — типичный размер cache line на amd64.
// Padding не даёт соседним счётчикам делить одну линию.
type paddedCounter struct {
v atomic.Int64
_ [56]byte // 64 - 8 байт под int64
}
type shardedCounter struct {
shards []paddedCounter
}
func newSharded() *shardedCounter {
// По шарду на логический процессор
n := runtime.GOMAXPROCS(0)
return &shardedCounter{shards: make([]paddedCounter, n)}
}
// Пишем в шард по индексу текущего P.
// runtime_procPin недоступен снаружи, поэтому
// на практике используют хеш от goroutine-локального
// значения или готовую библиотеку — см. раздел ниже.
func (c *shardedCounter) Add(idx, delta int64) {
c.shards[idx%int64(len(c.shards))].v.Add(delta)
}
func (c *shardedCounter) Sum() int64 {
var total int64
for i := range c.shards {
total += c.shards[i].v.Load()
}
return total
}
Тонкость: чтобы шардирование реально работало, каждая горутина должна писать преимущественно в «свой» шард — иначе вы получите тот же contention. Идеального публичного API для «индекса текущего процессора» в стандартной библиотеке нет: runtime.procPin непубличный. На практике берут готовые решения — например, счётчики из go.uber.org/atomic или подход с раскладкой по хешу. Не изобретайте велосипед с unsafe, если не уверены в раскладке памяти на целевой платформе.
RWMutex и ловушка читателей
Соблазн взять sync.RWMutex для «много читателей» оправдан, только если под замком делается заметная работа. Если критическая секция — это чтение одного поля, RLock из десятков горутин сам создаёт contention на внутреннем счётчике читателей. Для «прочитать один указатель, который иногда меняется» правильнее atomic.Pointer[T]:
import "sync/atomic"
type Config struct {
Timeout int
}
var cfg atomic.Pointer[Config]
// Читатели — без замка вообще
func current() *Config {
return cfg.Load()
}
// Писатель заменяет указатель целиком
func reload(c *Config) {
cfg.Store(c)
}
Здесь читатели никогда не блокируются, а публикация нового конфига — одна атомарная запись указателя. Работает только если объект после публикации не мутируется: меняете указатель, а не поля внутри.
Когда что выбирать
- Критическая секция защищает несколько полей или составную операцию (проверил-и-изменил над структурой) —
sync.Mutex. Атомик тут не заменяет замок:atomic.Load+ логика +atomic.Storeне атомарны как целое. - Один счётчик, читается наравне с записью —
atomic.Int64. Проще мьютекса и без планировщика. - Счётчик пишется очень часто, читается редко (метрики) — шардирование с padding.
- Значение читается постоянно, меняется целиком и редко —
atomic.Pointer[T], читатели без замка. - Читатели делают заметную работу под замком, писатель редок — вот здесь
sync.RWMutexоправдан.
Что ломается на практике
Частые ошибки, которые всплывают в проде и на code review:
- False sharing. Два
atomic.Int64в одной структуре подряд ложатся на одну cache line. Пишете в оба из разных горутин — получаете contention, которого в коде не видно. Лечится padding или разнесением по разным структурам. - Копирование примитива.
sync.Mutexиatomic.Int64нельзя копировать после первого использования. Передали структуру со счётчиком по значению — получили две несвязанные копии.go vetловит копирование мьютекса; для атомиков используйте указатель на структуру. - Атомик вместо замка для составной операции.
if c.v.Load() == 0 { c.v.Store(1) }— не атомарно, между Load и Store влезет другая горутина. НуженCompareAndSwap. - RWMutex под частым чтением. Профиль показывает время в
sync.(*RWMutex).RLock— читателей слишком много, переходите на atomic.Pointer.
Как проверить у себя и что мерить в проде
Один синтетический бенчмарк не заменяет измерение на реальной нагрузке. Порядок действий:
- Соберите профиль под нагрузкой:
go tool pprofпо CPU-профилю. Ищите время вruntime.lock2,sync.(*Mutex).lockSlow,sync.(*RWMutex).RLock— это признак contention на замке. - Включите mutex-профиль:
runtime.SetMutexProfileFraction(rate)и смотритеgo tool pprofпо mutex-профилю — он покажет, на каком именно замке горутины ждут дольше всего. - Прогоните бенчмарк кандидата с
-cpu=1,2,4,8и сравните форму деградации, а не одну цифру. - После замены примитива снова снимите профиль под той же нагрузкой — убедитесь, что contention ушёл, а не переехал в другое место (например, из мьютекса в cache line).
Дальше — читайте раздел про sync/atomic и модель памяти в официальной документации Go: гарантии видимости у атомиков описаны в The Go Memory Model, и без их понимания легко построить код, который «работает на моей машине» и падает на другой архитектуре. Инвалидация кеша конфигов и координация нескольких atomic.Pointer — отдельная тема, здесь мы разбирали только выбор примитива под один разделяемый ресурс.
1 CAS (compare-and-swap) — атомарная операция «сравни значение с ожидаемым и, если совпало, запиши новое». Основа большинства lock-free алгоритмов.
2 False sharing — ситуация, когда две независимые переменные попадают в одну cache line процессора, и запись в одну заставляет инвалидировать линию с обеими, создавая contention там, где логически его нет.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: The Go Memory Model, sync/atomic package documentation
Частые вопросы
У меня Go 1.18, там нет atomic.Int64. Что делать?
Я заменил мьютекс на атомик, но быстрее не стало. Почему?
Насколько атомик реально быстрее мьютекса в цифрах?
Можно ли заменить sync.Mutex атомиками всегда?
Как найти, на каком замке тормозит система в проде?
Нужен ли padding для каждого atomic-счётчика?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.