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