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

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

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

Горутины

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

package main

import "fmt"

func f(n int) {
    for i := 0; i < 10; i++ {
        fmt.Println(n, ":", i)
    }
}

func main() {
    go f(0)
    var input string
    fmt.Scanln(&input)
}

Эта программа состоит из двух горутин. Функция main, сама по себе, является горутиной. Вторая горутина создаётся, когда мы вызываем go f(0). Обычно, при вызове функции, программа выполнит все конструкции внутри вызываемой функции, а только потом перейдет к следующей после вызова, строке. С горутиной программа немедленно прейдет к следующей строке, не дожидаясь, пока вызываемая функция завершится. Вот почему здесь присутствует вызов Scanln, без него программа завершится еще перед тем, как ей удастся вывести числа.

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

func main() {
    for i := 0; i < 10; i++ {
        go f(i)
    }
    var input string
    fmt.Scanln(&input)
}

При запуске вы наверное заметили, что все горутины выполняются последовательно, а не одновременно, как вы того ожидали. Давайте добавим небольшую задержку функции с помощью функции time.Sleep и rand.Intn:

package main

import (
    "fmt"
    "time"
    "math/rand"
)

func f(n int) {
    for i := 0; i < 10; i++ {
        fmt.Println(n, ":", i)
        amt := time.Duration(rand.Intn(250))
        time.Sleep(time.Millisecond * amt)
    }
}
func main() {
    for i := 0; i < 10; i++ {
        go f(i)
    }
    var input string
    fmt.Scanln(&input)
}

f выводит числа от 0 до 10, ожидая от 0 до 250 мс после каждой операции вывода. Теперь горутины должны выполняться одновременно.

Каналы

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

package main

import (
    "fmt"
    "time"
)

func pinger(c chan string) {
    for i := 0; ; i++ {
        c <- "ping"
    }
}
func printer(c chan string) {
    for {
        msg := <- c
        fmt.Println(msg)
        time.Sleep(time.Second * 1)
    }
}
func main() {
    var c chan string = make(chan string)

    go pinger(c)
    go printer(c)

    var input string
    fmt.Scanln(&input)
}

Программа будет постоянно выводить «ping» (нажмите enter, чтобы её остановить). Тип канала представлен ключевым словом chan, за которым следует тип, который будет передаваться по каналу (в данном случае мы передаем строки). Оператор <- (стрелка влево) используется для отправки и получения сообщений по каналу. Конструкция c <- "ping" означает отправку "ping", а msg := <- c — его получение и сохранение в переменную msg. Строка с fmt может быть записана другим способом: fmt.Println(<-c), тогда можно было бы удалить предыдущую строку.

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

func ponger(c chan string) {
    for i := 0; ; i++ {
        c <- "pong"
    }
}

и изменим функцию main:

func main() {
    var c chan string = make(chan string)

    go pinger(c)
    go ponger(c)
    go printer(c)

    var input string
    fmt.Scanln(&input)
}

Теперь программа будет выводить на экран то ping, то pong по очереди.

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

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

func pinger(c chan<- string)

и канал c будет только отправлять сообщение. Попытка получить сообщение из канала c вызовет ошибку компилирования. Также мы можем изменить функцию printer:

func printer(c <-chan string)

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

Оператор Select

В языке Go есть специальный оператор select который работает как switch, но для каналов:

func main() {
    c1 := make(chan string)
    c2 := make(chan string)

    go func() {
        for {
            c1 <- "from 1"
            time.Sleep(time.Second * 2)
        }
    }()
    go func() {
        for {
            c2 <- "from 2"
            time.Sleep(time.Second * 3)
        }
    }()
    go func() {
        for {
            select {
            case msg1 := <- c1:
                fmt.Println(msg1)
            case msg2 := <- c2:
                fmt.Println(msg2)
            }
        }
    }()

    var input string
    fmt.Scanln(&input)
}

Эта программа выводит «from 1» каждые 2 секунды и «from 2» каждые 3 секунды. Оператор select выбирает первый готовый канал, и получает сообщение из него, или же передает сообщение через него. Когда готовы несколько каналов, получение сообщения происходит из случайно выбранного готового канала. Если же ни один из каналов не готов, оператор блокирует ход программы до тех пор, пока какой-либо из каналов будет готов к отправке или получению.

Обычно select используется для таймеров:

select {
case msg1 := <- c1:
    fmt.Println("Message 1", msg1)
case msg2 := <- c2:
    fmt.Println("Message 2", msg2)
case <- time.After(time.Second):
    fmt.Println("timeout")
}

time After создаёт канал, по которому посылаем метки времени с заданным интервалом. В данном случае мы не заинтересованы в значениях временных меток, поэтому мы не сохраняем его в переменные. Также мы можем задать команды, которые выполняются по умолчанию, используя конструкцию default:

select {
case msg1 := <- c1:
    fmt.Println("Message 1", msg1)
case msg2 := <- c2:
    fmt.Println("Message 2", msg2)
case <- time.After(time.Second):
    fmt.Println("timeout")
default:
    fmt.Println("nothing ready")
}

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

Буферизированный канал

При инициализации канала можно использовать второй параметр:

c := make(chan int, 1)

и мы получим буферизированный канал с ёмкостью 1. Обычно каналы работают синхронно - каждая из сторон ждёт, когда другая сможет получить или передать сообщение. Но буферизованный канал работает асинхронно — получение или отправка сообщения не заставляют стороны останавливаться. Но канал теряет пропускную способность, когда он занят, в данном случае, если мы отправим в канал 1 сообщение, то мы не сможем отправить туда ещё одно до тех пор, пока первое не будет получено.

Задачи

  • Как задать направление канала?

  • Напишите собственную функцию Sleep, используя time.After

  • Что такое буферизированный канал? Как создать такой канал с ёмкостью в 20 сообщений?

Fork me on GitHub