Введение в параллелизм в Go

Параллелизм — это классная тема, которая может стать огромным преимуществом, когда вы освоите ее. Честно говоря, сначала мне было страшно писать этот пост, потому что до недавнего времени я и сам не слишком хорошо разбирался в параллелизме. Я освоил основы, поэтому хотел помочь другим новичкам освоить параллелизм в Go. Это первый из многих уроков по параллелизму, так что следите за новостями!

Что такое параллелизм и почему он важен?

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

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

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

Параллелизм и параллелизм

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

Таким образом, параллелизм и параллельность — это два разных понятия. Программа может выполняться как параллельно, так и одновременно. Ваш код может быть написан как последовательно, так и параллельно. Этот код может выполняться на одноядерной или многоядерной машине. Думайте о параллелизме как о характеристике вашего кода, а о параллельности — как о характеристике выполнения.

Гороутины, рабочие Мортис

Go позволяет очень просто писать параллельный код. Каждая параллельная работа представлена горутиной. Вы можете запустить горутину, используя ключевое слово go перед вызовом функции. Вы когда-нибудь смотрели сериал «Рик и Морти»? Представьте себе, что ваша функция main — это Рик, который делегирует задачи горутине Морти.

Давайте начнем с последовательного кода.

package main

import (
    "fmt"
    "time"
)

func main() {
    simple()
}

func simple() {
    fmt.Println(time.Now(), "0")
    time.Sleep(time.Second)

    fmt.Println(time.Now(), "1")
    time.Sleep(time.Second)

    fmt.Println(time.Now(), "2")
    time.Sleep(time.Second)

    fmt.Println("done")
}
Вход в полноэкранный режим Выход из полноэкранного режима
2022-08-14 16:22:46.782569233 +0900 KST m=+0.000033220 0
2022-08-14 16:22:47.782728963 +0900 KST m=+1.000193014 1
2022-08-14 16:22:48.782996361 +0900 KST m=+2.000460404 2
done
Войти в полноэкранный режим Выход из полноэкранного режима

Приведенный выше код печатает текущее время вместе со строкой. На выполнение каждого оператора печати уходит одна секунда. В общей сложности на выполнение кода ушло около трех секунд.

Теперь давайте сравним это с параллельным кодом.

func main() {
    simpleConc()
}

func simpleConc() {
    for i := 0; i < 3; i++ {
        go func(index int) {
            fmt.Println(time.Now(), index)
        }(i)
    }

    time.Sleep(time.Second)
    fmt.Println("done")
}
Вход в полноэкранный режим Выход из полноэкранного режима
2022-08-14 16:25:14.379416226 +0900 KST m=+0.000049175 2
2022-08-14 16:25:14.379446063 +0900 KST m=+0.000079012 0
2022-08-14 16:25:14.379450313 +0900 KST m=+0.000083272 1
done
Вход в полноэкранный режим Выход из полноэкранного режима

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

«Подождите», — спросите вы. «Зачем ждать целую секунду? Разве мы не можем убрать эту строку, чтобы программа выполнялась как можно быстрее?». Хороший вопрос! Давайте посмотрим, что произойдет.

func main() {
    simpleConcFail()
}

func simpleConcFail() {
    for i := 0; i < 3; i++ {
        go func(index int) {
            fmt.Println(time.Now(), index)
        }(i)
    }

    fmt.Println("done")
}
Вход в полноэкранный режим Выход из полноэкранного режима
done
Войти в полноэкранный режим Выйти из полноэкранного режима

Хм… Программа действительно завершилась без паники, но мы пропустили вывод из goroutines. Почему они были пропущены?

Это потому, что по умолчанию Go не ждет завершения работы goroutines. Знаете ли вы, что main также выполняется внутри goroutine? Горутина main запускает рабочие горутины, вызывая simpleConcFail, но выходит из нее еще до того, как рабочие успевают завершить свою работу.

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

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

Подводя итог, мы узнали следующее:

  • Задания делегируются горутинам.

  • Использование параллелизма может повысить производительность.

  • Горутина main по умолчанию не ждет завершения рабочих горутин.

  • Нам нужен способ дождаться завершения работы каждой из goroutine.

Каналы, зеленый портал

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

ch := make(chan int)
Войти в полноэкранный режим Выйти из полноэкранного режима

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

func main() {
    unbufferedCh()
}

func unbufferedCh() {
    ch := make(chan int)

    go func() {
        ch <- 1
    }()

    res := <-ch
    fmt.Println(res)
}
Войти в полноэкранный режим Выход из полноэкранного режима
1
Войти в полноэкранный режим Выход из полноэкранного режима

Все просто, верно? Мы создаем канал с именем ch. У нас есть goroutine, которая посылает 1 в ch, а мы получаем эти данные и сохраняем их в res.

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

func main() {
    unbufferedChFail()
}

func unbufferedChFail() {
    ch := make(chan int)
    ch <- 1
    res := <-ch
    fmt.Println(res)
}
Вход в полноэкранный режим Выход из полноэкранного режима
fatal error: all goroutines are asleep - deadlock!
Вход в полноэкранный режим Выход из полноэкранного режима

Мы столкнулись с новым словом. Что такое тупик? Тупик — это когда ваша программа застревает. Почему приведенный выше код зашел в тупик?

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

В примере с неудачей действия отправки и получения происходят последовательно. Мы отправляем 1 на ch, но в это время некому принимать данные. Получение происходит на более поздней строке, а это значит, что 1 не может быть отправлен, пока не будет запущена линия получения. К сожалению, 1 не может быть отправлен первым, потому что ch является небуферизованным и не имеет места для хранения данных.

В рабочем примере действия отправки и получения происходят одновременно. Функция main запускает goroutine и пытается получить данные от ch. В это время горутина отправляет 1 в ch. Поэтому этот код может выполняться без тупика.

Еще один способ получать из канала без тупиков — сначала закрыть канал.

func main() {
    unbufferedCh()
}

func unbufferedCh() {
    ch2 := make(chan int)
    close(ch2)
    res2 := <-ch2
    fmt.Println(res2)
}
Вход в полноэкранный режим Выход из полноэкранного режима
0
Войти в полноэкранный режим Выход из полноэкранного режима

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

Подводя итог, мы узнали следующее:

  • Каналы — это способ связи горутин друг с другом.

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

  • Каналы сильно типизированы.

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

  • Закрытый канал не принимает никаких данных.

  • Получение данных из закрытого небуферизованного канала вернет нулевое значение.

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

Буферизованные каналы, портал, который почему-то цилиндрический?

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

func main() {
    bufferedCh()
}

func bufferedCh() {
    ch := make(chan int, 1)
    ch <- 1
    res := <-ch
    fmt.Println(res)
}
Вход в полноэкранный режим Выход из полноэкранного режима
1
Войти в полноэкранный режим Выход из полноэкранного режима

Здесь 1 хранится внутри ch, пока мы его не получим.

Очевидно, что мы не можем послать больше в канал с полной буферизацией. Прежде чем отправить больше, необходимо освободить место в буфере.

func main() {
    bufferedChFail()
}

func bufferedChFail() {
    ch := make(chan int, 1)
    ch <- 1
    ch <- 2
    res := <-ch
    fmt.Println(res)
}
Вход в полноэкранный режим Выход из полноэкранного режима
fatal error: all goroutines are asleep - deadlock!
Войти в полноэкранный режим Выход из полноэкранного режима

Вы также не можете принимать из пустого буферизованного канала.

func main() {
    bufferedChFail2()
}

func bufferedChFail2() {
    ch := make(chan int, 1)
    ch <- 1
    res := <-ch
    res2 := <-ch
    fmt.Println(res, res2)
}
Вход в полноэкранный режим Выход из полноэкранного режима
fatal error: all goroutines are asleep - deadlock!
Войти в полноэкранный режим Выход из полноэкранного режима

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

func main() {
    bufferedCh2()
}

func bufferedCh2() {
    ch := make(chan int, 1)
    ch <- 1
    go func() {
        ch <- 2
    }()
    res := <-ch
    fmt.Println(res)
}
Вход в полноэкранный режим Выход из полноэкранного режима
1
Вход в полноэкранный режим Выход из полноэкранного режима

Мы получаем один раз, чтобы извлечь 1, чтобы goroutine могла отправить 2 в канал. Мы не получили от ch дважды, поэтому будет получен только 1.

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

func main() {
    bufferedChRange()
}

func bufferedChRange() {
    ch := make(chan int, 3)
    ch <- 1
    ch <- 2
    ch <- 3
    close(ch)
    for res := range ch {
        fmt.Println(res)
    }
    // you could also do this
    // fmt.Println(<-ch)
    // fmt.Println(<-ch)
    // fmt.Println(<-ch)
}
Вход в полноэкранный режим Выход из полноэкранного режима
1
2
3
Войти в полноэкранный режим Выход из полноэкранного режима

Перемещение по открытому каналу никогда не прекратится. Это означает, что в какой-то момент канал будет пуст, и цикл диапазона будет пытаться получить сигнал из пустого канала, что приведет к тупиковой ситуации.

Подведем итог,

  • Буферизованные каналы — это каналы с пространством для хранения элементов.

  • Отправка и получение не обязательно должны происходить одновременно, в отличие от небуферизованных каналов.

  • Отправка в полный канал и получение из пустого канала приведет к тупиковой ситуации.

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

В ожидании Годо… Я имею в виду завершение работы горутин с использованием каналов.

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

func main() {
    basicSyncing()
}

func basicSyncing() {
    done := make(chan struct{})

    go func() {
        for i := 0; i < 5; i++ {
            fmt.Printf("%s worker %d startn", fmt.Sprint(time.Now()), i)
            time.Sleep(time.Duration(rand.Intn(5)) * time.Second)
        }
        close(done)
    }()

    <-done
    fmt.Println("exiting...")
}
Вход в полноэкранный режим Выход из полноэкранного режима

Мы создаем канал done, который отвечает за блокировку кода до тех пор, пока не завершится работа goroutine. done может быть любого типа, но struct{} часто используется для каналов такого типа. Его назначение не в передаче структур, поэтому его тип не имеет значения.

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

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

Заключение

Параллелизм может показаться сложной темой. Я, конечно, считал, что так оно и есть. Однако, после понимания основ, я думаю, что реализация действительно прекрасна. Надеюсь, вы, ребята, сможете извлечь что-то из этого руководства! Мы лишь поцарапали поверхность, и Go может предложить нам гораздо больше. Увидимся в следующий раз с новыми уроками по параллелизму. Пока!

Вы также можете прочитать этот пост на Medium и на моем личном сайте.

Оцените статью
devanswers.ru
Добавить комментарий