В Go независимо запущенная задача называется горутиной. В данном уроке мы научимся запускать несколько горутин сразу и связывать их между собой через каналы. Горутины похожи на корутины, процессы или потоки в других языках, хотя у них есть много своих особенностей. Их создание рационально, оно значительно упрощает процесс управления многими конкурентными операциями.
После прочтения данного урока вы сможете:
Рекомендуем вам супер TELEGRAM канал по Golang где собраны все материалы для качественного изучения языка. Удивите всех своими знаниями на собеседовании! 😎
Мы публикуем в паблике ВК и Telegram качественные обучающие материалы для быстрого изучения Go. Подпишитесь на нас в ВК и в Telegram. Поддержите сообщество Go программистов.
- Создавать горутины;
- Использовать каналы для связи;
- Понять канальные конвейеры.
Рассмотрим фабрику разработчиков Go, или гоферов. Все они очень заняты созданием различных элементов. Ну, почти все. В углу один из гоферов вздремнул — а может просто глубоко задумался. Вот стоит важный гофер — отдает указы всем остальным. Они суетятся и выполняют приказы, а по завершении сообщают о результате. Некоторые куда-то отправляют вещи с фабрики. Другие наоборот, что-то получают.
Содержание статьи
- Запуск горутины в Golang
- Создание нескольких горутин
- Каналы Golang в многопоточности
- Использование select в канале горутин
- Каналы nil в Golang ничего не делают
- Блокировка и deadlock в Golang
- Объединение горутин в конвейер
В предыдущих уроках мы были похожи на единственного работника фабрики Go, просто занимались своими собственными делами и никого не беспокоили. Однако в большинстве случаев работа в Go больше напоминает фабрику, где для достижения общей цели совершается много независимых друг от друга действий. Конкурентные задачи включают получение данных из веб сервера, вычисление миллионов знаков числа Пи или же управление рукой робота.
Представьте процесс написания программы, что выполняет последовательность действий. На каждое действие может потребоваться время, что включает ожидание того, что что-то произойдет до выполнения. Может быть создан обычный последовательный код. Но что, если вам нужно сделать две или более последовательности одновременно.
К примеру, вы хотите, чтобы одна часть программы занималась списком адресов электронной почты и отправляла письма, а другая часть ждала входящих сообщений и сохраняла их в базе данных. Как это можно сделать?
В некоторых языках программирования в коде пришлось бы сделать много изменений. Но в Go можно использовать одинаковый код для каждой независимой задачи. Горутины позволяют запускать любое количество действий одновременно.
Запуск горутины в Golang
Запустить горутину так же просто, как и вызвать функцию. Вам нужно поставить ключевое слово go
перед вызовом.
Горутина в Листинге 1 напоминает спящего гофера фабрики. Он почти ничего не делает, хотя при задействовании оператора Sleep
он мог бы сделать некоторые серьезные задачи. Когда возвращается функция main
, все горутины в программе сразу останавливаются, поэтому нам нужно подождать, пока спящий гофер выведет свое сообщение "… snore …"
. Мы подождем подольше, если требуется.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
package main import ( "fmt" "time" ) func main() { go sleepyGopher() // Начало горутины time.Sleep(4 * time.Second) // Ожидание храпа гофера } // Здесь все горутины останавливаются func sleepyGopher() { time.Sleep(3 * time.Second) // гофер спит fmt.Println("... snore ...") } |
Вопросы для проверки:
- Если вы хотите выполнить сразу несколько задач в Go, что нужно использовать?
- Какое ключевое слово используется для запуска новой независимой задачи?
Создание нескольких горутин в Golang
При каждом использовании ключевого слова go
начинается новая горутина. Все появления горутин должны запускаться одновременно. Технически они могут не запускаться одновременно, это зависит от процессора.
По факту все процессоры обычно тратят некоторое время на одну горутину перед переходом к следующей, используя так называемое разделение времени, или time sharing. Как именно это происходит известно только среде выполнения Go, а также используемой операционной системе и процессору. Всегда лучше быть готовым к тому, что операции из разных горутин могут запускаться в хаотичной очереди.
Функция main
в Листинге 2 начинается с пятью горутинами sleepyGopher
. Все они спят по три секунды, а затем выводят на экран одно и то же.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
package main import ( "fmt" "time" ) func main() { for i := 0; i < 5; i++ { go sleepyGopher() } time.Sleep(4 * time.Second) } func sleepyGopher() { time.Sleep(3 * time.Second) fmt.Println("... snore ...") } |
Мы можем выяснить кто выполнится первым, передав аргумент каждой горутине. Передача аргумента горутине похожа на передачу аргумента любой другой функции: значение копируется и передается как параметр.
При запуске следующего листинга вы увидите, что хотя мы запустили все горутины по очереди от нуля до девяти, они все завершились в разное время. Если вы будете запускать код не на Go playground, то каждый раз будет получен иной результат.
1 2 3 4 5 6 7 8 9 10 11 |
func main() { for i := 0; i < 5; i++ { go sleepyGopher(i) } time.Sleep(4 * time.Second) } func sleepyGopher(id int) { time.Sleep(3 * time.Second) fmt.Println("... ", id, " snore ...") } |
В данном коде есть проблема. Он ждет четыре секунды, хотя нужно подождать только три секунды. Если горутины не только спят но и делают другие операции, то мы не сможем узнать, сколько им потребуется времени для завершения работы. Нам потребуется найти способ, через который код сможет узнать, когда горутины завершаются. К счастью, для этого в Go есть каналы.
Вопрос для проверки:
В каком порядке запускаются разные горутины?
Каналы общения между горутинами в Golang
Канал может использоваться для безопасного отправления значений от одной горутины к другой. Подумайте о канале как о системе пневматической почты в старых офисах. Если опустить в емкость предмет, он переместится по трубе на другой конец, где его получит другой работник.
Как и другие типы в Go, каналы могут использоваться как переменные которые переданы функциям, сохраненные в структуре, и делать многое другое.
Для создания канала используется встроенная функция make
, та же самая, что используется для создания карт и срезов. У каналов есть тип, что уточняется во время их создания. Следующий канал может отправлять и принимать только целочисленные integer значения:
1 |
c := make(chan int) |
Каналу можно отправить значения, а также получить отправленные значения. Отправлять и получать значения канал может через оператор левой стрелки (<-
).
Для отправки значения стрелка указывается в направлении выражения канала, будто стрелка говорит значению справа перетечь в канал. Операция отправления подождет пока что-то (в другой горутине) попытается получить что-то на том же канале. В то время как оно ждет, отправляющий элемент больше не может ничего делать, хотя все остальные горутины продолжат выполняться свободно (если они не ждут операций с каналом). Следующий код посылает значение 99:
1 |
c <- 99 |
Для получения значения из канала стрелка указывает в другую сторону от канала (в левую часть от канала). В следующем коде мы получаем значение из канала c
и сохраняем это значение а переменную r
. Аналогично отправке каналу, получатель подождет, пока другая горутина не попытается отправить что-то тому же каналу:
1 |
r := <-c |
На заметку: Операции приема канала пишутся на отдельной строке, хотя это не обязательно. Операция приема канала может использоваться везде, где любое другое выражение используется.
Код в Листинге 4 создает канал и передает его пяти спящим горутинам. Затем он ждет получения пяти сообщений, одним из которых является начатая горутина. Каждая горутина спит и затем отправляет значения, тем самым идентифицируя себя. Когда процесс достигает функции main
, мы точно знаем, что все гоферы закончат сон, и возвращение произойдет без помехи для сна гофера. К примеру, скажем, что программа хранит результаты какого-то числового вычисления в онлайн хранилище. Может сохраниться несколько вещей сразу, и мы не хотим завершить программу пока все результаты не будут успешно сохранены.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
func main() { c := make(chan int) // Делает канал для связи for i := 0; i < 5; i++ { go sleepyGopher(i, c) } for i := 0; i < 5; i++ { gopherID := <-c // Получает значение от канала fmt.Println("gopher ", gopherID, " has finished sleeping") } } func sleepyGopher(id int, c chan int) { // Объявляет канал как аргумент time.Sleep(3 * time.Second) fmt.Println("... ", id, " snore ...") c <- id // Отправляет значение обратно к main } |
Квадратные элементы на Схеме 1 иллюстрируют горутины, а круг представляет канал. Ссылка из горутины к каналу помечена названием переменной, что отсылается к каналу. Направление ссылки представляет способ, который горутина использует для канала. Когда стрелка указывает на горутину, горутина читает из канала.
Схема 1: Горутины общаются через один канал связи
Вопросы для проверки:
- Какое выражение можно использовать для отправки строки
"hello world"
на канал под названиемс
? - Как бы вы получили значение и присвоили его переменной?
Использование select в канале горутин
В предыдущем примере мы использовали один канал для ожидания ответа от нескольких горутин. Он хорошо работает, когда все горутины возвращают один и тот же тип значения, но так происходит не всегда. Часто нам нужно подождать два или более значения разных типов.
Одним из примеров этого является случай ожидания некоторых значений по каналу, но ждать слишком долго нельзя. Возможно, мы немного нетерпеливы по отношению к сонным гоферам, и терпение это не вечно. Или нам еще может потребоваться установить тайм-аут сетевого запроса после несколько секунд, а не несколько минут.
К счастью стандартная библиотека Go предоставляет полезную функцию time.After
, которая может нам значительно помочь. Она возвращает канал, который получает значение по прошествии некоторого времени (горутина, что отправляет значение, является частью среды выполнения Go).
Нам нужно продолжать получать значения от сонных гоферов, пока они не закончат спать, или у нас не кончится терпение. Это означает, что нам нужно одновременно ждать таймера одного и другого канала. Оператор select
позволяет нам это сделать.
Оператор select
выглядит как оператор switch. Каждый case
внутри select
содержит канал получения или отправки. select
ждет завершения одного case
, а затем запускает его и связанный с ним оператор case
. Как будто select
смотрит на оба канала сразу и действует, когда видит, что что-то случается с любым из них.
В следующем листинге используется time.After
для создания канала тайм-аута, а затем используется select
для ожидания каналом сонных гоферов и тайм-аута канала.
1 2 3 4 5 6 7 8 9 10 |
timeout := time.After(2 * time.Second) for i := 0; i < 5; i++ { select { // Оператор select case gopherID := <-c: // Ждет, когда проснется гофер fmt.Println("gopher ", gopherID, " has finished sleeping") case <-timeout: // Ждет окончания времени fmt.Println("my patience ran out") return // Сдается и возвращается } } |
На заметку: Когда в операторе
select
нет ни одного случаяcase
, он будет ждать вечно. Это может оказаться полезным для остановки возвращения функцииmain
, когда вы начинаете горутины, что должны перестать выполняться на неопределенное время.
Не очень интересно, когда все гоферы спять в точности по три секунды, потому что терпение всегда заканчивается до момента их пробуждения. Гоферы в следующем листинге спять случайное количество времени. При запуске вы увидите, что некоторые гоферы просыпаются вовремя, а некоторые нет.
1 2 3 4 |
func sleepyGopher(id int, c chan int) { time.Sleep(time.Duration(rand.Intn(4000)) * time.Millisecond) c <- id } |
Данный паттерн полезен, когда вам нужно ограничить время на выполнения определенной операции. Поместив действие внутрь горутины и отправив его каналу, когда тот завершен, можно добиться фиксированного времени практически для всего в Go.
Хотя мы перестали ждать горутин, если мы не вернулись из функции main
, они все еще будут существовать, занимая память. Если возможно, лучше завершить их работу.
Пока все хорошо. Когда функция main
получила что-то на канале, она нашла гофера, отправляющего значение каналу. Но что произойдет, если мы случайно попытаемся прочитать предполагаемые горутины, которые перестали работать и ничего не отправляют? Или если мы попытаемся что-то отправить каналу, а не получить?
Вопросы для проверки:
- Значение какого рода возвратит
time.After
? - Что произойдет, если вы что-то отправите или получите на канал nil?
- Что есть в каждом случае
case
сselect
?
Каналы nil в Golang ничего не делают
Из-за того, что вам нужно явно создать каналы через make
, вам может показаться интересным, что произойдет, если вы используете значения каналов, что не были созданы. Как и в случае с картами, срезами и указателями, каналы могут быть nil
. nil
является их нулевым значением по умолчанию.
Если вы попробуете использовать канал nil
, он не вызовет сбой — вместо этого операция (отправки или приема) заблокируется навечно, как канал, что никогда ничего не отправляет или не получает. Исключением является close
, который подробнее будет описан далее. Если вы попытаетесь закрыть канал nil, произойдет сбой.
С первого взгляда это может показаться не очень полезным, но это не совсем так. Рассмотрим цикл с оператором select
. Мы не хотим ждать все каналы, упомянутые в select
, на протяжении цикла. К примеру, мы можем просто попытаться отправить что-то в канал, когда у нас будет готовое значение. Это можно сделать, используя переменную канала, что не является nil только тогда, когда нам нужно отправить значение.
Блокировка и deadlock в Golang
Когда горутина ожидает отправки или получения данных из канала, мы говорим, что она заблокирована. Это похоже на то, как если бы мы написали какой-то код с циклом, который выполняется вечно, ничего не делая, и на первый взгляд они выглядят точно так же. Но если вы запустите бесконечный цикл в программе на ноутбуке, вы можете обнаружить, что вентилятор начинает гудеть, компьютер нагревается, потому что он совершает много работы. Если сравнить, то заблокированная горутина не требует ресурсов (кроме небольшого объема памяти, используемой самой горутиной). Она тихо ждет, пока все, что ее блокирует, перестанет ее блокировать.
Когда одна или несколько горутин блокируются из-за того, что никогда не произойдет, это называется тупиком или deadlock. Программа будет зависать или сбиваться. Deadlock может быть вызван чем-то простым:
1 2 3 4 |
func main() { c := make(chan int) <-c } |
В больших программах deadlock может включать в себя сложную серию зависимостей между горутинами.
Хотя теоретически трудно защититься, на практике можно придерживаться нескольких простых правил (которые мы скоро рассмотрим). Создать программу без deadlock несложно. Когда сталкиваетесь с тупиком (deadlock), Go может показать вам состояние всех горутин, поэтому зачастую выяснить причину происходящего не составляет труда.
Вопрос для проверки:
Что делает заблокированная горутина?
Объединение горутин в конвейер
Пока что наши гоферы были сонными. Они спали, потом проснулись и пересылали только по одному значению своему каналу. Но не все гоферы на фабрике такие же. Некоторые выстраиваются в линию, получая предмет от другого гофера в очереди, обрабатывают его, а затем посылают следующему гоферу в очереди. Хотя работа каждого гофера довольно проса, финал их объединения может стать поразительным.
Данная техника называется конвейером (pipeline). Он полезен для производства крупных потоков данных без использования больших объемов памяти. Хотя каждая горутина может содержать только одно значение за раз, со временем она может обработать множество значений. Конвейер очень полезен помогая решать многие проблемы более простыми способами.
У нас уже есть все инструменты, необходимые для объединения горутин в конвейер. Значения переместятся в конвейер, передаваясь от одной горутины к другой. Работник на конвейере получает значение от соседа в верхнем потоке, делает с ним что-то, а затем передает его в нижний поток.
Создадим объединение работников, что обрабатывают строки. Гофер в начале ряда объединения показан в Листинге 7 — источник потока. Данный гофер не читает значения, он только отправляет их. В другой программе может задействоваться чтение данных из файла, базы данных или сети, но здесь мы просто отправим несколько произвольных значений. Чтобы сказать гоферам в нижнем потоке, что значений больше нет, источник отправляет контрольное значение, пустую строку, для указания, что все готово и больше работы не будет.
1 2 3 4 5 6 |
func sourceGopher(downstream chan string) { for _, v := range []string{"hello world", "a bad apple", "goodbye all"} { downstream <- v } downstream <- "" } |
Гофер из Листинга 8 отфильтровывает все плохое из потока. Он читает элемент из верхнего канала и посылает его в канал нижнего потока только тога, когда в значении нет строки "bad"
. Когда он видит пустую строку, то гофер-фильтр завершает свою работу, отправив пустую строку следующему гоферу вниз по линии, тем самым заканчивая работу как кости в домино.
1 2 3 4 5 6 7 8 9 10 11 12 |
func filterGopher(upstream, downstream chan string) { for { item := <-upstream if item == "" { downstream <- "" return } if !strings.Contains(item, "bad") { downstream <- item } } } |
Гофер, что находится в конце конвейера — гофер вывода — показан в Листинге 9. У этого гофера нет ничего ниже него в потоке. В другой программе результаты можно было бы сохранить в файл или вывести в терминал итог рассмотренных значений. Здесь гофер-вывода показывает все увиденные значения.
1 2 3 4 5 6 7 8 9 |
func printGopher(upstream chan string) { for { v := <-upstream if v == "" { return } fmt.Println(v) } } |
Давайте объединим работников-гоферов вместе. У нас три стадии в конвейере (источник, фильтр, вывод), но только два канала. Нам не нужно начинать новую горутину за последнего гофера, потому что мы хотим подождать его выполнения перед выходом из целой программы. Если у последнего гофера все же запустить горутину, то программа завершится сразу после запуска, так как это конец программы. Горутины просто не успеют отработать.
Когда функция printGopher
завершает свою работу, то нам известно, что две другие горутины так же завершили свою работу, и мы можем вернуться в main
, завершив всю программу, как показано в следующем листинге, а также на Схеме 2.
1 2 3 4 5 6 7 |
func main() { c0 := make(chan string) c1 := make(chan string) go sourceGopher(c0) go filterGopher(c0, c1) printGopher(c1) } |
Схема 2: Конвейер потоков в Go
На данный момент у нас есть одна проблема с кодом для конвейера. Мы используем пустую строку как способ обозначения, что больше нет данных для обработки. Но что, если нам нужно обработать пустую строку, предположим, у нее другое значение? Вместо строк мы могли бы отправить значение структуры, содержащее как нужную строку, так и булево поле, говорящее, является ли значение последним.
Но есть способ лучше. Go позволяет закрыть канал через close
для обозначения, что значения больше не будут отправляться.
1 |
close(c) |
После закрытия канала вы не можете записать в него новые значения (будет сбой при попытке), и любое чтение вернется сразу с нулевым значением для типа (в данном случае это пустая строка).
На заметку: Будьте осторожны! Если вы читаете из закрытого канала в цикле, не проверив закрыт ли он, цикл станет вечным, сжигая много процессорного времени. Убедитесь, что вы знаете, какие каналы могут быть закрыты.
Как узнать, был ли канал закрыт? Следующим образом:
1 |
v, ok := <-c |
Когда мы присваиваем результат двум переменным, вторая переменная сообщит, было ли чтение из канала успешным. У закрытого канала будет значение false
.
С этими новыми инструментами мы можем легко закрыть весь конвейер. Следующий листинг показывает источник горутины во главе конвейера.
1 2 3 4 5 6 |
func sourceGopher(downstream chan string) { for _, v := range []string{"hello world", "a bad apple", "goodbye all"} { downstream <- v } close(downstream) } |
Следующий листинг показывает, как теперь выглядит фильтр горутины.
1 2 3 4 5 6 7 8 9 10 11 12 |
func filterGopher(upstream, downstream chan string) { for { item, ok := <-upstream if !ok { close(downstream) return } if !strings.Contains(item, "bad") { downstream <- item } } } |
Данный паттерн чтения из канала, пока тот не закроется, является достаточно популярным, у него даже есть сокращенный вариант. Если мы используем канал в операторе range
, он будет читать значения из канала, пока тот не будет закрыт.
Это значит, что код можно переписать проще с циклом range
. Следующий код делает то же самое, что и предыдущий.
1 2 3 4 5 6 7 8 |
func filterGopher(upstream, downstream chan string) { for item := range upstream { if !strings.Contains(item, "bad") { downstream <- item } } close(downstream) } |
Финальный гофер в конвейере (pipeline) читает все сообщения и выводит их, одно за другим, как показано в следующем примере.
1 2 3 4 5 |
func printGopher(upstream chan string) { for v := range upstream { fmt.Println(v) } } |
Вопросы для проверки:
- Какое значение вы увидите при чтении из уже закрытого канала?
- Как можно проверить, был ли закрыт канал?
Заключение
- Оператор
go
начинает новую горутину, запускаясь последовательно; - Каналы используются для отправки значений между горутинами;
- Канал создается через
make(chan string)
; - Оператор
<-
принимает значения от канала (когда используется перед значением канала); - Оператор
<-
отправляет значения каналу (когда расположен между значением канала и значением того что будет отправлено); - Функция
close
закрывает канал; - Оператор range читает все отправленные значения из канала, пока тот не будет закрыт.
Итоговое задание для проверки #1:
Скучно, когда одинаковая строка кода повторяется из раза в раз. Напишите элемент конвейера (горутину), что запоминает предыдущее значение и только отправляет значение на следующий этап конвейера, если оно отличается от того, что пришло ранее. Чтобы все упростить, можете предположить, что первая строка никогда не бывает простой.
Итоговое задание для проверки #2:
Иногда проще оперировать словами, а не предложениями. Напишите конвейер, что принимает строки и разделяет их на слова (можете использовать функцию Fields
из пакета strings
), а также отправляет все слова, одно за другим, на следующую стадию конвейера.
Администрирую данный сайт с целью распространения как можно большего объема обучающего материала для языка программирования Go. В IT с 2008 года, с тех пор изучаю и применяю интересующие меня технологии. Проявляю огромный интерес к машинному обучению и анализу данных.
E-mail: vasile.buldumac@ati.utm.md
Образование
Технический Университет Молдовы (utm.md), Факультет Вычислительной Техники, Информатики и Микроэлектроники
- 2014 — 2018 Universitatea Tehnică a Moldovei, ИТ-Инженер. Тема дипломной работы «Автоматизация покупки и продажи криптовалюты используя технический анализ»
- 2018 — 2020 Universitatea Tehnică a Moldovei, Магистр, Магистерская диссертация «Идентификация человека в киберпространстве по фотографии лица»
Здравствуйте, у вас перепутаны переменные upstream и downstream между собой в коде