Использование мьютекса в 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)
}

Запуск этого кода даст тот же результат, что и раньше.

Это полезно по нескольким причинам:

  1. Если у вас есть несколько экземпляров данных, к которым требуется монопольный доступ, объединение мьютекса вместе с самими данными сделает их менее запутанными и более читабельными.
  2. Данные могут передаваться как аргументы функции, а мьютекс будет передаваться по умолчанию.

Если вам не нужен общий мьютекс для нескольких фрагментов данных, в качестве хорошей практики лучше включить мьютекс в структуру.

Распространенные ловушки

Хотя мьютексы могут показаться отличным решением, легко попасть в некоторые распространенные ловушки.

Всякий раз, когда вы звоните в 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)
}()

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