Мастер параллелизма в Go

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

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

Давайте начнем с определения того, что такое параллелизм:

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

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

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

Модель параллелизма Golang

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

Общие ресурсы с горутинами

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

Давайте определим, что такое состояние гонки в контексте параллельной программы.

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

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

А пока давайте напишем нашу первую параллельную программу и посмотрим, как создать горутину, используя ключевое слово go:

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

В строке 8 мы говорим программе создать новую горутину для функции countNumbers. Затем вернемся к основной горутине, в строке 10 есть еще один оператор печати.

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

Чтобы позволить функции countNumbers завершиться, мы можем приостановить 1 секунду в основной процедуре go в строке 9.

time.Sleep(1 * time.Second)

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

Что такое канал?

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

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

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

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

Тип каналов

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

Чтобы продемонстрировать эту концепцию блокировки при использовании небуферизованных каналов, давайте рассмотрим следующий пример:

Запуск предыдущего примера выведет следующее:

Давайте разберемся с выводом. Когда программа запускается, создается пустой пользовательский объект и канал логического типа (небуферизованный канал) в строках 36 и 37 соответственно, затем в строке 39 создается горутина, что означает, что часть кода внутри этой функции будет выполняться в отдельная горутина.

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

Основная горутина продолжает работу и вызывает метод importingPosts, а также делает еще два оператора печати, последний из которых [Main-Goroutine] — — -waiting------ , именно здесь вступает в действие концепция блокировки, о которой мы говорили ранее, в строке 53 мы видим что основная горутина читает из канала done, это в основном означает, что основная горутина не продолжит свое выполнение, пока вторая горутина не отправит сообщение на этот канал.

Во второй горутине вызывается функция buildUser и она печатает [Second-GoRoutine] Finished Building User , а затем в следующей строке отправляет сообщение в канал. В этот момент основная горутина обнаружит это и продолжит выполнение, как и вторая горутина.

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

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

В этом примере мы узнали, как создать небуферизованный канал с помощью встроенной функции make.

done := make(chan int)

Также как отправлять и получать данные с канала

done <- true // send
<-done // receive ignorting value
resp := <-done // receive storing value in a variable

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

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

Давайте рассмотрим следующий пример, на этот раз с использованием нескольких горутин.

Вывод этого примера выглядит следующим образом:

В этом примере у нас есть два канала usersToUpdate и userToNotify. Обратите внимание, что первый канал принимает массив пользователей, а второй — только один объект пользователя. Затем есть два массива пользователей, один для существующих пользователей и один для новых пользователей.

В первой горутине мы отправляем usersToUpdatechannel и фрагмент newUsers, поэтому, когда программа доходит до строки 40, создается новая горутина. .

Обратите внимание на синтаксис функции filterNewUsersByStatus для параметра usersToUpdate.

usersToUpdate chan<- []*User

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

Таким образом, в этом случае мы сообщаем каналу usersToUpdate, что в контексте этой функции этот канал будет принимать только отправку информации, но не ее получение.

Эта функция filterNewUsersByStatus работает в диапазоне newUsers и выбирает только тех, которые активны и для которых включена настройка уведомлений. После этого в строке 54 через канал отправляются отфильтрованные пользователи.

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

Во второй горутине мы отправляем канал usersToUpdate, канал userToNotify и срез existingUsers. Именно здесь вступает в игру концепция использования результатов канала в качестве входных данных для другой горутины.

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

В строке 59 функция сначала обновила существующих пользователей, добавив к каждому из них новый тег. Затем в строке 63 создается новая переменная, присваивающая ее результату канала usersToUpdate. Эта строка заблокирует выполнение этой горутины до тех пор, пока канал не отправит сообщение. Другими словами, если filterNewUsersByStatus требует много времени для отправки filteredUsers, этой горутине придется подождать в этой строке, прежде чем продолжить.

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

userToNotify также нужно будет закрыть после того, как эта функция завершит свою работу, поэтому в строке 58 у нас есть defer для закрытия канала.

Затем в строке 42 есть функция, которая вызывается в основной горутине, которая будет уведомлять пользователей, она принимает канал userToNotify и existingUsers в качестве параметров.

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

Затем в строке 78 он охватывает канал userToNotify, и для каждого пользователя, который отправляется через этот канал, эта функция отправляет этому пользователю уведомление по электронной почте. Этот синтаксис позволяет нам получать всю информацию, отправленную через этот канал, и как только канал будет закрыт, цикл for также прервется. Это предотвратит чтение из закрытого канала, как я упоминал ранее, это один из способов гарантировать, что вы не читаете из закрытого канала. Другой синтаксис выглядит следующим образом:

resp, ok := <-userToNofity

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

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

В этом примере мы узнали, как закрыть канал, используя функцию отсрочки и закрытия.

defer close(done)

Также как сделать канал однонаправленным, когда он передается функции

userToNotify <-chan *User // read-only channel
userToNotify chan<- *User // send-only channel

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

for user := range userToNotify {}

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

Чтобы создать буферизованный канал, нам нужно всего лишь передать в функцию make дополнительный параметр:

ans := make(chan int, 5)

Этот канал будет принимать 5 целых чисел без блокировки горутины, но если на этот канал отправляется 6-е целое число, он блокируется до тех пор, пока не будет выполнена операция приема. То же самое произойдет, если канал пуст и выполняется операция приема, он будет заблокирован до тех пор, пока не будет выполнена операция отправки.

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

Давайте посмотрим на это, используя следующий код:

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

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

Давайте посмотрим на вывод этого кода:

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

Основные выводы

  • Используйте горутины, чтобы ускорить вашу программу Go.
  • Используйте ключевое слово make для создания небуферизованного канала.
  • Используйте ключевое слово make, указывающее емкость для создания буферизованного канала.
  • Прочитать данные из канала с этим синтаксисом resp := <-names.
  • Отправить данные в канал с этим синтаксисом numbers <- num.
  • Прочитайте все данные, отправленные в канал, используя диапазон для цикла.
  • Закройте канал, используя встроенные функции defer и close.
  • Блокировка концепций между разными горутинами.
  • Измените двунаправленные каналы, чтобы они вели себя как каналы только для отправки или только для чтения в контекстах функций.
  • Используйте каналы для связи между различными горутинами.

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

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

Ресурсы

Если вы хотите узнать больше о Go, вам могут быть полезны следующие статьи.