Бесплатная миграция IT-инфраструктуры в облако

Многопоточность в Golang

Миша Курушин
Миша Курушин
Технический писатель
24 ноября 2023 г.
1126
14 минут чтения
Средний рейтинг статьи: 5

Однопоточные приложения в Golang выглядят как обычный последовательно выполняющийся код.

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

Никаких общих данных, проблем одновременного доступа (чтения и записи) и синхронизации.

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

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

Простое приложение

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

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

package main

import (
    "fmt" // для вывода в консоль
    "time" // для создания таймаута
)

func mining(name string, progress *int, dungeon *[]string) { // используем указатели на переменные прогресса и содержимого шахты
    if *progress < len(*dungeon) { // проверяем, точно ли выработка шахты меньше ее размера
        time.Sleep(2 * time.Second) // ставим выполнение кода на паузы на 2 секунды, тем самым имитируя процесс копки
        fmt.Printf("В шахте «%s» найдено: «%s»\n", name, (*dungeon)[*progress]) // сообщаем в консоль информацию найденном ресурсе и название шахты (обратите внимание, как происходит разыменование указателя на массив)
        *progress++ // увеличиваем значение выработки шахты
        mining(name, progress, dungeon) // повторяем процесс копки
    }
}

func main() {
    dungeon1 := []string{"камень", "железо", "золото", "камень", "золото"} // шахта №1
    dungeon1Progress := 0 // состояние выработки шахты №1

    dungeon2 := []string{"камень", "камень", "железо", "камень"} // шахта №2
    dungeon2Progress := 0// состояние выработки шахты №2

    mining("Зарубки", &dungeon1Progress, &dungeon1) // начинаем процесс копки шахты №1
    mining("Каменки", &dungeon2Progress, &dungeon2) // начинаем процесс копки шахты №2
}

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

В шахте «Зарубки» найдено: «камень»
В шахте «Зарубки» найдено: «железо»
В шахте «Зарубки» найдено: «золото»
В шахте «Зарубки» найдено: «камень»
В шахте «Зарубки» найдено: «золото»
В шахте «Каменки» найдено: «камень»
В шахте «Каменки» найдено: «камень»
В шахте «Каменки» найдено: «железо»
В шахте «Каменки» найдено: «камень»

Обратите внимание, что сначала выкапывается шахта «Зарубки», а уже потом «Каменки». Такая последовательная (однопоточная) копка кажется довольно медленной и неэффективной.

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

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

Горутины (Goroutines)

Распараллелить выполнение нескольких задач можно с помощью так называемых «горутин» (Goroutines).

По сути «горутина» — это функция, которая не прекращает выполнение программы (кода, который указан после нее) в тот момент, когда начинает выполняться сама.

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

Вот вам небольшой пример, состоящий из псевдокода:

...
func main() {
    // эти функции будут выполняться последовательно

    action()
    action()
    action()

    // эти функции начнут свое одновременное выполнение сразу после вызова

    go anotherAction() // указано «go», значит выполнение кода после функции продолжится не дожидаясь ее результатов
    go anotherAction() // аналогично
    go anotherAction()
}

Теперь мы можем немного модифицировать наше приложение с шахтами:

package main

import (
    "fmt"
    "time"
)

func mining(name string, progress *int, dungeon *[]string) {
    if *progress < len(*dungeon) {
        time.Sleep(2 * time.Second)
        fmt.Printf("В шахте «%s» найдено: «%s»\n", name, (*dungeon)[*progress])
        *progress++
        mining(name, progress, dungeon)
    }
}

func main() {
    dungeon1 := []string{"камень", "железо", "золото", "камень", "золото"}
    dungeon1Progress := 0

    dungeon2 := []string{"камень", "камень", "железо", "камень"}
    dungeon2Progress := 0

    go mining("Зарубки", &dungeon1Progress, &dungeon1) // добавили ключевое слово «go»
    go mining("Каменки", &dungeon2Progress, &dungeon2) // аналогично добавили «go»

    for dungeon1Progress < len(dungeon1) && dungeon1Progress < len(dungeon1) { // выполняем этот цикл до тех пор, пока выработка каждой шахты не станет равна ее размеру
        fmt.Printf("Центр обеспечения ожидает возвращения шахтеров...\n")
        time.Sleep(3 * time.Second) // код внутри цикла выполняем каждые 3 секунды, выводя сообщения об импровизированном «Центре обеспечения»
    }
}

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

Центр обеспечения ожидает возвращения шахтеров...
В шахте «Каменки» найдено: «камень»
В шахте «Зарубки» найдено: «камень»
Центр обеспечения ожидает возвращения шахтеров...
В шахте «Зарубки» найдено: «железо»
В шахте «Каменки» найдено: «камень»
Центр обеспечения ожидает возвращения шахтеров...
В шахте «Каменки» найдено: «железо»
В шахте «Зарубки» найдено: «золото»
В шахте «Зарубки» найдено: «камень»
В шахте «Каменки» найдено: «камень»
Центр обеспечения ожидает возвращения шахтеров...
В шахте «Зарубки» найдено: «золото»

Здесь видно, что копка в обеих шахтах выполняется одновременно, при этом информация о результатах добычи ресурсов «разбавляется» сообщениями от «Центра обеспечения», которые периодически продуцирует основной цикл программы.

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

Каналы (Channels)

Каналы (Channels) — это своего рода «кабели», позволяющие горутинам обмениваться информацией между собой.

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

При этом для записи и извлечения данных используются символы стрелки и тире:

package main

import "fmt"

func main() {
some_channel := make(chan string) // создаем канал

go func() { // создаем самовызывающуюся функцию, которая записывает сообщение в канал
fmt.Printf("Ждем 2 секунды...\n")
        time.Sleep(2 * time.Second)
        some_channel <- "Некоторое сообщение"
    }()

messages := <-some_channel // здесь выполнение кода приостанавливается до тех пор, пока горутина выше не запишет данные в канал

fmt.Println(messages)
}

Общий консольный вывод в этом примере будет следующий:

Ждем 2 секунды...
Некоторое сообщение

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

package main

import (
    "fmt"
    "time"
)

func main() {
    some_channel := make(chan string, 2) // создаем канал

    go func() {
        fmt.Printf("Ждем 2 секунды...\n")
        time.Sleep(2 * time.Second)
        some_channel <- "Некоторое сообщение"
        fmt.Printf("Ждем еще 2 секунды...\n")
        time.Sleep(2 * time.Second)
        some_channel <- "Еще одно сообщение"
    }()

    message1 := <-some_channel
    fmt.Println(message1)

    message2 := <-some_channel
    fmt.Println(message2) 
}

Общий консольный вывод будет такой:

Ждем 2 секунды...
Ждем еще 2 секунды...
Некоторое сообщение
Еще одно сообщение

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

Направления каналов

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

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

Одной будет разрешено только записывать данные в канал, а другой — только читать из него:

package main

import "fmt"

// аргумент канала в этой функции может только записывать значения
// попытка чтения вызовет ошибку

func write(actions chan<- string, name string) { // стрелка указана после «chan»
    actions <- name // передаем в канал название действия
}

// аргумент канала в этой функции может только читать значения
// попытка записи вызовет ошибку

func read(actions <-chan string, execution *string) { // стрелка указана перед «chan»
    *execution = <-actions
}

func main() {
    actions := make(chan string, 3) // буферизированный канал на 3 значения
    var execution string

    write(actions, "Прочитать книгу")
    write(actions, "Убраться в квартире")
    write(actions, "Приготовить еду")

    read(actions, &execution)
    fmt.Printf("Сейчас выполняется действие: %s\n", execution)

    read(actions, &execution)
    fmt.Printf("Сейчас выполняется действие: %s\n", execution)

    read(actions, &execution)
    fmt.Printf("Сейчас выполняется действие: %s\n", execution)
}

Общий вывод в терминал консоли будет следующим:

Сейчас выполняется действие: Прочитать книгу
Сейчас выполняется действие: Убраться в квартире
Сейчас выполняется действие: Приготовить еду

Неблокирующее чтение из канала

За счет использования конструкции select есть возможность избежать блокировки выполнения кода при чтении из канала:

package main

import (
    "fmt"
    "time"
)

func main() {
    channel := make(chan string)

    // самовызывающаяся функция горутины

    go func() {
        channel <- "Было получено сообщение\n"
    }()

    // на первом селекте сообщения в канале еще не будет, поэтому вызовется секция «default»

    select {
    case message := <-channel:
        fmt.Printf(message)
    default:
        fmt.Printf("Сообщений нет\n")
    }

    time.Sleep(2 * time.Second) // ждем 2 секунды

    // на втором селекте сообщение уже окажется в канале, поэтому поэтому вызовется секция с чтением из канала

    select {
    case message := <-channel:
        fmt.Printf(message)
    default:
        fmt.Printf("Сообщений нет\n")
    }
}

Модернизированное приложение

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

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

В коде ниже мы создадим отдельные структуры под шахты и «Центр обеспечения»:

package main

import (
    "fmt"
    "time"
)

type Dungeon struct {
    name string // название
    resources []string // ресурсы
    progress int // степень выработки
    finished chan bool // канал для сообщения о завершении копки
}

type SupplyCenter struct {
    dungeons []*Dungeon // массив с указателями на экземпляры всех существующих шахт
}

func dig(dung *Dungeon) {
    if (*dung).progress < len((*dung).resources) {
        time.Sleep(1 * time.Second)
        fmt.Printf("В шахте «%s» найдено: «%s»\n", (*dung).name, (*dung).resources[(*dung).progress])
        (*dung).progress++
        dig(dung)
    } else {
        (*dung).finished <- true // помещаем в канал сообщение о завершении копки
    }
}

func main() {
    supply := SupplyCenter{[]*Dungeon{
        {"Зарубки", []string{"камень", "железо", "золото", "камень", "золото"}, 0, make(chan bool)},
        {"Каменки", []string{"камень", "камень", "железо", "камень"}, 0, make(chan bool)},
        {"Железняки", []string{"железо", "золото", "камень", "железо", "камень", "золото"}, 0, make(chan bool)},
    }}

    // стартуем многопоточный процесс копки в созданных шахтах
    for _, dung := range supply.dungeons {
        go dig(dung)
    }

    // ожидаем сообщения о завершении копки от всех шахт, тем самым приостанавливая выполнение программы в этой точке кода
    for _, dung := range supply.dungeons {
        <-(*dung).finished
    }

    // после получения сообщений от всех шахт программа будет завершена
}

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

В шахте «Каменки» найдено: «камень»
В шахте «Железняки» найдено: «железо»
В шахте «Зарубки» найдено: «камень»
В шахте «Железняки» найдено: «золото»
В шахте «Зарубки» найдено: «железо»
В шахте «Каменки» найдено: «камень»
В шахте «Железняки» найдено: «камень»
В шахте «Каменки» найдено: «железо»
В шахте «Зарубки» найдено: «золото»
В шахте «Каменки» найдено: «камень»
В шахте «Зарубки» найдено: «камень»
В шахте «Железняки» найдено: «железо»
В шахте «Железняки» найдено: «камень»
В шахте «Зарубки» найдено: «золото»
В шахте «Железняки» найдено: «золото»

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

Заключение

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

Использование «Горутин» и «Каналов» можно использовать в сочетании с различными языковыми конструкциями Go. В нашем случае мы реализовали блокировку выполнения программы внутри цикла for, тем самым обеспечив ожидание выполнения кода внутри горутин.

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

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

  • Отдавайте предпочтение каналам, а не обычным переменным (или указателям на них) для связи и синхронизации между горутинами.
  • Старайтесь подбирать наиболее подходящие языковые конструкции, «обрамляющие» примитивы многопоточности.
  • Не плодите лишних блокировок программы и обеспечивайте нормальное планирование при использовании процедур в Go.
  • Не создавайте многопоточные приложения «вслепую», а используйте соответствующие инструменты профилирования (например, в Go доступен специальный пакет «net/http/pprof» для оптимизации HTTP-приложений) для выявления узких мест и оптимизации производительности.
Хотите внести свой вклад?
Участвуйте в нашей контент-программе за
вознаграждение или запросите нужную вам инструкцию
img-server
24 ноября 2023 г.
1126
14 минут чтения
Средний рейтинг статьи: 5
Пока нет комментариев