Конкуренция в Go

Теория: sync.Mutex и RWMutex

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

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

Сценарий с «плавающим» счетчиком выглядит примерно так. Есть переменная count и три горутины, каждая из которых делает count++ в цикле. С точки зрения Go это не одна атомарная операция, а несколько шагов: прочитать count, прибавить единицу, записать обратно. Пока одна горутина читает значение, другая уже успевает его изменить. В итоге часть инкрементов теряется.

Внешне гонка данных проявляется так:

  • одинаковый код дает разные результаты при каждом запуске;
  • появляются странные, иногда невозможные значения;
  • детектор go test -race выводит предупреждения о совместном доступе к памяти.

Гонка не всегда сразу ломает программу. Иногда она годами остается незаметной и проявляется только под нагрузкой. Но как только несколько горутин оказываются в одном и том же участке памяти без защиты, результат становится непредсказуемым.

Использование sync.Mutex

Чтобы устранить гонку, нужно превратить работу с общими данными в критическую секцию — участок кода, который выполняет только одна горутина за раз. В Go эту роль выполняет sync.Mutex.

Мьютекс — это примитив взаимного исключения. Горутине нужно попасть в защищенную секцию — она вызывает Lock(). Если замок свободен, она проходит дальше и становится единственным владельцем. Если замок уже удерживается, горутина ждет, пока его освободят. Когда работа с общими данными закончена, вызывается Unlock(), и следующая горутина может войти.

Например, так выглядит потокобезопасная конфигурация:

type Config struct {
	mu *sync.Mutex
	m  map[string]string
}

func NewConfig() *Config {
	return &Config{
		mu: &sync.Mutex{},
		m:  make(map[string]string),
	}
}

func (c *Config) Get(key string) (string, bool) {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.m[key], true
}

func (c *Config) Set(key, value string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.m[key] = value
}

Теперь даже если Config передадут по значению, у всех копий будет один общий мьютекс и одна карта, а поведение останется корректным. Это хороший рефлекс: поля из sync внутри структур — почти всегда указатели.

sync.RWMutex для чтения и записи

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

Обычный Mutex блокирует всех: и читателей, и писателей. Если читать можно параллельно, это создает ненужное ограничение. Для таких ситуаций есть sync.RWMutex — мьютекс с разделением прав доступа.

У RWMutex два режима работы:

  • режим чтения (RLock / RUnlock) позволяет нескольким горутинам одновременно читать данные;
  • режим записи (Lock / Unlock) дает эксклюзивный доступ, блокируя всех читателей и других писателей.

Пока никого нет в режиме записи, любое количество горутин может войти в RLock. Но как только нужен режим записи, RWMutex ждет, пока все читатели отпустят RUnlock, и только после этого пропускает писателя.

Пример кэша со справочными данными:

type Cache struct {
	mu sync.RWMutex
	m  map[string]string
}

func (c *Cache) Get(key string) (string, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()
	v, ok := c.m[key]
	return v, ok
}

func (c *Cache) Set(key, value string) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.m[key] = value
}

Чтения здесь гораздо чаще, чем записи. Несколько горутин могут вызывать Get одновременно, не мешая друг другу. Как только нужно обновить данные, Set захватывает мьютекс на запись, блокируя новые чтения и дождется завершения уже начавшихся.

RWMutex особенно полезен в сценариях «много чтения — мало записи»: кэши, справочники, конфигурации. Если же записи происходят часто, выигрыш исчезает, а накладные расходы RWMutex могут даже сделать код медленнее обычного мьютекса.

Как избежать взаимоблокировок

Как только в системе появляются несколько замков, возникает риск взаимоблокировки. Это состояние, когда горутины ждут друг друга бесконечно. Одна держит первый мьютекс и ждет второй, другая держит второй и ждет первый. Ни одна не может продвинуться дальше.

Типичный пример — две структуры, каждая со своим мьютексом. Одна функция захватывает сначала a.mu, затем b.mu, другая — в обратном порядке. При неудачном стечении обстоятельств обе горутины останавливаются: первая держит a и ждет b, вторая держит b и ждет a.

Избежать таких ситуаций помогает несколько простых правил.

Во-первых, фиксированный порядок захвата замков. Если в коде потенциально нужно взять два мьютекса A и B, нужно принять одно соглашение: сначала всегда A, потом всегда B. Тогда даже при множестве горутин цикл ожидания не образуется.

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

В-третьих, аккуратное сочетание мьютексов и каналов. Опасная конструкция выглядит так: горутина удерживает мьютекс и отправляет сообщение в канал, а той стороне, которая читает из канала, для обработки нужно взять тот же мьютекс. Получается цикл «мьютекс → канал → мьютекс». Надежнее сначала собрать данные под замком, отпустить замок, а уже затем работать с каналом. при этом, нужно помнить, что "под капотом" в канале тоже есть уже мьютекс, для работы с каналом обычно отдельных блокировок не требуется

И наконец, нельзя вызывать Lock для одного и того же мьютекса дважды подряд из одной горутины. Мьютексы в Go не реентерабельны. Если горутина уже владеет замком и пытается взять его еще раз, она заблокируется сама на себя и никогда не выйдет. В такой ситуации лучше переработать структуру кода: разделить данные или вынести часть логики наружу.

Если правила соблюдаются, мьютексы перестают быть источником сюрпризов. Они становятся предсказуемым инструментом: защищают общие данные от гонок и не приводят программу к тупикам.

Рекомендуемые программы

+7 800 100 22 47

бесплатно по РФ

+7 495 085 21 62

бесплатно по Москве

108813 г. Москва, вн.тер.г. поселение Московский,
г. Московский, ул. Солнечная, д. 3А, стр. 1, помещ. 20Б/3
ОГРН 1217300010476
ИНН 7325174845