Базовые элементы каналов в Golang

GolangBeginner
Практиковаться сейчас

Введение

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

В отличие от других языков, которые достигают конкурентности через разделяемую память, Go реализует конкурентность, используя каналы (channels) для обмена данными между различными горутинами (goroutines).

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

Каналы и горутины вместе составляют примитивы конкурентности в Go. В этом разделе мы познакомимся с каналами — этой новой структурой данных.

Ключевые моменты:

  • Типы каналов
  • Создание каналов
  • Операции над каналами
  • Блокировка каналов
  • Однонаправленные каналы

Обзор каналов

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

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

В Go канал подобен конвейерной ленте или очереди, которая следует правилу "Первым пришел — первым ушел" (First-In-First-Out, FIFO).

Типы каналов и объявление

Канал является ссылочным типом (reference type), и формат его объявления выглядит следующим образом:

var channelName chan elementType

Каждый канал имеет определенный тип и может передавать данные только одного и того же типа. Рассмотрим несколько примеров:

Создайте файл с именем channel.go в директории ~/project:

cd ~/project
touch channel.go

Например, объявим канал типа int:

var ch chan int

Приведенный выше код объявляет канал для целых чисел (integer channel) с именем ch.

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

В файле channel.go напишите следующий код:

package main

import "fmt"

func main() {
    var ch1 chan int   // Объявить целочисленный канал
    var ch2 chan bool  // Объявить булев канал
    var ch3 chan []int // Объявить канал, передающий срезы целых чисел
    fmt.Println(ch1, ch2, ch3)
}

Приведенный выше код просто объявляет три канала разных типов.

Запустите программу с помощью следующей команды:

go run channel.go

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

<nil> <nil> <nil>

Инициализация канала

Подобно картам (maps), каналы необходимо инициализировать после объявления, прежде чем их можно будет использовать.

Синтаксис инициализации канала следующий:

make(chan elementType)

В файле channel.go напишите следующий код:

package main

import "fmt"

func main() {
    // Канал, хранящий целочисленные данные
    ch1 := make(chan int)

    // Канал, хранящий булевы данные
    ch2 := make(chan bool)

    // Канал, хранящий данные типа []int
    ch3 := make(chan []int)

    fmt.Println(ch1, ch2, ch3)
}

Запустите программу с помощью следующей команды:

go run channel.go

Вывод программы показан ниже:

0xc0000b4000 0xc0000b4040 0xc0000b4080

В приведенном выше коде определены и инициализированы три различных типа каналов.

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

Прямой вывод канала показывает только адрес памяти самого канала. Это связано с тем, что, подобно указателю (pointer), канал является лишь ссылкой на адрес в памяти.

Итак, как мы можем получить доступ к значениям в канале и оперировать ими?

Операции с каналами

Операции с каналами сосредоточены вокруг отправки и получения данных, что осуществляется с помощью оператора <-. Чтобы увидеть каналы в действии, нам необходимо использовать их с горутинами (goroutines) — легковесными потоками Go для параллелизма (concurrency).

Канал связывает горутину-отправителя и горутину-получателя. Если одна из них не готова, другая будет заблокирована. Рассмотрим полный пример.

В файле channel.go напишите следующий код:

package main

import (
    "fmt"
    "time"
)

func main() {
    // Создать небуферизованный канал
    ch := make(chan int)

    // Запустить новую горутину, используя анонимную функцию
    go func() {
        fmt.Println("Goroutine начинает отправку...")
        // Отправить значение 10 в канал
        ch <- 10
        fmt.Println("Goroutine завершила отправку.")
    }()

    fmt.Println("Main ожидает данные...")
    // Получить значение из канала
    value := <-ch
    fmt.Printf("Main получил: %d\n", value)

    // Дать горутине время на печать ее финального сообщения
    time.Sleep(time.Second)
}

Запустите программу с помощью следующей команды:

go run channel.go

Вывод программы будет примерно таким (порядок может незначительно варьироваться):

Main is waiting for data...
Goroutine starts sending...
Main received: 10
Goroutine finished sending.

В этом примере:

  1. ch := make(chan int) создает небуферизованный канал. Небуферизованные каналы требуют, чтобы отправитель и получатель были готовы одновременно для осуществления связи. Это называется синхронизацией.
  2. go func() { ... }() запускает новую горутину. Ключевое слово go выполняет функцию параллельно с функцией main.
  3. Внутри горутины ch <- 10 отправляет значение 10 в канал. Эта операция будет заблокирована до тех пор, пока горутина main не будет готова принять данные.
  4. В функции main, value := <-ch принимает значение. Эта операция блокируется до тех пор, пока горутина не отправит значение.
  5. Как только обе стороны готовы, значение передается, и обе горутины разблокируются и продолжают выполнение. Мы добавили time.Sleep, чтобы гарантировать, что программа не завершится до того, как горутина успеет напечатать свое финальное сообщение.

Закрытие канала

Когда больше никаких значений не будет отправляться по каналу, отправитель может закрыть его с помощью функции close(). Распространенный способ прочитать все значения из канала до его закрытия — использовать цикл for range.

Изменим channel.go:

package main

import "fmt"

func main() {
    // Буферизованный канал для хранения нескольких значений
    ch := make(chan int, 3)

    // Запустить горутину для отправки данных
    go func() {
        fmt.Println("Goroutine отправляет 10, 20, 30")
        ch <- 10
        ch <- 20
        ch <- 30
        // Закрыть канал после отправки всех значений
        close(ch)
        fmt.Println("Goroutine закрыла канал.")
    }()

    fmt.Println("Main принимает данные...")
    // Использовать цикл for-range для приема значений до тех пор, пока канал не будет закрыт
    for value := range ch {
        fmt.Printf("Main получил: %d\n", value)
    }
    fmt.Println("Main завершил прием.")
}

Запустите программу с помощью следующей команды:

go run channel.go

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

Main is receiving data...
Goroutine sending 10, 20, 30
Main received: 10
Main received: 20
Main received: 30
Goroutine closed the channel.
Main finished receiving.

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

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

  • Отправка данных в закрытый канал вызовет ошибку времени выполнения (panic).
  • Получение данных из закрытого, пустого канала немедленно вернет нулевое значение (zero value) для типа канала.
  • Повторное закрытие уже закрытого канала вызовет panic.
  • В общем случае, закрывать канал должен только отправитель, но никогда не получатель.

Блокировка канала

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

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

Каналы, созданные с помощью make(chan T), являются небуферизованными (unbuffered).

ch1 := make(chan int) // Небуферизованный канал

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

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

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

ch2 := make(chan int, 5) // Буферизованный канал емкостью 5

С буферизованным каналом:

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

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

Пример взаимоблокировки (Deadlock)

Что произойдет, если мы превысим емкость канала? Программа завершится взаимоблокировкой (deadlock). Взаимоблокировка возникает, когда все горутины в программе заблокированы в ожидании чего-то, что никогда не произойдет.

Создайте файл с именем channel1.go:

cd ~/project
touch channel1.go

Напишите следующий код, который пытается отправить два значения в канал емкостью один, и все это внутри горутины main:

package main

import "fmt"

func main() {
    ch := make(chan int, 1)
    ch <- 10 // Это выполняется успешно
    fmt.Println("Sent 10 to channel")
    ch <- 20 // Это блокируется навсегда, потому что канал полон
    fmt.Println("succeed")
}

Запустите программу с помощью следующей команды:

go run channel1.go

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

Sent 10 to channel
fatal error: all goroutines are asleep - deadlock!

Программа завершается с ошибкой взаимоблокировки (panic). Горутина main отправляет 10, что заполняет буфер. Затем она пытается отправить 20, но блокируется, потому что буфер полон. Поскольку никакая другая горутина никогда не будет принимать данные из канала, горутина main будет заблокирована навсегда, что приведет к обнаружению взаимоблокировки средой выполнения Go (Go runtime).

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

Однонаправленные каналы

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

Мы можем явно объявить канал с ограниченным использованием перед его инициализацией.

Канал только для записи (Write-only Channel)

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

package main

import "fmt"

func writeOnly(ch chan<- int) {
    ch <- 10
    fmt.Println("Data written to channel")
}

func main() {
    ch := make(chan int, 1)
    writeOnly(ch)
    fmt.Println("Data received:", <-ch)
}

Запустите программу с помощью следующей команды:

go run channel.go

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

Data written to channel
Data received: 10

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

Канал только для чтения (Read-only Channel)

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

package main

import "fmt"

func readOnly(ch <-chan int) {
    fmt.Println("Data received from channel:", <-ch)
}

func main() {
    ch := make(chan int, 1)
    ch <- 20
    readOnly(ch)
}

Запустите программу с помощью следующей команды:

go run channel.go

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

Data received from channel: 20

В этом примере функция readOnly ограничивает канал только чтением.

Комбинирование каналов только для записи и только для чтения

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

package main

import "fmt"

func writeOnly(ch chan<- int) {
    ch <- 30
    fmt.Println("Data written to channel")
}

func readOnly(ch <-chan int) {
    fmt.Println("Data received from channel:", <-ch)
}

func main() {
    ch := make(chan int, 1)
    writeOnly(ch)
    readOnly(ch)
}

Запустите программу с помощью следующей команды:

go run channel.go

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

Data written to channel
Data received from channel: 30

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

Резюме

В этой лабораторной работе мы изучили основы каналов, которые включают:

  • Типы каналов и способы их объявления
  • Методы инициализации каналов
  • Операции над каналами
  • Концепция блокировки каналов
  • Объявление однонаправленных каналов