golang

Когда atomic действительно быстрее Mutex

  • понедельник, 14 сентября 2026 г. в 00:00:12
https://habr.com/ru/articles/1081648/

Введение

Привет, Хабр! Эта статья посвящается важной теме работы в многопоточной среде. Для безопасной работы с одним ресурсом из разных горутин разработчик должен быть уверен в безопасности и обособленности действий, чтобы не создать ситуации, в которых "гонки данных" (data race) ломали бы важную логику и создавали коллизии данных.

В случаях, когда нужно обращаться к одному источнику из разных горутин в Go обычно используют sync/atomic или MutexОсновная задача текущей статьи рассказать об их отличиях и о тех случаях, когда лучше применять atomic, чем sync.Mutex. Перед тем, как перейти к различиям, необходимо рассмотреть эти способы по ближе.

sync/atomic

Атомарные операции (atomic) - это операции, которые выполняются полностью или не выполняются вообще. Подобные операции не делимы и часто применяются для выполнения какого-то одного действия. Примером таких действий может быть: чтение, запись, сложение, или сравнение. Такая операция превращается в одну инструкцию для процессора, что позволяет избежать лишних блокировок.

Ниже рассматриваются некоторые методы из пакета sync/atomic для использования атомарных операций.

atomic.Load

Функция Load используется для атомарного чтения значений из переменной. Она позволяет безопасно получать значение, которое одновременно может измениться другими горутинами.

var isReady int32 = 0


// Атомарно читаем общие данные:
ready := atomic.LoadInt32(&isReady) // LoadInt32() - для чтения именно int32 типа
fmt.Printf("%v", ready)

Для разных типов в пакете sync/atomic существуют соответствующие функции. Например, LoadInt32() используется для атомарного чтения типа int32.

atomic.Store

Функция Store используется для атомарной записи значений.

var isReady int32 = 0

// Заносим данные в переменную по адресу в памяти:
atomic.StoreInt32(&isReady, 1)
fmt.Printf("%v", isReady)

Атомарная запись значения гарантирует, что другие горутины не увидят промежуточного состояния операции. При этом Store не блокирует другие горутины и не запрещает им выполнять последующие записи в ту же переменную, вместо этого использует низкоуровневые инструкции процессора.

atomic.Add

Функция Add необходима для атомарного сложения. Она очень удобна в случаях, когда нужно повысить значение данных (к примеру того же пресловутого счётчика).

var isReady int32 = 0

atomic.AddInt32(&isReady, 1)
fmt.Printf("%v", isReady)

atomic.CompareAndSwap

Функция CompareAndSwap является очень интересной. Она сравнивает значение из переменной с тем, которое вы указываете и заменяет на новое. Если значения равны, она заменяет на новое и возвращает true, если нет, то вернёт false, что означает, что замена не состоялась.

var isReady int32 = 0

result := atomic.CompareAndSwapInt32(&isReady, 1, 1)
fmt.Printf("%v", result)

Это были основные функции из пакета sync/atomic, но там также есть различные вариации функций для побитовых операций, таких как OR.

sync.Mutex

Это ещё один инструмент для синхронизации горутин. Он гарантирует, что в один и тот же момент времени только одна горутина может производить операции над данными, блокируя доступ из других горутин.
При базовом использовании в Mutex используются два метода:

  1. Lock() - блокировка доступа.

  2. Unlock() - разблокировка доступа.

Для начала рассмотрим пример кода без мьютексов (с гонкой данных):

var Counter int32 = 0

// Гонка данных без мьютекса:
func main() {
	
	wg := sync.WaitGroup{}
	wg.Add(2)

	go operator1(&wg)
	go operator2(&wg)
	
	wg.Wait()
	fmt.Printf("Final counter: %d", Counter)
}


func operator1(wg *sync.WaitGroup) {
	defer wg.Done()
	
	for i := 0; i < 5; i++  {
		Counter ++
		fmt.Println("\n", Counter)
	}
}


func operator2(wg *sync.WaitGroup) {
	
	defer wg.Done()
	for i := 0; i < 5; i++  {
		Counter ++
		fmt.Println("\n", Counter)
	}
}

При запуске go run -race . мы увидим такой вывод:

 1

 2

 3

 4

 5
==================
WARNING: DATA RACE
Read at 0x0000006262ac by goroutine 8:
  main.operator1()
      /home/maks/Projects/test1/main.go:28 +0xb6
  main.main.gowrap1()
      /home/maks/Projects/test1/main.go:16 +0x2e

Previous write at 0x0000006262ac by goroutine 9:
  main.operator2()
      /home/maks/Projects/test1/main.go:38 +0xcc
  main.main.gowrap2()
      /home/maks/Projects/test1/main.go:17 +0x2e

Goroutine 8 (running) created at:
  main.main()
      /home/maks/Projects/test1/main.go:16 +0xbe

Goroutine 9 (finished) created at:
  main.main()
      /home/maks/Projects/test1/main.go:17 +0x124
==================

 6

 7

 8

 9

 10
Final counter: 10Found 1 data race(s)
exit status 66

Необходимо исправить проблему, сделав работу безопасной с использованием мьютексов:


var Counter int32 = 0

func main() {
	
	wg := sync.WaitGroup{}
	wg.Add(2)
	var mutex sync.Mutex

	go operator1(&wg, &mutex)
	go operator2(&wg, &mutex)
	
	wg.Wait()
	fmt.Printf("Final counter: %d", Counter)
}


func operator1(wg *sync.WaitGroup, mutex *sync.Mutex) {
	defer wg.Done()
	
	for i := 0; i < 5; i++  {
		mutex.Lock()
		Counter ++
		fmt.Println("\n", Counter)
		mutex.Unlock()
	}
}


func operator2(wg *sync.WaitGroup, mutex *sync.Mutex) {
	
	defer wg.Done()
	for i := 0; i < 5; i++  {
		mutex.Lock()
		Counter ++
		fmt.Println("\n", Counter)
		mutex.Unlock()
	}
}

Но надо также учитывать, что sync.Mutex - это не просто флаг "свободен/занят". У него есть два режима работы, которые делают этот инструмент быстрым и справедливым для каждой горутины:

  1. Нормальный режим: Работает по умолчанию. Как только мьютекс освободился, его захватить может любая из горутин, в том числе та, которая только что отработала. Это даёт большую производительность, но может привести к "голоданию".

  2. Режим голодания: если горутина в очереди ждёт дольше 1 миллисекунды, мьютекс переключается в этот режим и здесь право захвата строго передаётся следующей в очереди горутине.

sync.RWMutex

Также стоит упомянуть про sync.RWMutex, который используется для сценариев, когда данные чаще читаются, чем изменяются. Здесь есть два метода RLock() и RUnlock(), которые используются для захвата мьютекса для чтения и освобождения соответственно. Множество горутин могут одновременно держать RLock(), если никто не пишет.

Различия sync/atomic и sync.Mutex

После введения основ по каждому инструменту, можно перейти к основной задаче этой статьи. На первый взгляд можно подумать, что оба инструмента выполняю одну и ту же роль - делают одновременный доступ из разных горутин безопасным. От части с этим согласиться можно, но на деле у них больше различий, чем сходств.

sync/atomic - это пакет стандартной библиотеки, которая выполняется как одиночная операция, в то время как sync.Mutex - это конструкция рантайма, которая используется для критической секции (части кода) и полностью исключает использования конкретной части другими горутинами. Таким образом, в случае, когда необходимо сделать произвольное количество переменных, операций в конкретном обращении к общему участку памяти, лучше использовать sync.Mutex.

sync/atomic всегда быстрее sync.Mutex?

Это очень спорное заявление. Да, sync/atomic действительно может быть быстрее в случае, когда операция очень мала и её можно выразить одной атомарной операцией, но есть случаи, когда atomic действительно проигрывает Mutex. В бенчмарке ниже происходит сравнение atomic и Mutex на примере простой операции увеличения счётчика.

package main

import (
	"sync"
	"sync/atomic"
	"testing"
)

// Тест для Mutex
func BenchmarkMutex(b *testing.B) {
	var counter int64
	var mutex sync.Mutex

	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			mutex.Lock()
			counter++
			mutex.Unlock()
		}
	})
}

// Тест для atomic
func BenchmarkAtomic(b *testing.B) {
	var counter int64

	b.RunParallel(func(pb *testing.PB) {
		for pb.Next() {
			atomic.AddInt64(&counter, 1)
		}
	})
}

Результат:

maks@fedora ~/P/test1 [1]> go test -bench=. -cpu=1,2,4,8
goos: linux
goarch: amd64
pkg: test1
cpu: AMD Ryzen 5 7640HS w/ Radeon 760M Graphics     
BenchmarkMutex      	378627867	         3.171 ns/op
BenchmarkMutex-2    	195535148	         5.885 ns/op
BenchmarkMutex-4    	80525481	        13.01 ns/op
BenchmarkMutex-8    	46922386	        23.20 ns/op
BenchmarkAtomic     	753790170	         1.562 ns/op
BenchmarkAtomic-2   	193068660	         6.053 ns/op
BenchmarkAtomic-4   	247736799	         4.845 ns/op
BenchmarkAtomic-8   	190388100	         6.297 ns/op
PASS
ok  	test1	13.107s

Здесь видно как меняется производительность при разном уровне параллельного выполнения. Так при обычном  BenchmarkMutexиBenchmarkAtomic результаты отличаются почти в два раза ( 3.171 ns/opвBenchmarkMutexпротив1.562 ns/opвBenchmarkAtomic). Но с ростом количества потоков ситуация проявляется ещё сильнее: в BenchmarkMutex-8значение23.20 ns/opпротивBenchmarkAtomic-8 со значением6.297 ns/op. Так как Здесь демонстрируется атомарная операция (увеличение счётчика). Её вполне можно уместить в одну инструкцию процессора.

При этом высокая конкуренция не делает atomic автоматически медленнее. Несколько ядер конкурируют за одну cahce line, содержащую счётчик и это даёт дополнительные накладные расходы на поддержание согласованности кэшей. Однако Mutex в этой ситуации тоже не является бесплатным. Он сам требует синхронизации между горутинами и добавляет стоимость блокировки и разблокировки.

По этой причине, при разработке лучше не ставить вопрос сравнения общей скорости работы atomic и Mutex, а смотреть за состоянием программы, за тем, какое количество одновременных горутин будет пытаться изменить один участок памяти и тем, какое количество операций и переменных за раз необходимо обезопасить.

Что и когда использовать?

Таким образом, использовать sync/atomic рентабельно, когда:

  • Вы делаете простой счётчик, работа с флагами.

  • У вас одна переменная, которая не зависит от других.

  • Вы понимаете порядок, в котором операции чтения и записи выполняются

Использовать sync.Mutex рентабельно, когда:

  • Хотите защитить больше одной переменной.

  • Есть инварианты между полями структуры.

  • Критическая секция содержит вызовы функций, I/O, аллокации

  • Вы не уверены, что вам лучше использовать

Заключение

Надеюсь эта статья была для вас полезна и помогла лучше понять особенности использования sync/atomic и sync.Mutex в Go. После прочтения этой статьи у вас должно было появиться понимание, что atomic - это не магический инструмент, который позволяет сделать из любой многопоточной реализации "ракету". Они больше подходят для тех случаев, когда необходимо обезопасить работу с отдельным значением, но по мере усложнения программы использование atomic может стать не выгодным. В конечном счёте, если вам необходимо защитить несколько связанных переменных или выполнить несколько операций, как единое целое, лучше использовать sync.Mutex. Подобный сценарий окажется более подходящим и понятным.