🏃 Параллельное программирование в Go
Изучаем основы параллельного программирования в Go, а также пытаемся разобраться на примерах, почему конкурентность в Go – это не совсем параллелизм.
Перевод публикуется с сокращениями, автор оригинальной статьи Stefan Nilsson.
Основы
The Go Playground – интерактивный веб-сервис, который позволяет запускать в песочнице небольшие программы в духе «Hello world!». Попробуйте!
Изучите основы Go
A Tour of Go – еще один интерактивный учебник с кучей примеров. Он берет начало на официальном сайте и обучает вас основам программирования Go в браузере.
Установите инструменты Go
В Getting Started объясняется, как установить инструменты Go. Доступны бинарные пакеты для FreeBSD, Linux, Mac OS X и Windows, а также инструкции по развертыванию и настройке.
Начните проект Go
How to Write Go Code посвящен разработке простых пакетов Go. Он рассказывает про организацию и тестирование кода, а также про использование команд fetch
,
build
и install
.
Горутины
Вы можете создать новый поток (горутину) с помощью оператора go. Все горутины в одной программе используют одно и то же адресное пространство.
Программа выводит сообщение «Hello from main goroutine». Она также может напечатать «Hello from another goroutine», в зависимости от того, какая из двух горутин завершится первой.
Следующая программа скорее всего выведет «Hello from main goroutine» и «Hello from another goroutine». Они могут появиться в любом порядке. Еще одна особенность заключается в том, что вторая горутина работает очень медленно и не печатает сообщение до завершения программы.
Вот более реалистичный
пример, где определяется функция, которая использует concurrency
для отсрочки
события:
Вот как вы можете
использовать функцию Publish
:
Скорее всего программа напечатает три строки в заданном порядке с пятисекундными перерывами между ними.
Невозможно реализовать ожидание потоков в процессе «сна», но есть метод синхронизации – использование каналов.
Реализация
Внутри горутины действуют как корутины, которые мультиплексируются между несколькими потоками операционной системы. Если одна горутина блокирует поток ОС, например, ожидая ввода, другие горутины в этом потоке будут мигрировать, чтобы продолжать работать.
Каналы обеспечивают синхронизированную связь
Новое значение канала
можно задать с помощью встроенной функции make
.
Чтобы отправить значение в
канал, используйте бинарный оператор «<-
», а для получения – унарный
оператор.
Оператор задает направление канала на отправку или получение. По умолчанию канал является двунаправленным.
Буферизованные и небуферизованные каналы
- Если пропускная способность канала равна нулю или отсутствует, канал не буферизуется и отправитель блокируется до тех пор, пока получатель не получит значение.
- Если канал имеет буфер, отправитель блокируется только до тех пор, пока значение не будет скопировано в буфер. Если буфер заполнен, ждем пока какой-либо получатель не получит значение.
- Приемники всегда блокируются, пока не появятся данные для приема.
- Отправка или получение с nil-канала блокируется навсегда.
Закрытие канала
Функция закрытия помечает, что никакие значения больше не будут отправляться по каналу. Обратите внимание, что закрывать канал необходимо только в том случае, если приемник этого ожидает.
- После вызова
close
и после получения любых ранее отправленных значений, операции приема вернут нулевое значение без блокировки. - Операция приема множества значений дополнительно возвращает состояние канала.
- Отправка в закрытый канал или его закрытие, а также закрытие nil-канала, вызовут
run-time panic
.
Пример
В следующем примере функция
Publish
вернет канал, который используется для броадкастинга сообщения после
публикации текста:
Обратите внимание: мы используем канал пустых структур для указания, что канал будет использоваться только для сигнализации, а не для передачи данных. Выглядит это так:
Select ожидает группы каналов
Оператор select
одновременно ожидает нескольких операций отправки или получения.
- Оператор блокируется до тех пор, пока одна из операций не будет разблокирована.
- Если выполняется несколько операций, то одна из них будет выбрана случайным образом.
Операции
отправки и приема в nil-канале
блокируются навсегда. Это можно использовать для отключения канала в инструкции
select
:
Вариант по умолчанию
Вариант по умолчанию будет выполнен, если все остальные заблокированы.
Примеры
Бесконечная случайная двоичная последовательность
В качестве примера можно использовать случайный выбор вариантов, которые могут генерировать случайные биты.
Операция блокировки по таймауту
Функция time.After
входит в стандартную библиотеку. Она ожидает истечения указанного
времени, а затем отправляет текущее время в возвращаемый канал:
Оператор select
блокируется до тех пор, пока по крайней мере один case
не сможет выполниться. С нулевыми кейсами этого
никогда не произойдет:
Гонки данных
Такая ситуация возникает часто и может усложнить отладку.
Показанная ниже функция приводит к гонке данных, и ее поведение не определено – она может, например, напечатать число 1. Попробуем выяснить, как это происходит:
Две горутины g1 и g2, участвуют в гонке, и нет никакого способа узнать, в каком порядке будут выполняться операции. Ниже приведен один из нескольких возможных вариантов:
Как избежать гонки данных?
Предпочтительный способ обработки одновременного доступа к данным в Go – использовать канал для передачи данных от одной горутины к следующей.
В этом коде канала происходят два события:
- передаются данные от одной горутины к другой – точка синхронизации;
- отправляющая горутина будет ждать, пока другая получит данные и наоборот.
Как обнаружить гонку данных?
Гонки данных могут легко появляться, но обнаружить их трудно. К счастью среда выполнения Go может помочь и в этом. Используйте ключ -race для включения встроенного детектора гонки данных.
Пример
Программа с гонкой данных:
Запуск этой программы с параметром -race покажет нам, что существует гонка между записью в строке 7 и чтением в строке 9:
Подробности
Он работает на darwin/amd64, freebsd/amd64, linux/amd64 и Windows/amd64.
Накладные расходы варьируются, но обычно происходит увеличение использования памяти в 5-10 раз и увеличение времени выполнения в 2-20 раз.
Как отлаживать deadlock-и
Дэдлоки возникают, когда горутины ждут друг друга и ни одна из них не может завершиться.
Взглянем на пример:
Программа застрянет на операции отправки, ожидая вечно, пока кто-то прочитает значение. Go способен обнаруживать подобные ситуации во время выполнения. Вот результат нашей программы:
Советы по отладке
Горутина может застрять:
- когда она ждет канал;
- либо когда она ждет одну из блокировок в пакете sync.
Общие причины:
- ни одна горутина не имеет доступа к каналу или блокировке;
- горутины ждут друг друга.
С помощью каналов легко понять, что вызвало дедлок. С другой стороны, интенсивно использующие мьютексы программы могут быть заведомо трудными для отладки.
Ожидание горутин
Группа sync.WaitGroup
ожидает завершения работы группы горутин:
- сначала основная горутина вызывает Add, чтобы установить количество ожидающих горутин;
- затем запускаются две новые горутины и вызывают Done при завершении.
В то же время Wait используется для блокировки до тех пор, пока эти две горутины не завершатся.
Замечание: группа ожидания не должна копироваться после первого использования.
Трансляция сигнала по каналу
В этом примере функция
Publish
возвращает канал, который используется для передачи сигнала при
публикации сообщения.
Обратите внимание, что мы
используем канал пустых структур: struct{}
. Это явно указывает на то, что канал предназначен только для сигнализации, а не для передачи данных.
Вот как можно это использовать:
Как убить горутину
Чтобы горутина остановилась, ей необходимо прослушивать сигнал остановки на выделенном выходном канале и проверять его.
Вот более полный пример, где используется один канал как для передачи данных, так и для сигнализации:
Timer и Ticker
Таймеры и тикеры позволяют выполнять код по расписанию один или несколько раз.
Timeout (Timer)
time.After ожидает в течение заданного промежутка, а затем отправляет текущее время по возвращаемому каналу:
time.Timer не будет обработан сборщиком мусора до тех пор, пока таймер не сработает. Используйте time.NewTimer вместо вызова метода Stop, когда таймер больше не нужен:
Repeat (Ticker)
time.Tick
возвращает
канал, который обеспечивает тиканье часов с четными интервалами:
time.Ticker не будет обработан сборщиком мусора до тех пор, пока таймер не сработает. Используйте time.NewTicker вместо вызова метода Stop, когда тикер больше не нужен:
Блокировка взаимного исключения (мьютекс)
Иногда удобнее синхронизировать доступ к данным с помощью явной блокировки, а не с помощью каналов. Стандартная библиотека Go предлагает для этой цели блокировку взаимного исключения sync.Mutex.
Используйте с осторожностью
Из-за этого вам следует подумать о разработке кастомной структуры данных с чистым API и убедиться, что вся синхронизация выполняется внутри.
В этом примере мы создаем
безопасную и простую в использовании конкурентную структуру данных AtomicInt
, в которой хранится integer
. Любое количество горутин
может безопасно получить доступ к этому числу с помощью методов Add
и Value
.
Заключение
Мы рассмотрели распространенные проблемы, относящиеся к конкурентности в Go. Это не весь материал по теме – остальное вам придется самостоятельно изучать на официальном сайте. Не ленитесь, развивайтесь и удачи в обучении!
Дополнительные материалы:
- Где используется язык программирования Go?
- Конкурентность в Golang и WorkerPool [Часть 2]
- Golang против Python: какой язык программирования выбрать?
- Язык Go: как стать востребованным программистом