Атомики в 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. Зная детали их реализации мы можем сказать, что атомики быстрее мьютексов без замеров. Однако тут стоит упомянуть, что использование атомиков ограниченно определенными случаями (не всегда они нам могут подойти).
