Напишите безопасный параллельный код

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

Прежде чем мы начнем, если вы новичок в параллелизме в Golang, я настоятельно рекомендую сначала прочитать эти статьи:

Что такое состояние гонки?

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

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

Общие данные

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

При написании параллельных программ важно определить все переменные, которые будут общими для горутин.

Критические секции

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

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

Атомарные операции

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

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

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

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

Golang предоставляет примитивы для синхронизации доступа к памяти, поэтому операции могут быть атомарными.

пакет sync.Mutex

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

Этот механизм представляет собой использование методов Lock и Unlock из пакета.

Метод Lock установит, что горутина, которая вызывает этот метод, только что получила блокировку, и никакие другие горутины не могут использовать блокировку, пока она не будет освобождена.

Метод Unlock снимает блокировку, чтобы другие горутины могли ее использовать.

Когда одна горутина использует блокировку, а другая тоже пытается получить блокировку, горутина будет блокироваться до тех пор, пока другая горутина не освободит блокировку.

Давайте посмотрим на пример состояния гонки и на то, как мы можем решить его с помощью блокировки:

В приведенном выше сценарии две горутины добавляются к срезу numbers. Давайте определим общие данные, критические разделы и условия гонки.

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

Критические разделы в этом примере — две операции добавления в строках 17 и 22, поскольку мы хотим, чтобы эти операции были атомарными в том контексте, в котором они выполняются.

Если мы запустим программу, можно получить следующие результаты:

// Expected
numbers &[]
numbers &[1 2]
// Expected
numbers &[12]
numbers &[12 1 2]
// Race condition
numbers &[]
numbers &[1]
// Race condition
numbers &[]
numbers &[12 1 2]

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

Чтобы решить эту проблему, операции добавления чисел к общей переменной должны быть атомарными, sync.Mutex может помочь нам в этом:

Запуск программы теперь будет производить только два ожидаемых результата:

// Expected
numbers &[]
numbers &[1 2]
// Expected
numbers &[12]
numbers &[12 1 2]

Общепринятой практикой является вызов mu.Lock(), а затем в функции отсрочки метода mu.UnLock(), потому что сложная функция, в которой есть несколько возможных способов завершения функции либо возврата раньше, либо возврата ошибки, с использованием функции defer гарантирует блокировка снимается, когда функция завершается, независимо от того, какой путь она выбирает.

Пакет sync также предоставляет другой тип блокировки только для операций чтения. sync.RWMutex предоставляет методы mu.RLock() и mu.RUnlock() для получения и снятия блокировки соответственно. Это полезно, когда есть сценарии, в которых вы хотите заблокировать часть кода, но операции, выполняемые в этом блоке, доступны только для чтения.

Заключение

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

Спасибо, что прочитали, и следите за новостями.