Атомики в Go - это один из методов синхронизации горутин. Они находятся в пакете стандартной библиотеки sync/atomic. Некоторые статьи сравнивают atomics с mutex, так как это примитивы синхронизации низкого уровня. Они предоставляют бенчмарки и сравнения по скорости, например Go: How to Reduce Lock Contention with the Atomic Package.
Однако важно понимать, что, хотя это примитивы синхронизации низкого уровня, они разные по своей сути. Прежде всего атомики являются "low-level atomic memory primitives", как отмечено в документации, то есть являются примитивами низкого уровня реализующими атомарные операции с памятью. В этой статье я расскажу про некоторые особенности их внутренней реализации и отличие от мьютексов.
Внутреннее устройство atomics
Давайте сначала возьмем пример из документации и рассмотрим операцию Swap:
The swap operation, implemented by the SwapT functions, is the atomic equivalent of:
old = *addr
*addr = new
return oldПод SwapT подразумеваются все Swap операции с различными типами данных. Для примера возьмем SwapInt64. Функция описана в sync/atomic/doc.go:
func SwapInt64(addr *int64, new int64) (old int64)Однако ее реализация уже не на Go, а на ассемблере и находится в sync/atomic/asm.s:
TEXT ·SwapInt64(SB),NOSPLIT,$0
JMP runtime∕internal∕atomic·Xchg64(SB)Однако здесь мы видим переход на другую функцию (простой jump) с именем Xchg64 и эта функция находится в рантайме Go. Тут мы уже можем видеть разделение по архитектурам процессоров.
Вот код для 64-bit Intel 386:
// uint64 Xchg64(ptr *uint64, new uint64)
// Atomically:
// old := *ptr;
// *ptr = new;
// return old;
TEXT ·Xchg64(SB), NOSPLIT, $0-24
MOVQ ptr+0(FP), BX
MOVQ new+8(FP), AX
XCHGQ AX, 0(BX)
MOVQ AX, ret+16(FP)
RETА этот для ARM 64:
// uint64 Xchg64(ptr *uint64, new uint64)
// Atomically:
// old := *ptr;
// *ptr = new;
// return old;
TEXT ·Xchg64(SB), NOSPLIT, $0-24
MOVD ptr+0(FP), R0
MOVD new+8(FP), R1
MOVBU internal∕cpu·ARM64+const_offsetARM64HasATOMICS(SB), R4
CBZ R4, load_store_loop
SWPALD R1, (R0), R2
MOVD R2, ret+16(FP)
RET
load_store_loop:
LDAXR (R0), R2
STLXR R1, (R0), R3
CBNZ R3, load_store_loop
MOVD R2, ret+16(FP)
RETСтоит здесь отметить, что Go использует свой язык ассемблера. Это сделано для компиляции под различные платформы и подробнее почитать об этом можно, например, здесь: A Quick Guide to Go's Assembler. Важно отметить, что компилятор оперирует полуабстрактным набором инструкций (semi-abstract instruction set). Выбор инструкций происходит частично после генерации кода. Например, операция MOV в итоге может быть как отдельной операцией, так и может быть преобразована в набор инструкций и это будет зависеть от архитектуры процессора. Сам же язык основан на Plan 9 assembler.
Таким образом мы не всегда можем по коду стандартной библиотеки быть уверенными, что не будет изменений в скомпилированном коде под нашу архитектуру. Давайте посмотрим какой код будет скомпилирован в итоге для рассматриваемой операции SwapInt64:
package main
import (
"sync/atomic"
)
func main() {
var old, new int64 = 1, 10
println(old, new)
new = atomic.SwapInt64(&old, new)
println(old, new)
}Я использовал IDA64 для анализа бинарного файла, но советую посмотреть на дизассемблированный код в Compiler Explorer (можно выбирать разные архитектуры, версии Go и т.п.). Интересно также будет взглянуть на этапы компиляции, представления ast и применяемые оптимизации в Go SSA Playground. Теперь давайте найдем в дизассемблированном коде функцию main:

Код для new = atomic.SwapInt64(&old, new) располагается ровно после первого call runtime_printunlock и до следующего сall runtime_printlock
mov ecx, 0Ah
mov rdx, [rsp+28h+var_10]
xchg rcx, [rdx]
mov [rsp+28h+var_18], rcxУ нас всего четыре инструкции: три mov и одна xchg. Дальнейший анализ затруднен, потому что количество тактов конкретной инструкции может зависеть от нескольких факторов, таких как модель и архитектура процессора, типы операндов (регистры, память) и некоторые другие условия (промах кеша, например). Если вам интересны более подробные расчеты и детали, то вы можете обратится к этому мануалу или к Intel® 64 and IA-32 Architectures Optimization Reference Manual (APPENDIX D. INSTRUCTION LATENCY AND THROUGHPUT).
Несмотря на сложности расчета скорости исполнения инструкций процессора мы можем видеть, что ассемблерный код минимальный и в большинстве случаев он, скорее всего, будет исполняться быстрее, чем реализация на mutex. Дальше мы посмотрим как устроен мьютекс, чтобы подтвердить предположения или их опр��вергнуть.
Внутреннее устройство mutex
Мьютекс гораздо более комплексная структура по сравнению с atomic, чем может показаться на первый взгляд. Прежде всего у нас есть два вида мютексов: sync.Mutex и sync.RWMutex. У каждого из них есть методы Lock и Unlock, у RWMutex есть дополнительно метод RLock (блокирование для чтения). В обоих типах методы Lock довольно долгие по сравнению с атомиками.
У Mutex код метода Lock включает два вида блокировки. Первый вариант - это когда удается захватить не заблокированный мьютекс, второй вариант довольно долгий и он запускается если мьютекс заблокирован:
// Lock locks m.
// If the lock is already in use, the calling goroutine
// blocks until the mutex is available.
func (m *Mutex) Lock() {
// Fast path: grab unlocked mutex.
if atomic.CompareAndSwapInt32(&m.state, 0, mutexLocked) {
if race.Enabled {
race.Acquire(unsafe.Pointer(m))
}
return
}
// Slow path (outlined so that the fast path can be inlined)
m.lockSlow()
}Как видите первый вариант довольно быстрый и включает в себя одну atomic операцию. Второй вариант включает довольно много кода и здесь подробно разбираться не будет: там также используются атомики в процессе блокировки, но очевидно, что он исполняется еще дольше первого варианта (Fast path).
У RWMutex код метода Lock включает в себя вызов метода Lock структуры Mutex:
// Lock locks rw for writing.
// If the lock is already locked for reading or writing,
// Lock blocks until the lock is available.
func (rw *RWMutex) Lock() {
if race.Enabled {
_ = rw.w.state
race.Disable()
}
// First, resolve competition with other writers.
rw.w.Lock()
// Здесь пропущена часть кода ...
}
}Да и сама струк��ура RWMutex включает в себя структуру Mutex как одно из полей:
type RWMutex struct {
w Mutex // held if there are pending writers
// Здесь пропущена часть кода ...
}Метод RLock у RWMutex гораздо быстрее и содержит меньше кода, но тем не менее не быстрее атомика, который там задействован:
func (rw *RWMutex) RLock() {
// Здесь пропущена часть кода ...
if atomic.AddInt32(&rw.readerCount, 1) < 0 {
// A writer is pending, wait for it.
runtime_SemacquireMutex(&rw.readerSem, false, 0)
}
// Здесь пропущена часть кода ...
}Заключение
В качестве заключение хотелось бы еще раз сказать, что сравнения производительности атомиков с мьютексами в Go будут не в пользу последних. В статье мы разобрали внутреннее устройство атомиков на примере SwapInt64 и немного посмотрели на внутреннее устройство Mutex и RWMutex. Зная детали их реализации мы можем сказать, что атомики быстрее мьютексов без замеров. Однако тут стоит упомянуть, что использование атомиков ограниченно определенными случаями (не всегда они нам могут подойти).
