Использование мьютекса в Go (Golang) — с примерами
В этом посте мы обсудим, почему мы используем мьютексы в Go, и узнаем, как использовать мьютекс для блокировки данных и устранения условий гонки (race conditions).
Go позволяет нам одновременно запускать код с помощью горутин. Однако, когда параллельные процессы получают доступ к одному и тому же фрагменту данных, это может привести к состояниям гонки .
Мьютексы (Mutex) — это структуры данных, предоставляемые пакетом синхронизации . Они могут помочь нам заблокировать различные разделы данных , чтобы только одна горутина могла получить к ним доступ одновременно.
Использование мьютекса для блокировки
Давайте посмотрим на пример, где параллельные горутины могут повредить часть данных при одновременном доступе к ним:
// a simple function that returns true if a number is even func isEven(n int) bool { return n%2 == 0 } func main() { n := 0 // goroutine 1 // reads the value of n and prints true if its even // and false otherwise go func() { nIsEven := isEven(n) // we can simulate some long running step by sleeping // in practice, this can be some file IO operation // or a TCP network call time.Sleep(5 * time.Millisecond) if nIsEven { fmt.Println(n, " is even") return } fmt.Println(n, "is odd") }() // goroutine 2 // modifies the value of n go func() { n++ }() // just waiting for the goroutines to finish before exiting time.Sleep(time.Second) }
Запуск этого кода даст нам следующий неожиданный результат:
1 is even
Это происходит потому, что горутина 1 фактически обращается к n
дважды: один раз, чтобы проверить, четно ли оно, и еще раз, чтобы напечатать его значение. Между этими шагами горутина 2 увеличивает значение n
.
Распечатанное значение теперь отличается от проверенного значения. Это известно как гонка данных .
Мы должны гарантировать, что n
не следует писать так, чтобы его читала другая горутина, и наоборот.
Мы можем использовать sync.Mutex
введите, чтобы предотвратить доступ к нескольким горутинам n
в то же время:
func main() { n := 0 var m sync.Mutex // now, both goroutines call m.Lock() before accessing `n` // and call m.Unlock once they are done go func() { m.Lock() defer m.Unlock() nIsEven := isEven(n) time.Sleep(5 * time.Millisecond) if nIsEven { fmt.Println(n, " is even") return } fmt.Println(n, "is odd") }() go func() { m.Lock() n++ m.Unlock() }() time.Sleep(time.Second) }
Вызов m.Lock
«заблокирует» мьютекс. Если какая-либо другая горутина вызывает m.Lock
, он заблокирует поток до тех пор, пока m.Unlock
называется.
Если горутина вызывает m.Lock
перед первым доступом для чтения/записи к соответствующим данным и вызывает m.Unlock
после последнего гарантируется, что между этим периодом горутина будет иметь эксклюзивный доступ к данным.
для всей области действия функции, лучше отложить Если вы хотите удерживать блокировку Unlock
позвони, а не звони сам в конце
Вот почему, когда мы запускаем приведенный выше код , мы получаем правильный результат:
0 is even
Мьютекс чтения/записи — тип sync.RWMutex
Иногда допустимо одновременное чтение данных, если запись остается атомарной.
В этом случае мы можем sync.RWMutex
type, который имеет разные блокировки для чтения и записи данных:
package main import ( "fmt" "sync" "time" ) func isEven(n int) bool { return n%2 == 0 } func main() { n := 0 var m sync.RWMutex // goroutine 1 // Since we are only reading data here, we can call the `RLock` // method, which obtains a read-only lock go func() { m.RLock() defer m.RUnlock() nIsEven := isEven(n) time.Sleep(5 * time.Millisecond) if nIsEven { fmt.Println(n, " is even") return } fmt.Println(n, "is odd") }() // goroutine 2 go func() { m.RLock() defer m.RUnlock() nIsPositive := n > 0 time.Sleep(5 * time.Millisecond) if nIsPositive { fmt.Println(n, " is positive") return } fmt.Println(n, "is not positive") }() // goroutine 3 // Since we are writing into data here, we use the // `Lock` method, like before go func() { m.Lock() n++ m.Unlock() }() time.Sleep(time.Second) }
При запуске этого кода мы можем наблюдать, что горутины 1 и 2 имеют доступ n
одновременно, но операции чтения (1 и 2) и записи (3) заблокированы друг от друга , и одно может начаться только в том случае, если другое завершилось.
Это может повысить производительность вашего приложения, если оно будет читать эти данные несколько раз, поскольку чтение может происходить одновременно.
Добавление мьютексов в структуры (Struct)
Если вы хотите связать мьютекс вместе с его данными, вы можете рассмотреть возможность использования композиции структуры.
Если бы мы хотели сделать то же самое для предыдущего примера, мы могли бы создать структуру для хранения числового значения, а также включить мьютекс:
type intLock struct { val int sync.Mutex } func (n *intLock) isEven() bool { return n.val%2 == 0 }
Затем мы можем использовать мьютекс внутри самой структуры для обработки блокировки и разблокировки:
func main() { n := &intLock{val: 0} go func() { n.Lock() defer n.Unlock() nIsEven := n.isEven() time.Sleep(5 * time.Millisecond) if nIsEven { fmt.Println(n.val, " is even") return } fmt.Println(n.val, "is odd") }() go func() { n.Lock() n.val++ n.Unlock() }() time.Sleep(time.Second) }
Запуск этого кода даст тот же результат, что и раньше.
Это полезно по нескольким причинам:
- Если у вас есть несколько экземпляров данных, к которым требуется монопольный доступ, объединение мьютекса вместе с самими данными сделает их менее запутанными и более читабельными.
- Данные могут передаваться как аргументы функции, а мьютекс будет передаваться по умолчанию.
Если вам не нужен общий мьютекс для нескольких фрагментов данных, в качестве хорошей практики лучше включить мьютекс в структуру.
Распространенные ловушки
Хотя мьютексы могут показаться отличным решением, легко попасть в некоторые распространенные ловушки.
Всякий раз, когда вы звоните в Lock
метод, вы должны убедиться, что Unlock
в конце концов называется. Это может показаться простым, но его легко пропустить. Рассмотрим этот пример:
go func() { n.Lock() nIsEven := n.isEven() time.Sleep(5 * time.Millisecond) if nIsEven { fmt.Println(n.val, " is even") // mutex is never unlocked return } fmt.Println(n.val, "is odd") n.Unlock() }()
Здесь мы называем Unlock
метод в конце функции вместо использования defer
. Сейчас если n
четно, мы напечатаем соответствующий оператор и вернемся из горутины, но Unlock
никогда бы не позвонили.
Если это произойдет, любая горутина, желающая получить доступ к той же блокировке, будет заблокирована навсегда. Использование defer для вызова Unlock
Метод поможет вам избежать этого.
Всегда снимайте блокировку, как только закончите доступ к данным, и никогда позже — вы просто зря потратите ресурсы.
Например, если вам нужно было выполнить какую-то работу после доступа n
, с использованием defer
может быть не подходит:
go func() { n.Lock() defer n.Unlock() nIsEven := n.isEven() time.Sleep(5 * time.Millisecond) if nIsEven { fmt.Println(n.val, " is even") return } fmt.Println(n.val, "is odd") // some work after printing time.Sleep(5 * time.Millisecond) }()
В этом случае, n.Unlock
будет вызван после последнего time.Sleep
вызов, хотя мы можем снять блокировку, как только закончим печатать результат. Используя defer
, мы потенциально задержали выполнение других горутин, требующих доступа к этой блокировке.
В подобных случаях лучше вручную снять блокировку, как только вы закончите доступ к данным:
go func() { n.Lock() nIsEven := n.isEven() time.Sleep(5 * time.Millisecond) if nIsEven { fmt.Println(n.val, " is even") // unlock before returning n.Unlock() return } fmt.Println(n.val, "is odd") // unlock after printing `n`s value n.Unlock() // we can now release the lock 5ms earlier time.Sleep(5 * time.Millisecond) }()
Подводя итог, можно сказать, что мьютекс — отличный инструмент для предотвращения несанкционированного доступа к данным. Существует множество способов использования мьютекса и множество подводных камней, которые могут возникнуть, поэтому обязательно оцените свой вариант использования, прежде чем принять решение о правильном подходе.