Краткое введение в горутины и каналы
Параллелизм довольно сложная тема. Различные языки программирования решают эту проблему по-разному. Некоторые из них действительно сложные, некоторые - немного меньше. Но я бы осмелился сказать, что Go действительно преуспевает в этой области. Причина, по которой он так хорошо обрабатывает параллелизм, заключается в том, что он был создан в 21 веке, когда многоядерные процессоры являются отраслевым стандартом, а скорость выполнения имеет существенное значение.
Что такое горутина?
Goroutine - это, по сути, очень легкий заменитель треда. Если вы переходите с Java, вы, вероятно, знаете, что один поток Java по умолчанию выделяет 1МБ памяти. С другой стороны, одна горутина занимает всего 2кб (!). Он может динамически добавлять больше памяти, но не тратит ее впустую.
Как реализовать горутину
Теперь давайте посмотрим на код. Допустим, я не хочу получать доступ к стороннему API для получения информации. Мне нужно довольно много информации, и она нужна мне быстро. Зная, что большая часть времени, необходимого для получения данных из простого HTTP-запроса, тратится на ожидание ответа удаленного сервера, я решаю использовать параллелизм.
Чтобы упростить задачу, я сделаю шаг за шагом и сначала сделаю это синхронным способом. И для начала мне нужна функция, которая отправляет вызов API и возвращает тело запроса:
func getData(url string) string {
r, err := http.Get(url)
if err != nil {
log.Fatal(err)
}
defer r.Body.Close()
body, err := ioutil.ReadAll(r.Body)
if err != nil {
log.Fatal(err)
}
return string(body)
}
Как мы знаем, запрос, являющийся сетевым вызовом, может пойти не так, поэтому я проверяю наличие ошибок. Я также вызываю defer r.Body.Close()
, потому что таким образом мы говорим этой функции закрыть тело запроса после того, как она закончила с ним работать, так же, как мы закрываем файл после чтения его содержимого.
Наконец, мы присваиваем переменной тела значение, возвращаемое в результате вызова функции ioutil.ReadAll, которая принимает параметр, реализующий интерфейс io.Reader (который довольно часто реализуется в Go), и возвращает массив байтов, который мы впоследствии можно легко преобразовать в строку.
Теперь давайте вызовем эту функцию в нашей основной функции. Я буду использовать свой любимый API, Rick and Morty API, который я использую во всех своих примерах (к счастью, не очень многие люди реализуют код из моих статей, иначе этот сервер API будет недоступен 24/7):
func main() {
r := getData("https://rickandmortyapi.com/api/character/100")
fmt.Println(r)
}
Так здорово, что мы получили некоторую информацию о персонаже 100, которому очень повезло - «Бубонная чума». Отлично. Но нам действительно нужна информация обо всех персонажах Рика и Морти, а не только о мистере Чуме. И мы не хотим ждать десятки или сотни секунд, чтобы получить это, нам это нужно сейчас! На помощь приходят горутины:
func getDataFaster(url string, c chan string) {
r, err := http.Get(url)
if err != nil {
log.Fatal(err)
}
defer r.Body.Close()
body, err := ioutil.ReadAll(r.Body)
if err != nil {
log.Fatal(err)
}
c <- string(body)
}
Ого! Это не тот синтаксис, которого вы ожидали, верно? Я имею в виду, что это за стрелка? И что такое chan
?
Чтобы я мог объяснить, позвольте мне показать вам версию, которую вы ожидали, и то, как мы будем вызывать ее в основной функции (спойлер: это не будет работать должным образом):
func main() {
for i := 1; i < 200; i++ {
go getDataFaster("https://rickandmortyapi.com/api/character/" + strconv.Itoa(i))
}
}
func getDataFaster(url string) {
r, err := http.Get(url)
if err != nil {
log.Fatal(err)
}
defer r.Body.Close()
body, err := ioutil.ReadAll(r.Body)
if err != nil {
log.Fatal(err)
}
fmt.Println(string(body))
}
На данный момент мы изменили функцию getDataFaster только для печати результата, а не для его возврата. Я объясню почему позже. Мы также используем цикл for, чтобы получить все 200 символов (на самом деле их больше, но мы действительно не хотим так много спамить). Поскольку i
это целое число, мы будем использовать встроенный модуль strconv
для преобразования его в строку. А теперь самое интересное. Мы используем ключевое слово go
, чтобы сообщить компилятору go, что мы хотим, чтобы эта функция выполнялась асинхронно, то есть выполнялась так быстро, что кажется, что все 200 вызовов функций выполняются одновременно. К сожалению, когда мы запускаем это, мы обнаруживаем, что он ничего не делает.
Причина, по которой это ничего не возвращает, заключается в том, что основная функция, по сути, является собственной горутиной, она просто прошла цикл for и вышла. Его не волнует, что остальные 200 горутин еще не закончили свою работу.
Введите каналы!
Помните этот уродливый код со стрелкой и chan
. Это каналы. Канал - это, по сути, место для хранения некоторого значения из нашей горутины и включения другой горутины (в данном случае нашей основной функции) для получения этого значения. Эта стрелка в конце в основном говорит - вместо того, чтобы возвращать string(body)
, как в обычной функции, вставьте ее в канал, потому что это не обычная функция, она асинхронная.
Хорошо, но как же тогда основная функция получает доступ к этим значениям, спросите вы? Посмотрим в следующем фрагменте кода:
package main
import (
"fmt"
"io/ioutil"
"log"
"net/http"
"strconv"
)
func main() {
ch := make(chan string)
var data []string
for i := 1; i < 200; i++ {
go getDataFaster("https://rickandmortyapi.com/api/character/"+strconv.Itoa(i), ch)
data = append(data, <-ch)
}
fmt.Println(data)
}
func getDataFaster(url string, c chan string) {
r, err := http.Get(url)
if err != nil {
log.Fatal(err)
}
defer r.Body.Close()
body, err := ioutil.ReadAll(r.Body)
if err != nil {
log.Fatal(err)
}
c <- string(body)
}
Кстати, этот код включает в себя все объявления пакетов и импорта для людей, которые здесь только для копирования / вставки.
Как видите, первое, что мы делаем, это объявляем канал с помощью встроенной функции make
. Канал также должен быть статически типизирован, поэтому мы объявляем канал, который будет содержать только строки. Затем мы объявляем фрагмент строк, называемый данными, где мы планируем хранить всю эту информацию обо всех этих дурацких персонажах Рика и Морти. Мы входим в цикл for, как и раньше, но теперь у нас снова есть эта странная стрелка. На этот раз канал не тот, который находится на принимающей стороне, а скорее кажется тем, который отправляет. Здесь мы добавляем значение, которое канал будет хранить для нас, в наш срез данных.
Таким образом мы инициируем операцию блокировки. Это означает, что основная функция будет заблокирована до тех пор, пока значение не будет фактически получено из канала. Вот как мы заставляем основную функцию ждать, вместо того, чтобы по неосторожности выйти, как это было в первый раз.
Это также причина, по которой мы не можем присвоить значение вызову горутины (я сказал, что объясню позже), потому что он асинхронный код и не возвращает никакого значения, он может только добавить его в канал.
Наконец, мы печатаем все на консоль, и, поскольку она выдает 200 результатов, ваша консоль, вероятно, взорвется, как моя: