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

Привет, Хабр! Эта статья посвящается важной теме работы в многопоточной среде. Для безопасной работы с одним ресурсом из разных горутин разработчик должен быть уверен в безопасности и обособленности действий, чтобы не создать ситуации, в которых "гонки данных" (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 используются два метода:
Lock()- блокировка доступа.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 миллисекунды, мьютекс переключается в этот режим и здесь право захвата строго передаётся следующей в очереди горутине.
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. Подобный сценарий окажется более подходящим и понятным.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.