Когда 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 = 0atomic.AddInt32(&isReady, 1)fmt.Printf("%v", isReady)

atomic.CompareAndSwap

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

var isReady int32 = 0result := 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 RACERead 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 +0x2ePrevious 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 +0x2eGoroutine 8 (running) created at:  main.main()      /home/maks/Projects/test1/main.go:16 +0xbeGoroutine 9 (finished) created at:  main.main()      /home/maks/Projects/test1/main.go:17 +0x124================== 6 7 8 9 10Final counter: 10Found 1 data race(s)exit status 66

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

var Counter int32 = 0func main() {wg := sync.WaitGroup{}wg.Add(2)var mutex sync.Mutexgo 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 mainimport ("sync""sync/atomic""testing")// Тест для Mutexfunc BenchmarkMutex(b *testing.B) {var counter int64var mutex sync.Mutexb.RunParallel(func(pb *testing.PB) {for pb.Next() {mutex.Lock()counter++mutex.Unlock()}})}// Тест для atomicfunc BenchmarkAtomic(b *testing.B) {var counter int64b.RunParallel(func(pb *testing.PB) {for pb.Next() {atomic.AddInt64(&counter, 1)}})}

Результат:

maks@fedora ~/P/test1 [1]> go test -bench=. -cpu=1,2,4,8goos: linuxgoarch: amd64pkg: test1cpu: AMD Ryzen 5 7640HS w/ Radeon 760M Graphics     BenchmarkMutex      378627867         3.171 ns/opBenchmarkMutex-2    195535148         5.885 ns/opBenchmarkMutex-4    80525481        13.01 ns/opBenchmarkMutex-8    46922386        23.20 ns/opBenchmarkAtomic     753790170         1.562 ns/opBenchmarkAtomic-2   193068660         6.053 ns/opBenchmarkAtomic-4   247736799         4.845 ns/opBenchmarkAtomic-8   190388100         6.297 ns/opPASSok  test113.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. Подобный сценарий окажется более подходящим и понятным.

ссылка на оригинал статьи https://habr.com/ru/articles/1081648/