Конкурентность в Golang

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

Давайте разберёмся, чем они друг от друга отличаются.

Что такое конкурентность?

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

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

  1. Запуск на выполнение баннера с последними предложениями и продуктами cверху страницы.
  2. Показ количества пользователей на сайте в данный момент.
  3. Обновление содержимого корзины при выборе продуктов.
  4. Ведение счётчика времени до следующей распродажи и так далее. 

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

На картинке выше у нас несколько задач, выполняемых одновременно, но есть разница в том, как они выполняются. Рассмотрим теперь подробнее.

Конкурентное и параллельное выполнение

Работа с конкурентными приложениями

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

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

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

На второй картинке в нижней части проиллюстрирована конкурентность без параллелизма. Здесь показано конкурентное выполнение двух задач с переключением контекста: одномоментно может быть выполнена лишь одна задача.

Добавим приложению параллелизма

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

Конкурентность и параллелизм — очень похожие понятия, но мне кажется, что разницу вы уже уловили.

Таким образом, увеличивая сложность системы, можно увеличить и сложность решаемых с её помощью задач: работая с Golang, мы можем масштабировать приложение, с лёгкостью переходя от конкурентного исполнения к параллельному. Масштабируемость в Golang — это легко!

Работа с горутинами

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

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

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

Если приложение выполняется на нескольких ядрах, то добавляется и параллелизм.

Преимущества горутин:

  1. Они легковесны.
  2. Легко и без проблем масштабируют.
  3. Они — практически потоки.
  4. Требуют меньше памяти (2KB).
  5. Предоставляют дополнительную память горутинам во время выполнения.

Теперь обратимся к простой программе на Golang:

package main

import (
    "fmt"
    "time"
)

func main() {
  start := time.Now()
  func() {
    for i:=0; i < 3; i++ {
      fmt.Println(i)
    }
  }()

  func() {
    for i:=0; i < 3; i++ {
      fmt.Println(i)
    }
  }()

  elapsedTime := time.Since(start)

  fmt.Println("Total Time For Execution: " + elapsedTime.String())

  time.Sleep(time.Second)
}

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

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

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

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

<figure><iframe width="700" height="525" src="/media/c7575c02a42975093806da924e66199a" allowfullscreen=""></iframe></figure>

Использование горутин в Golang

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

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

package main

import (
    "fmt"
    "time"
)

func main() {
  start := time.Now()
  go func() {
    for i:=0; i < 3; i++ {
      fmt.Println(i)
    }
  }()

  go func() {
    for i:=0; i < 3; i++ {
      fmt.Println(i)
    }
  }()

  elapsedTime := time.Since(start)

  fmt.Println("Total Time For Execution: " + elapsedTime.String())

  time.Sleep(time.Second)
}

Вывод:

А в этом сценарии мы добавляем ключевое слово go к функциям немедленного выполнения. Выполнение начинается с функции main.

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

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

В данном сценарии в конкурентном режиме будут выполняться три потока: основной main, поток первой функции немедленного выполнения first и поток второй такой функции.

Попробуйте выполнить это в виртуальном редакторе ниже:

В чём отличие от последовательного исполнения

В приведённом выше коде мы добавили ключевое слово go перед выполнением функции. При этом для выполнения функции создаётся отдельная горутина и эта функция выполняется внутри отдельного потока горутины.

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

Добавление параллельного выполнения

В Go мы можем увеличить количество ядер простой строчкой кода. Приложению будет дана команда перейти на несколько ядер:

runtime.GOMAXPROCS(4)

Здесь мы указали, что приложение может использовать четыре ядра для исполнения.

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

package main

import (
    "fmt"
    "time"
    "runtime"
)

func main() {
  runtime.GOMAXPROCS(4)
  start := time.Now()
  go func() {
    for i:=0; i < 3; i++ {
      fmt.Println(i)
    }
  }()

  go func() {
    for i:=0; i < 3; i++ {
      fmt.Println(i)
    }
  }()

  elapsedTime := time.Since(start)

  fmt.Println("Total Time For Execution: " + elapsedTime.String())

  time.Sleep(time.Second)
}

Теперь программа сможет выполняться на нескольких ядрах параллельно и делать это быстрее. Исполнение кода будет вот таким:

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

Тут-то мы добавляем вместе с конкурентностью и параллелизм. Можете попробовать выполнить программу в данном виртуальном редакторе:

<figure><iframe width="700" height="525" src="/media/f09b3eba247e0a92e1eb190e4265bb47" allowfullscreen=""></iframe></figure>

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

Благодарю за внимание.

Читайте также:


Перевод статьи Mayank Gupta: Understanding Golang and Goroutines

Предыдущая статьяНасколько хорошо вы разбираетесь в концепциях баз данных?
Следующая статьяПревращаем сценарии Python в инструменты МО