golang

Атомики в Go: особенности внутренней реализации

  • суббота, 1 июля 2023 г. в 00:00:27
https://habr.com/ru/articles/744822/

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