Быстрый старт Golang. Часть 6: Параллелизм

Параллелизм в Go GoLang
Параллелизм позволяет программам выполнять несколько задач одновременно.

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

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

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

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

Горутины в Go

Горутины — это функции или методы, которые выполняются параллельно с другими функциями или методами. Горутины можно рассматривать как легковесные потоки. Стоимость создания горутины в Go намного ниже, чем стоимость создания потока в других языках программирования, включая системные потоки (threads).

Golang - Параллелизм и Гоурутины

Создание горутины в Go очень просто — достаточно добавить ключевое слово go перед вызовом функции:

go functionName()Code language: Go (go)

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

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

Вот простой пример использования горутин в Go:

go
package main

import (
    "fmt"
    "time"
)

func hello() {
    fmt.Println("Hello from goroutine")
}

func main() {
    go hello()
    time.Sleep(1 * time.Second)
    fmt.Println("Hello from main function")
}
Code language: Go (go)

В этом примере функция hello запускается в новой горутине с помощью ключевого слова go. Затем мы используем функцию time.Sleep для приостановки выполнения основной горутины на одну секунду. Это дает достаточно времени для того, чтобы горутина hello успела выполниться, прежде чем основная горутина завершится. Если бы мы не использовали time.Sleep, программа могла бы завершиться прежде, чем горутина hello успела бы выполниться, и мы бы не увидели вывод от этой горутины.

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

Hello from goroutine
Hello from main functionCode language: Go (go)

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

Каналы в Go

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

Golang - Параллелизм и каналы

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

ch := make(chan int)Code language: Go (go)

В этом примере ch является каналом, который может передавать целые числа (int). Тип данных, который канал может передавать, указывается в угловых скобках после ключевого слова chan.

Каналы используются в сочетании с ключевыми словами go и select для создания эффективных многопоточных программ. Давайте рассмотрим пример использования каналов.

package main

import "fmt"

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

    go func() { messages <- "ping" }()

    msg := <-messages
    fmt.Println(msg)
}
Code language: Go (go)

В этом примере мы создаем канал строк messages. Затем мы запускаем новую горутину, которая отправляет строку "ping" в канал. В основной горутине мы получаем это сообщение из канала и выводим его.

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

ping

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

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

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

ch := make(chan int, 2)Code language: Go (go)

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

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

package main

import "fmt"

func main() {
    ch := make(chan string, 2)
    ch <- "message1"
    ch <- "message2"
    fmt.Println(<-ch)
    fmt.Println(<-ch)
}
Code language: Go (go)

В этом примере мы создаем буферизованный канал ch с буфером размером 2. Затем мы отправляем два сообщения в канал, не дожидаясь их получения. Наконец, мы получаем и выводим эти сообщения.

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

message1
message2Code language: Go (go)

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

Операции с каналами: отправка, получение, закрытие

В Go есть три основные операции, которые можно выполнять с каналами: отправка, получение и закрытие.

Отправка

Отправка значения в канал выполняется с помощью оператора <-. Например, ch <- v отправляет значение v в канал ch.

Получение

Получение значения из канала также выполняется с помощью оператора <-, но в этом случае он используется вместе с каналом, как в v := <-ch, который получает значение из ch и присваивает его v.

Закрытие

Каналы можно закрыть с помощью функции close. Закрытие канала указывает, что больше не будет отправлено значений в этот канал. Попытка отправить значение в закрытый канал приведет к панике.

Давайте рассмотрим пример использования этих операций.

package main

import (
    "fmt"
    "time"
)

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

    go func() {
        time.Sleep(time.Second)
        ch <- "Hello, World!"
        close(ch)
    }()

    msg, ok := <-ch
    fmt.Println(msg, ok) // Выводит: Hello, World! true

    msg, ok = <-ch
    fmt.Println(msg, ok) // Выводит: false, потому что канал был закрыт
}
Code language: Go (go)

В этом примере мы создаем канал ch и запускаем горутину, которая спит одну секунду, затем отправляет строку "Hello, World!" в канал и закрывает его.

В главной горутине мы получаем значение из канала и проверяем, открыт ли он. Поскольку горутина отправила значение в канал и закрыла его, мы получаем "Hello, World!" и true.

Затем мы пытаемся снова получить значение из канала. Но поскольку канал был закрыт, мы получаем нулевое значение для типа канала (в этом случае пустую строку) и false.

Range и каналы

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

Давайте рассмотрим пример использования range с каналом.

package main

import (
    "fmt"
    "time"
)

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

    go func() {
        for i := 0; i < 5; i++ {
            time.Sleep(time.Second)
            ch <- i
        }
        close(ch)
    }()

    for i := range ch {
        fmt.Println(i) // Выводит числа от 0 до 4
    }
}
Code language: Go (go)

В этом примере мы создаем канал ch и запускаем горутину, которая в цикле отправляет числа от 0 до 4 в канал, после чего закрывает его.

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

Select

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

Давайте рассмотрим пример использования select.

package main

import (
    "fmt"
    "time"
)

func main() {
    ch1 := make(chan string)
    ch2 := make(chan string)

    go func() {
        time.Sleep(time.Second)
        ch1 <- "one"
    }()

    go func() {
        time.Sleep(2 * time.Second)
        ch2 <- "two"
    }()

    for i := 0; i < 2; i++ {
        select {
        case msg1 := <-ch1:
            fmt.Println("received", msg1)
        case msg2 := <-ch2:
            fmt.Println("received", msg2)
        }
    }
}
Code language: Go (go)

В этом примере мы создаем два канала ch1 и ch2 и запускаем две горутины. Первая горутина спит одну секунду, затем отправляет строку "one" в ch1. Вторая горутина спит две секунды, затем отправляет строку "two" в ch2.

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

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

Default case in select

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

Давайте рассмотрим пример использования default в select.

package main

import (
    "fmt"
    "time"
)

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

    go func() {
        time.Sleep(time.Second)
        ch <- "one"
    }()

    select {
    case msg := <-ch:
        fmt.Println("received", msg)
    default:
        fmt.Println("no message received")
    }

    time.Sleep(time.Second * 2)

    select {
    case msg := <-ch:
        fmt.Println("received", msg)
    default:
        fmt.Println("no message received")
    }
}
Code language: Go (go)

В этом примере мы создаем канал ch и запускаем горутину, которая спит одну секунду, затем отправляет строку "one" в ch.

В главной горутине мы используем select для попытки получить значение из ch. Однако, поскольку горутина спит одну секунду перед отправкой значения, ch еще не готов к получению, и select выполняет default, выводя "no message received".

Затем мы спим две секунды, чтобы дать горутине время отправить значение в ch, и снова используем select. На этот раз ch готов к получению, и select выводит полученное значение.

Использование range и close с каналами

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

Каналы можно закрыть с помощью функции close. Закрытие канала указывает, что больше не будет отправлено значений в этот канал. Попытка отправить значение в закрытый канал приведет к панике.

Давайте рассмотрим пример использования range и close с каналами.

package main

import (
    "fmt"
    "time"
)

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

    go func() {
        for i := 0; i < 5; i++ {
            time.Sleep(time.Second)
            ch <- i
        }
        close(ch)
    }()

    for i := range ch {
        fmt.Println(i) // Выводит числа от 0 до 4
    }
}
Code language: Go (go)

В этом примере мы создаем канал ch и запускаем горутину, которая в цикле отправляет числа от 0 до 4 в канал, после чего закрывает его.

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

Это показывает, как range и close могут быть использованы вместе для эффективной работы с каналами в Go.

Использование select с каналами в Go

В Go select позволяет одновременно ожидать несколько операций ввода-вывода. Он похож на switch, но для каналов.

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

Давайте рассмотрим пример использования select с каналами в Go:

package main

import (
    "fmt"
    "time"
)

func server1(ch chan string) {
    time.Sleep(6 * time.Second)
    ch <- "from server1"
}

func server2(ch chan string) {
    time.Sleep(3 * time.Second)
    ch <- "from server2"
}

func main() {
    output1 := make(chan string)
    output2 := make(chan string)
    go server1(output1)
    go server2(output2)
    select {
    case s1 := <-output1:
        fmt.Println(s1)
    case s2 := <-output2:
        fmt.Println(s2)
    }
}Code language: Go (go)

В приведенном выше коде у нас есть два сервера, `server1` и `server2`. `server1` засыпает на 6 секунд, а `server2` — на 3 секунды. В функции `main` мы создаем два канала, `output1` и `output2`, и запускаем `server1` и `server2` в отдельных горутинах. Затем мы используем `select`, чтобы ждать данных от обоих серверов.

Поскольку `server2` засыпает на меньшее количество времени, он первым отправляет данные на канал. Поэтому `select` получает данные от `server2`.

Если бы `server1` был быстрее, `select` получил бы данные от `server1`.

Это показывает, что `select` выбирает первый готовый канал.

Использование default с select в Go

Как уже упоминалось, `select` блокирует, пока одна из его ветвей не будет готова. Если вы не хотите блокировать, вы можете использовать `default`.

Если ни одна из ветвей `select` не готова, и есть ветвь `default`, то будет выполнена ветвь `default`. Если `default` отсутствует, `select` будет блокировать, пока не будет готова хотя бы одна ветвь.

Давайте рассмотрим пример использования `default` с `select` в Go:

package main

import (
    "fmt"
    "time"
)

func process(ch chan string) {
    time.Sleep(10500 * time.Millisecond)
    ch <- "process successful"
}

func main() {
    ch := make(chan string)
    go process(ch)
    for {
        time.Sleep(1000 * time.Millisecond)
        select {
        case v := <-ch:
            fmt.Println("received value: ", v)
            return
        default:
            fmt.Println("no value received")
        }
    }
}
Code language: Go (go)

В приведенном выше коде у нас есть функция `process`, которая засыпает на 10500 миллисекунд (10,5 секунды), а затем отправляет строку `"process successful"` на канал.

В функции `main` мы создаем канал `ch` и запускаем `process` в отдельной горутине. Затем мы входим в бесконечный цикл, где каждую секунду мы пытаемся прочитать из канала `ch` с помощью `select`.

Если данные доступны на канале `ch`, мы читаем их и выводим, а затем выходим из программы. Если данные не доступны, выполняется ветвь `default`, и мы выводим `"no value received"`.

Таким образом, `select` с `default` позволяет нам выполнять операции, не блокируя на чтение из канала.

Default Selection в Go

Default Selection в Go позволяет выполнить действие, когда ни один из каналов в операторе `select` не готов для операций чтения или записи. Это достигается с помощью ключевого слова `default`.

Вот базовый синтаксис использования `default` в `select`:

select {
    case <-ch1:
    // блок кода для ch1
    case ch2 <- value:
    // блок кода для ch2
    default:
    // блок кода, который будет выполнен, если ни один из каналов не готов
}
Code language: Go (go)

Когда оператор `select` достигает `default`, он выполняет блок кода `default`, если ни один из каналов не готов для чтения или записи. Если каналы готовы, `default` будет проигнорирован.

Вот пример использования `default` в `select` в Go:

package main

import (
    "fmt"
    "time"
)

func main() {
    tick := time.Tick(100 * time.Millisecond)
    boom := time.After(500 * time.Millisecond)
    for {
        select {
            case <-tick:
                fmt.Println("tick.")
            case <-boom:
                fmt.Println("BOOM!")
                return
            default:
                fmt.Println(" .")
                time.Sleep(50 * time.Millisecond)
        }
    }
}
Code language: Go (go)

В этом примере `tick` и `boom` являются каналами, которые получают значения через определенные промежутки времени. `select` используется для ожидания данных из этих каналов. Если нет данных, то выполняется блок `default`, который просто печатает точку и затем делает паузу.

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

Mutex в Go

В Go, `sync.Mutex` предоставляет примитивы для синхронизации доступа к общим ресурсам между различными горутинами. `Mutex` — это взаимоисключающий блокировщик, который предотвращает одновременный доступ к общему ресурсу. Это особенно полезно в ситуациях, когда несколько горутин имеют доступ к общему ресурсу, и мы хотим предотвратить одновременное чтение и запись в этот ресурс.

Golang - Параллелизм и мьютексы

В Go `Mutex` предоставляется пакетом `sync`, и у него есть два основных метода: `Lock()` и `Unlock()`. `Lock()` используется для блокировки `Mutex`, и `Unlock()` используется для разблокировки `Mutex`.

Вот простой пример использования `Mutex` в Go:

package main

import (
    "fmt"
    "sync"
)

var x = 0

func increment(wg *sync.WaitGroup, m *sync.Mutex) {
    m.Lock()
    x = x + 1
    m.Unlock()
    wg.Done()
}

func main() {
    var w sync.WaitGroup
    var m sync.Mutex

    for i := 0; i < 1000; i++ {
        w.Add(1)
        go increment(&w, &m)
    }
    w.Wait()
    fmt.Println("final value of x", x)
}
Code language: Go (go)

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

RWMutex в Go

В дополнение к `Mutex`, Go также предоставляет `RWMutex` (Read/Write Mutex), который позволяет множественным горутинам одновременно читать данные, но только одной горутине записывать данные. Это может быть полезно, когда у вас есть данные, которые часто читаются, но редко обновляются.

`RWMutex` имеет методы `RLock()` и `RUnlock()` для блокировки и разблокировки чтения, а также `Lock()` и `Unlock()` для блокировки и разблокировки записи.

Вот пример использования `RWMutex` в Go:

package main

import (
    "fmt"
    "sync"
    "time"
)

var m = make(map[int]int)
var mutex = sync.RWMutex{}

func write() {
    mutex.Lock()
    m[1] = 1
    time.Sleep(1*time.Second)
    mutex.Unlock()
}

func read() {
    mutex.RLock()
    _ = m[1]
    mutex.RUnlock()
}

func main() {
    go write()
    go read()
    time.Sleep(2*time.Second)
    fmt.Println("Program exited")
}
Code language: Go (go)

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

Stateful Goroutines

В Go, вы можете использовать горутины и каналы для создания системы, где состояние полностью инкапсулировано внутри одной горутины. Этот подход называется «stateful goroutines».

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

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

Давайте рассмотрим пример:

package main
import (
    "fmt"
    "math/rand"
    "time"
)
type readOp struct {
    key  int
    resp chan int
}
type writeOp struct {
    key  int
    val  int
    resp chan bool
}
func main() {
    var readOps uint64
    var writeOps uint64
    reads := make(chan readOp)
    writes := make(chan writeOp)
    go func() {
        var state = make(map[int]int)
        for {
            select {
            case read := <-reads:
                read.resp <- state[read.key]
            case write := <-writes:
                state[write.key] = write.val
                write.resp <- true
            }
        }
    }()
    for r := 0; r < 100; r++ {
        go func() {
            for {
                read := readOp{
                    key:  rand.Intn(5),
                    resp: make(chan int)}
                reads <- read
                <-read.resp
                atomic.AddUint64(&readOps, 1)
                time.Sleep(time.Millisecond)
            }
        }()
    }
    for w := 0; w < 10; w++ {
        go func() {
            for {
                write := writeOp{
                    key:  rand.Intn(5),
                    val:  rand.Intn(100),
                    resp: make(chan bool)}
                writes <- write
                <-write.resp
                atomic.AddUint64(&writeOps, 1)
                time.Sleep(time.Millisecond)
            }
        }()
    }
    time.Sleep(time.Second)
    readOpsFinal := atomic.LoadUint64(&readOps)
    fmt.Println("readOps:", readOpsFinal)
    writeOpsFinal := atomic.LoadUint64(&writeOps)
    fmt.Println("writeOps:", writeOpsFinal)
}
Code language: Go (go)

В этом примере мы создаем систему, где состояние инкапсулировано внутри одной горутины (`go func()`). Эта горутина управляет доступом к состоянию через каналы `reads` и `writes`.

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

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

Оценить
Exception.Expert