Параллелизм является ключевым аспектом современной разработки программного обеспечения. Он позволяет программам выполнять несколько задач одновременно, что может значительно улучшить производительность и эффективность, особенно в системах с несколькими процессорами или ядрами.
В Go есть встроенная поддержка параллелизма с помощью горутин и каналов. Горутины — это легковесные потоки, управляемые средой выполнения Go. Они меньше и эффективнее, чем традиционные потоки операционной системы, и Go может эффективно управлять тысячами или даже миллионами горутин в одной программе.
Каналы в Go предоставляют мощный механизм для безопасного и эффективного обмена данными между горутинами. Они могут быть использованы для синхронизации горутин, для передачи данных между ними или для сигнализации о событиях.
В этом модуле мы подробно рассмотрим горутины, каналы и другие аспекты параллелизма в Go, а также узнаем, как использовать эти инструменты для создания эффективных и надежных параллельных программ.
Горутины в Go
Горутины — это функции или методы, которые выполняются параллельно с другими функциями или методами. Горутины можно рассматривать как легковесные потоки. Стоимость создания горутины в Go намного ниже, чем стоимость создания потока в других языках программирования, включая системные потоки (threads).
Создание горутины в 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 function
Code language: Go (go)
Обратите внимание, что порядок вывода может отличаться, поскольку горутины выполняются параллельно.
Каналы в Go
Каналы в Go являются мощным инструментом для обеспечения связи между горутинами. Каналы позволяют двум и более горутинам обмениваться данными и синхронизировать их выполнение.
Каналы создаются с помощью ключевого слова 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
message2
Code 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
` — это взаимоисключающий блокировщик, который предотвращает одновременный доступ к общему ресурсу. Это особенно полезно в ситуациях, когда несколько горутин имеют доступ к общему ресурсу, и мы хотим предотвратить одновременное чтение и запись в этот ресурс.
В 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 горутин, которые будут выполнять запись в состояние. Каждая операция чтения или записи выполняется через отправку запроса на соответствующий канал, который затем обрабатывается нашей горутиной, управляющей состоянием.
Этот подход обеспечивает безопасность доступа к состоянию, поскольку в любой момент времени к нему имеет доступ только одна горутина. Это исключает возможность гонок данных, которые могут возникнуть при использовании множества горутин, имеющих доступ к одному и тому же состоянию.