Введение
Привет, Хабр! Эта статья посвящается важной теме работы в многопоточной среде. Для безопасной работы с одним ресурсом из разных горутин разработчик должен быть уверен в безопасности и обособленности действий, чтобы не создать ситуации, в которых "гонки данных" (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. Подобный сценарий окажется более подходящим и понятным.

