Атомарные операции против мьютексов в Go: когда скорость становится проблемой
- среда, 21 января 2026 г. в 00:00:07
Команда Go for Devs подготовила перевод статьи о том, действительно ли атомарные операции всегда быстрее и лучше мьютексов в конкурентном коде. Автор разбирает реальные сценарии, показывает бенчмарки и объясняет, почему рост производительности часто оборачивается ростом сложности и рисков для корректности.
Недавно при ревью pull request’а разгорелось обсуждение о том, что лучше использовать — sync/atomic или sync.RWMutex.
Это вопрос, который регулярно всплывает при написании конкурентного кода на Go, поэтому я решил, что из этого получится хороший пост.
Для тех, у кого нет времени читать всё целиком, вот основные выводы:
Атомарные операции: могут быть быстрее при обновлении одного общего значения из нескольких потоков (или горутин).
Mutex / RWMutex: добавляют накладные расходы, но лучше подходят для операций, состоящих из нескольких шагов или затрагивающих несколько полей.
Атомарные операции защищают одну инструкцию: они не гарантируют корректность последовательности операций (чтение → логика → запись).
С ростом сложности: по мере усложнения логики (циклы compare-and-swap (CAS), повторы, согласованность нескольких полей) атомарные операции становятся всё сложнее для понимания и сопровождения.
Ментальная модель:
Mutex / RWMutex: всё, что находится внутри блокировки, — это единое целое.
Атомарные операции: защищена только эта конкретная операция.
Эмпирическое правило: начинайте с мьютекса (RWMutex — если преобладают чтения); переходите на атомарные операции как на оптимизацию, когда требования к производительности и сценарии использования это оправдывают.
В этой статье мы разберём атомарные операции и мьютексы, чем они отличаются с точки зрения производительности и поведения, и в каких случаях каждый из них уместен.
Сначала мы рассмотрим механизмы управления конкурентным доступом и то, как атомарные операции и мьютексы принципиально по-разному защищают общие данные. Затем посмотрим на простые бенчмарки, чтобы понять их характеристики производительности. Наконец, мы усложним пример (как это происходит в реальных приложениях), чтобы показать, в каких местах дизайны на основе атомарных операций начинают становиться хрупкими.
Цель статьи — не доказать, что один инструмент всегда лучше другого, а показать, почему атомарные операции могут быть быстрее и почему эта скорость часто достигается ценой усложнения и рисков для корректности.
Все примеры кода в этой статье написаны на Go и доступны на GitHub, включая бенчмарки.
https://github.com/madflojo/atomics-v-rwmutex-examples
Хотя в примерах используется Go, сами концепции применимы и к другим языкам программирования. По ходу статьи я буду отдельно отмечать детали, специфичные для Go.
Управление доступом к общим данным — одна из фундаментальных задач конкурентного программирования. Атомарные операции и мьютексы — два основных инструмента, которые позволяют делать это безопасно.
Прежде чем переходить к их различиям, давайте разберёмся, зачем вообще нужны механизмы конкурентного доступа.
Незащищённый конкурентный доступ к общим данным в многопоточном (или любом другом конкурентном) приложении может приводить к гонкам данных и непредсказуемому поведению.
Гонка данных возникает, когда два или более потока (или горутины) одновременно обращаются к одной и той же области памяти. Несколько операций чтения могут выполняться безопасно, но если хотя бы одна из операций — запись, результат становится непредсказуемым.

В лучшем случае операция чтения получит результат последней записи. Однако никаких гарантий на это нет.
В худшем случае операция чтения может увидеть устаревшие или частично записанные данные, что приводит к ошибкам, которые сложно воспроизвести и отладить. В Go (как и в других языках) некоторые гонки данных могут даже приводить к panic или падениям, делая приложение нестабильным.
Атомарные операции и мьютексы — это два механизма контроля доступа к общим данным. Они решают одну и ту же задачу, но на совершенно разных уровнях абстракции. Оба инструмента помогают координировать чтения и записи между потоками (или горутинами), обеспечивая целостность данных и предотвращая состояния гонки.
Эти инструменты различаются тем, какой объём координации они предоставляют, а это напрямую влияет и на корректность, и на производительность. Понимание этих различий — ключ к правильному выбору. Начнём с мьютексов, которые предлагают сильную и интуитивно понятную модель управления конкурентным доступом.
mu := sync.Mutex{}
mu.Lock()
// Perform operations on shared data
mu.Unlock()Мьютекс (сокращение от mutual exclusion, «взаимное исключение») — это примитив синхронизации, который предоставляет эксклюзивный доступ к общим данным. В каждый момент времени блокировку может удерживать только один поток (или горутина).
Подход предельно прост: захватить блокировку, выполнить операции с общими данными и затем освободить блокировку. Хотя в Go под капотом есть оптимизации рантайма (о них мы поговорим позже), базовое поведение одинаково для большинства языков программирования.
При наличии конкурентных читателей и писателей мьютекс обеспечивает жёсткую координацию, принудительно вводя эксклюзивный доступ. Как только поток (или горутина) захватывает блокировку, все остальные потоки, пытающиеся получить тот же мьютекс, вынуждены ждать, пока он не будет освобождён.


За счёт эксклюзивного доступа мьютексы устраняют гонки данных и позволяют легко сохранять целостность данных. Обратная сторона этой гарантии — некоторая конкуренция и накладные расходы, так как потокам (или горутинам) может приходиться ждать доступа к общим данным. Однако на практике эти накладные расходы часто несущественны, и преимущества в виде простоты и корректности обычно перевешивают издержки по производительности.
mu := sync.RWMutex{}
// Write lock
mu.Lock()
// Perform write operations
mu.Unlock()
// Read lock
mu.RLock()
// Perform read operations (never write within a read lock)
mu.RUnlock()Мьютекс для чтения/записи (RWMutex) — это разновидность мьютекса, которая предоставляет отдельные блокировки для чтения и записи. Блокировка на запись ведёт себя так же, как обычный мьютекс (эксклюзивный доступ), тогда как блокировка на чтение позволяет нескольким потокам (или горутинам) одновременно удерживать read-блокировку.
Как уже упоминалось, несколько конкурентных операций чтения безопасны до тех пор, пока во время этих чтений не происходит запись. За счёт разделения блокировок на чтение и запись RWMutex позволяет оптимизировать производительность в нагрузках, где преобладает чтение, разрешая читателям выполняться параллельно. В реализации Go, как только писатель встаёт в ожидание, новые читатели ставятся в очередь за ним, чтобы избежать голодания писателей.
Если взять предыдущий пример и заменить обычный мьютекс на RWMutex, можно увидеть, как меняется поведение.


Хотя блокировки на запись по-прежнему обеспечивают эксклюзивный доступ для одного потока (или горутины), блокировки на чтение могут выдаваться сразу нескольким читателям. Для часто используемых данных — например, конфигурации, которая часто читается, но редко обновляется, — RWMutex может значительно снизить конкуренцию, разрешая параллельное чтение.
В своём докладе на Monster Scale Summit 2025 — «Scaling Payments Routing at American Express» — я рассказывал о том, как в нашем Global Transaction Router перевод некоторых мьютексов (например, защищающего конфигурацию маршрутизации) на RWMutex дал заметный прирост производительности.
Хотя RWMutex может быть мощной оптимизацией для сценариев с преобладанием чтения, им очень легко воспользоваться неправильно.
Распространённые ошибки, которые мне доводилось видеть:
1.Несоответствие пар lock/unlock.
При работе с RWMutex критически важно сопоставлять RLock с RUnlock, а Lock — с Unlock. Если после RLock по ошибке вызвать Unlock (вместо RUnlock), это может привести к дедлокам или panic.
Линтеры помогают выявлять такие ошибки, но мне всё равно приходилось тратить время на отладку тестов, которые зависали именно из-за этой проблемы.
2. Запись под блокировкой на чтение.
По мере изменения требований методы, которые изначально были только для чтения, могут начать выполнять запись. Если вы или следующий разработчик, работающий с кодом, забудете (или не поймёте), что используется read-блокировка, это может привести к гонкам данных, описанным ранее.
Мьютексы для чтения и записи — мощный инструмент, но они требуют аккуратного использования, чтобы избежать подводных камней. При этом мьютексы и RWMutex остаются простым и эффективным способом координации доступа к общим данным. Далее мы рассмотрим атомарные операции, которые решают ту же задачу, но с другой стороны.
var n atomic.Int64
// Write operation
n.Add(1)
// Read operation
y := n.Load()Атомарные операции обеспечивают безопасный конкурентный доступ, опираясь на низкоуровневые инструкции CPU и протоколы когерентности кэша, которые гарантируют, что каждая операция над одним значением выполняется атомарно (неделимо). В отличие от мьютексов, атомарные операции не координируют несколько шагов — они делают атомарной каждую отдельную операцию.
В Go мьютексы управляются рантаймом. Блокировка позволяет потоку (или горутине) запретить другим доступ к общим данным до тех пор, пока она не будет освобождена. Атомарные операции работают принципиально иначе. Каждая операция атомарна и не может быть наблюдена (прочитана или записана) «на полпути». Несколько потоков (или горутин) могут работать с одним и тем же значением, но в каждый момент времени может завершиться только одна атомарная операция над этим значением.
Если вернуться к предыдущему примеру с конкурентными читателями и писателями, можно увидеть, что атомарные операции фокусируются на упорядочивании отдельных операций, а не на координации доступа к общим данным.

Проще всего думать об атомарных операциях так: инструкции CPU и протоколы когерентности кэша гарантируют, что операции над общим значением в памяти выполняются строго по одной. Здесь нет понятий «несколько читателей» или «эксклюзивный писатель» — каждая операция упорядочена и атомарна.
Поскольку атомарные операции реализуются через низкоуровневые аппаратные инструкции, каждая из них выполняется очень быстро. Более того, Go использует атомарные операции внутри своего рантайма для оптимизации работы мьютексов.
Атомарность и высокая скорость делают атомарные операции весьма привлекательными. Однако из-за того, что они защищают только одну инструкцию, их легко использовать неправильно, если корректность зависит более чем от одной операции.
Вместо того чтобы полагаться исключительно на мьютексы уровня операционной системы, рантайм Go содержит оптимизацию «быстрого пути» для захвата мьютексов, которая использует атомарные операции.
Эта оптимизация работает так: сначала рантайм пытается сымитировать захват блокировки с помощью атомарной операции. Если атомарная операция проходит успешно, блокировка немедленно возвращается вызывающему коду, что позволяет избежать накладных расходов полноценного мьютекса уровня ОС. Если атомарная операция не удаётся, Go повторяет попытку ограниченное число раз, прежде чем перейти к мьютексу уровня операционной системы. Такой подход хорошо работает при кратковременной конкуренции, позволяя горутинам получать блокировку без «парковки» со стороны ОС.
Диаграмма ниже иллюстрирует последовательность операций при захвате мьютекса в Go.

Go может оптимизировать мьютексы таким образом, потому что управляет горутинами внутри собственного рантайма. Это означает, что во многих случаях блокировки могут захватываться и освобождаться быстро, без накладных расходов полноценного мьютекса уровня ОС. При этом важно помнить, что это оптимизация реализации рантайма, а не гарантия языка, и в будущих версиях Go она может измениться.
Лучше исходить из того, что на уровне отдельных операций мьютексы медленнее атомарных операций. Если в какой-то момент они оказываются столь же быстрыми — считайте это приятным побочным эффектом оптимизаций рантайма.
Прежде чем перейти к примерам реализации, приведём краткую сводную таблицу с описанием рассмотренных механизмов конкурентного доступа.
Тип | Описание |
|---|---|
Mutex | Эксклюзивный доступ к общим данным. Защищает многошаговые операции и инварианты. |
RWMutex | Позволяет нескольким читателям работать одновременно, но обеспечивает эксклюзивный доступ для писателей. Лучше всего подходит для нагрузок с преобладанием чтения. |
Атомарные операции | Атомарный доступ к одному общему значению с использованием низкоуровневых аппаратных инструкций. |
Теперь, когда мы разобрали теорию атомарных операций и мьютексов, давайте реализуем оба подхода на простом примере.
Чтобы наглядно показать разницу в производительности, мы создадим нарочито упрощённый пакет, который управляет балансом счёта. Интерфейс намеренно сделан простым, чтобы сфокусироваться на механизмах конкурентного доступа, а не на всей сложности реального приложения.
type Balance interface {
Balance() int64
Add(amount int64)
Subtract(amount int64) error
}Интерфейс Balance позволяет пополнять и списывать средства, а также получать текущий баланс. Просто, но достаточно, чтобы продемонстрировать различия между атомарными операциями и мьютексами.
В этом разделе мы реализуем этот интерфейс двумя способами: наивным вариантом на атомарных операциях и вариантом на мьютексе для чтения/записи. Затем запустим набор бенчмарков, чтобы показать различия в производительности между этими реализациями.
Два бенчмарка, которые мы запустим:
Тест-кейс | Описание |
|---|---|
BalanceAdd | Конкурентно вызывает |
BalanceAddWithRead | Конкурентно вызывает |
Цель этих тестов — прежде всего показать различия в производительности между атомарными операциями и мьютексами при конкуренции за общий ресурс. Разумеется, к бенчмаркам стоит относиться с долей скепсиса: на результаты могут влиять архитектура CPU, версия Go и нагрузка на систему. Но в качестве ориентира эти тесты должны сработать.
Сами бенчмарки доступны в репозитории GitHub, на который мы ссылались ранее.
На первый взгляд реализация интерфейса Balance с использованием атомарных операций выглядит прямолинейно.
type AtomicBalance struct {
value atomic.Int64
}Внутри структуры мы храним баланс как atomic.Int64. Это позволяет безопасно выполнять атомарные операции над балансом из нескольких горутин.
func (b *AtomicBalance) Balance() int64 {
return b.value.Load()
}
func (b *AtomicBalance) Add(amount int64) {
b.value.Add(amount)
}
func (b *AtomicBalance) Subtract(amount int64) error {
b.value.Add(-amount)
return nil
}Чтобы пополнить или списать средства, мы просто вызываем метод Add() у atomic.Int64. Такая реализация проста, безопасна при конкурентном использовании и удовлетворяет нашим нарочито упрощённым требованиям по управлению балансом.
Реализация интерфейса Balance на мьютексе для чтения/записи тоже довольно прямолинейна — особенно для тех, кто уже знаком с Go.
type RWMutexBalance struct {
mu sync.RWMutex
value int64
}Внутри структуры мы создаём RWMutex. Мьютекс для чтения/записи здесь уместен, потому что мы хотим дать нескольким читателям возможность одновременно проверять баланс, сохраняя при этом контроль над доступом на запись при пополнении или списании средств. Это намеренно немного преждевременная оптимизация, но в реальных приложениях проверка баланса зачастую происходит чаще, чем его изменение. Поэтому имеет смысл сразу заложить этот паттерн.
Когда мьютекс на месте, для чтения или записи баланса нужно захватывать соответствующую блокировку.
func (b *RWMutexBalance) Balance() int64 {
b.mu.RLock()
defer b.mu.RUnlock()
return b.value
}
func (b *RWMutexBalance) Add(amount int64) {
b.mu.Lock()
defer b.mu.Unlock()
b.value += amount
}При получении баланса мы захватываем read-блокировку с помощью RLock(), что позволяет нескольким читателям работать параллельно и повышает производительность в нагрузках, где преобладает чтение.
При пополнении баланса мы захватываем блокировку на запись с помощью Lock(), что обеспечивает эксклюзивный доступ к балансу на время обновления.
Теперь, когда обе реализации готовы, можно запускать бенчмарки и сравнивать их производительность.
К бенчмаркам всегда стоит относиться с долей скепсиса. Различия в железе, изменения версий Go и текущая нагрузка на систему — всё это может влиять на результаты. Если вы запустите эти бенчмарки (это можно сделать через репозиторий GitHub, на который мы ссылались ранее), числа у вас могут отличаться.
Этот раздел нужен исключительно для иллюстрации разницы в производительности между атомарными операциями и мьютексами при конкуренции за общий ресурс. Не стоит ожидать, что ваши результаты будут точно такими же.
Результаты в этой статье получены на следующей конфигурации:
Go version: 1.25.5
CPU: ARM64 Apple M2 (всего 8 ядер: 4 производительных + 4 энергоэффективных)
System: Mac Mini (2023)
Впрочем, эти характеристики не принципиальны: не зацикливайтесь на числах — смотрите на относительную разницу между атомарными операциями и мьютексами.
Базовые реализации, которые мы сделали на этом этапе, будут склоняться в пользу атомарных операций по производительности. Но производительность — не единственный фактор при выборе между атомарными операциями и мьютексами.
Также имейте в виду, что эти бенчмарки отражают накладные расходы на координацию и не учитывают корректность, а также не показывают реальную пропускную способность системы под настоящей, «боевой» нагрузкой.

Более высокие значения ns/op означают, что каждая операция выполняется дольше (общая пропускная способность ниже), поэтому более короткие столбцы — лучше.
Тест-кейс | Atomic (ns/op) | RWMutex (ns/op) | Преимущество |
|---|---|---|---|
BalanceAdd | ~34 ns/op | ~110 ns/op | ~в 3,2 раза быстрее |
BalanceAddWithRead | ~38 ns/op | ~119 ns/op | ~в 3,1 раза быстрее |
Как и ожидалось, атомарные операции превосходят реализацию на RWMutex. В обоих тестовых сценариях атомарные операции примерно в 3 раза быстрее, чем вариант с RWMutex.
В этот момент у вас может возникнуть вопрос: «Зачем вообще использовать что-то кроме атомарных операций, если они выглядят настолько быстрее?»
Рад, что вы об этом задумались.
В следующем разделе мы увидим, как наша простая реализация на атомарных операциях начинает разваливаться, как только корректность начинает зависеть не от одной-единственной инструкции.
Наши первоначальные реализации были намеренно простыми, чтобы наглядно показать разницу в производительности между атомарными операциями и мьютексами. Однако реальные приложения почти всегда требуют более сложной бизнес-логики, а это может существенно увеличить сложность кода и снизить его поддерживаемость при использовании атомарных операций.
Чтобы продемонстрировать эти проблемы, давайте усложним требования и реализации.
Изменения требований:
Запретить уход баланса в отрицательное значение при списании средств.
Хранить вместе с балансом счётчик транзакций и время последнего обновления.
Эти изменения выглядят незначительными, но они сразу проявляют компромиссы между атомарными операциями и мьютексами.
Атомарные операции быстрые, но при этом очень примитивные. Как только в логику добавляется сложность, код на атомарных операциях быстро начинает усложняться.
В предыдущей упрощённой реализации мы никак не ограничивали баланс. Теперь же нам нужно гарантировать, что баланс никогда не станет отрицательным. Для этого необходимо сначала прочитать текущее значение баланса и лишь затем решать, можно ли выполнять списание.
Если реализовать эту логику наивно, как и раньше, мы можем незаметно допустить уход баланса в минус.
В этом коде есть ошибки
func (b *AtomicBalance) Subtract(amount int64) error {
current := b.value.Load()
// As time passes here, other goroutines may modify b.value
if current-amount < 0 {
return ErrInsufficientFunds
}
b.value.Add(-amount)
return nil
}Если мы просто читаем баланс, проверяем, не станет ли он отрицательным, и затем выполняем списание, мы создаём состояние гонки.

Логика предполагает, что баланс не изменится между чтением и записью нового значения. В высококонкурентном приложении это неверно.
Атомарные операции гарантируют атомарность каждой отдельной операции: чтение атомарно и запись атомарна. Но ничто не гарантирует, что между этими операциями (во время проверки будущего значения) баланс не будет изменён.
Если бездумно выполнять списание, мы можем получить отрицательный баланс. Поэтому нам нужно удостовериться, что баланс не изменился с момента последнего чтения.
Операция Compare-And-Swap (CAS) — это распространённый приём при работе с атомарными операциями. Она позволяет прочитать значение, сравнить его с ожидаемым и заменить на новое, если значения совпадают.
func (b *AtomicBalance) Subtract(amount int64) error {
// This may retry indefinitely when there is high contention
// There should probably be a max retry count to avoid infinite loops
for {
current := b.value.Load()
next := current - amount
if next < 0 {
return ErrInsufficientFunds
}
// Check if the value is still what we expect, and swap it.
// Other goroutines may have modified b.value in the meantime.
// If it was modified, the CAS will be false, and the loop retries.
if b.value.CompareAndSwap(current, next) {
return nil
}
}
}С помощью CompareAndSwap() мы можем гарантировать, что обновляем баланс только в том случае, если он не изменился с момента чтения. Но каждый раз, когда CAS не проходит (потому что другая горутина изменила баланс), нам приходится повторять всю операцию.
CAS обеспечивает корректность, но ценой усложнения кода.
Когда нужно прекращать повторы?
Как обрабатывать ошибки после слишком большого числа попыток?
Да, в этом случае атомарные операции быстрее, но эта скорость достигается за счёт сложности. Как только появляются логика повторов, тайм-ауты и обработка отказов, код становится труднее читать и сопровождать — особенно для тех, кто не знаком с атомарными операциями и CAS-паттернами.
Для сравнения, реализация списания с использованием мьютекса выглядит значительно проще.
func (b *RWMutexBalance) Subtract(amount int64) error {
b.mu.Lock()
defer b.mu.Unlock()
if b.value-amount < 0 {
return ErrInsufficientFunds
}
b.value -= amount
return nil
}Поскольку мы выполняем запись, сначала захватывается блокировка на запись. Это предотвращает любые другие чтения или записи баланса до тех пор, пока блокировка не будет освобождена. В результате мы можем безопасно прочитать баланс, проверить условие и выполнить списание.
Любая другая горутина, которая попытается вызвать Balance(), Add() или Subtract(), будет заблокирована до освобождения мьютекса. Это гарантирует корректность не только внутри метода Subtract(), но и между всеми методами, работающими с балансом.
Никаких повторов, никаких тайм-аутов, существенно меньшая сложность — но ценой некоторой конкуренции и накладных расходов.
Даже после перехода на CAS для корректного списания средств остаётся ещё одно требование: нужно поддерживать счётчик транзакций и время последнего обновления вместе с балансом.
Мы можем без труда добавить эти поля в AtomicBalance и обновлять их по мере необходимости.
type AtomicBalance struct {
value atomic.Int64
trx atomic.Int64
updated atomic.Int64
}
func (b *AtomicBalance) Add(amount int64) {
b.value.Add(amount)
b.trx.Add(1)
b.updated.Store(time.Now().UnixNano())
}Метод Add() теперь обновляет баланс, увеличивает счётчик транзакций и записывает время последнего обновления. Но из-за природы атомарных операций эти обновления происходят независимо друг от друга, то есть не синхронизированы.
Если нас устраивает eventual consistency, это может быть допустимо — речь идёт о наносекундах. В течение короткого времени читатель может увидеть обновлённый баланс, но счётчик транзакций и метка времени ещё не будут отражать это изменение. Для многих приложений, например финансовых систем, такое рассогласование может приводить к проблемам с целостностью данных.
Хотя атомарные операции можно использовать и для синхронизации этих полей (например, атомарно подменяя указатель на неизменяемую структуру), это влечёт за собой дополнительную сложность: аллокации, copy-on-write-обновления, CAS-циклы и строгие гарантии неизменяемости.
В противоположность этому реализация с RWMutex позволяет легко поддерживать синхронность данных, сохраняя при этом понятность кода.
func (b *RWMutexBalance) Add(amount int64) {
b.mu.Lock()
defer b.mu.Unlock()
b.value += amount
b.trx++
b.updated = time.Now().UnixNano()
}Пока удерживается блокировка на запись, мы можем безопасно обновить все три поля одновременно, не опасаясь, что другие потоки (или горутины) будут читать или изменять их в процессе. Эксклюзивный доступ гарантирует, что баланс, счётчик транзакций и время последнего обновления всегда находятся в согласованном состоянии.
При работе с одиночными операциями над отдельными полями атомарные операции действительно отлично подходят. Но когда требуется согласованно обновлять несколько полей, мьютексы дают простой и надёжный способ обеспечить целостность данных.
Выбор механизма конкурентного доступа всегда зависит от конкретного сценария и требований вашего приложения.
Чтобы кратко подытожить, когда стоит использовать атомарные операции, а когда — мьютексы (или RWMutex), ниже приведена справочная таблица с типовыми сценариями.
Сценарий | Atomic | RWMutex | Mutex |
|---|---|---|---|
Один числовой счётчик, к которому постоянно обращаются | ✅ Оптимально | ⚠️ Избыточно | ⚠️ Избыточно |
Конфигурация, часто читаемая многими горутинами | ⚠️ Сложно реализовать корректно | ✅ Оптимально | ❌ Высокая конкуренция |
Десятки независимых счётчиков метрик | ✅ Оптимально | ⚠️ Избыточно | ⚠️ Избыточно |
Последовательности read-modify-write, которые должны быть атомарными | ❌ Склонны к логическим ошибкам | ⚠️ Требует осторожности — помогает при доминировании чтений, но всё равно требует аккуратного использования RLock/Lock | ✅ Оптимально |
Связанные поля, которые нужно обновлять вместе (баланс + таймстемп + счётчик) | ❌ Сложно поддерживать согласованность | ⚠️ Требует осторожности — помогает при доминировании чтений, но всё равно требует аккуратного использования RLock/Lock | ✅ Оптимально |
Общее состояние с преобладанием записей, к которому обращаются многие горутины | ⚠️ Быстро, если каждое обновление — одна атомарная инструкция | ❌ Учёт читателей добавляет накладные расходы, а писатели всё равно блокируют всех | ✅ Оптимально |
И атомарные операции, и мьютексы — отличные инструменты для управления конкурентным доступом к данным. Но важно понимать, когда использовать один, а когда другой. Разумеется, это «когда» зависит от задачи, которую вы решаете, и от конкретных ограничений вашей системы.
Если вернуться к нашему примеру, на старте атомарные операции выглядели оправданно и давали заметный выигрыш по производительности. Но по мере роста требований (появился счётчик транзакций и время последнего обновления) использование атомарных операций стало сложнее, привело к eventual consistency и логическим ошибкам.
В то же время реализация на мьютексе оставалась прямолинейной и легко читаемой даже при росте сложности. Цена этого — конкуренция и накладные расходы, но во многих случаях преимущества в виде простоты и корректности перевешивают издержки по производительности.
Моё личное мнение: лучше начинать с мьютекса (или RWMutex, если нагрузка в основном на чтение), а затем оптимизировать с помощью атомарных операций там, где это действительно оправдано — и желательно после измерений бенчмарками или профилировщиком. Даже если вы хорошо понимаете атомарные операции, следующий разработчик, который будет работать с вашим кодом, может этого не понимать. Смещение приоритета в сторону читаемости и поддерживаемости кода часто стоит небольшого компромисса по производительности.

Друзья! Эту статью подготовила команда «Go for Devs» — сообщества, где мы делимся практическими кейсами, инструментами для разработчиков и свежими новостями из мира Go. Подписывайтесь, чтобы быть в курсе и ничего не упустить!