[Перевод] Как устроены каналы в Go
Перевод познавательной статьи «Golang: channels implementation» о том, как устроены каналы в Go.
Go становится всё популярнее и популярнее, и одна из причин этого — великолепная поддержка конкурентного программирования. Каналы и горутины сильно упрощают разработку конкурентных программ. Есть несколько хороших статей о том, как реализованы различные структуры данных в Go — к примеру, слайсы, карты, интерфейсы —, но про внутреннюю реализацию каналов написано довольно мало. В этой статье мы изучим, как работают каналы и как они реализованы изнутри. (Если вы никогда не использовали каналы в Go, рекомендую сначала прочитать эту статью.)
Устройство канала
Давайте начнём с разбора структуры канала:
- qcount — количество элементов в буфере
- dataqsiz — размерность буфера
- buf — указатель на буфер для элементов канала
- closed — флаг, указывающий, закрыт канал или нет
- recvq — указатель на связанный список горутин, ожидающих чтения из канала
- sendq -указатель на связанный список горутин, ожидающих запись в канал
- lock — мьютекс для безопасного доступа к каналу
В общем случае, горутина захватывает мьютекс, когда совершает какое-либо действие с каналом, кроме случаев lock-free проверок при неблокирующих вызовах (я объясню это подробнее чуть ниже). Closed — это флаг, который устанавливается в 1, если канал закрыт, и в 0, если не закрыт. Эти поля далее будут исключены из общей картины, для большей ясности.
Канал может быть синхронным (небуферизированным) или асинхронным (буферезированным). Давайте вначале посмотрим, как работают синхронные каналы.
Синхронные каналы
Допустим, у нас есть следующий код:
package main
func main() {
ch := make(chan bool)
go func() {
ch <- true
}()
<-ch
}
Вначале создается новый канал и он выглядит вот так:
Go не выделяет буфер для синхронных каналов, поэтому указатель на буфер равен nil и dataqsiz
равен нулю. В приведённом коде нет гарантии, что случится первее — чтение из канала или запись, поэтому допустим, что первым действием будет чтение из канала (обратный пример, когда вначале идёт запись, будет рассмотрена ниже в примере с буферизированным каналами). Вначале, текущая горутина произведёт некоторые проверки, такие как: закрыт ли канал, буферизирован он или нет, содержит ли гоуртины в send-очереди. В нашем примере у канала нет ни буфера, ни ожидающих отправки горутин, поэтому горутина добавит сама себя в recvq
и заблокируется. На этом шаге наш канал будет выглядеть следующим образом:
Теперь у нас осталась только одна работающая горутина, которая пытается записать данные в канал. Все проверки повторяются снова, и когда горутина проверяет recvq
очередь, она находит ожидающую чтение горутину, удаляет её из очереди, записывает данные в её стек и снимает блокировку. Это единственное место во всём рантайме Go, когда одна горутина пишет напрямую в стек другой горутины. После этого шага, канал выглядит точно так же, как сразу после инициализации. Обе горутины завершаются и программа выходит.
Так устроены синхронные каналы. Сейчас же, давайте посмотрим на буферизированные каналы.
Буферезированные каналы
Рассмотрим следующий пример:
package main
func main() {
ch := make(chan bool, 1)
ch <- true
go func() {
<-ch
}()
ch <- true
}
Опять же, порядок исполнения неизвестен, пример с первой читающей горутиной мы разобрали выше, поэтому сейчас допустим, что два значения белы записаны в канал, и после этого один из элементов вычитан. И первым шагом идёт создание канала, который будет выглядеть вот так:
Разница в сравнении с синхронным каналом в том, что тут Go выделяет буфер и устанавливает значение dataqsiz
в единицу.
Следующим шагом будет отправка первого значения в канал. Чтобы сделать это, горутина сначала производит несколько проверок: пуста ли очередь recvq
, пуст ли буфер, достаточно ли места в буфере.
В нашем случае в буфере достаточно места и в очереди ожидания чтения нет горутин, поэтому горутина просто записывает элемент в буфер, увеличивает значение qcount
и продолжает исполнение далее. Канал в этот момент выглядит так:
На следующем шаге, горутина main отправляет следующее значение в канал. Когда буфер полон, буферизированный канал будет вести себя точно так же, как сихронный (буферизированный) канал, тоесть горутина добавит себя в очередь ожидания и заблокируется, в результате чего, канал будет выглядеть следующим образом:
Сейчас горутина main заблокирована и Go запустил одну анонимную горутину, которая пытается прочесть значение из канала. И вот тут начинается хитрая часть. Go гарантирует, что канал работает по принципу FIFO очереди (спецификация), но горутина не может просто взять значение из буфера и продолжить исполнение. В этом случае горутина main заблокируется навсегда. Для решения этой ситуации, текущая горутина читает данные из буфера, затем добавляет значение из заблокированной горутины в буфер, разблокирует ожидающую горутину и удаляет её из очереди ожидания. (В случае же, если нет ожидающих горутину, она просто читает первое значение из буфера)
Select
Но постойте, Go же ещё поддерживает select с дефолтным поведением, и если канал заблокирован, как горутина сможет обработать default? Хороший вопрос, давайте быстро посмотрим на приватное API каналов. Когда вы запускаете следующий кусок кода:
select {
case <-ch:
foo()
default:
bar()
}
Go запускает функцию со следующей сигнатурой:
func chanrecv(t *chantype, c *hchan, ep unsafe.Pointer, block bool)
chantype
это тип канала (например, bool в случае make (chan bool)), hchan
— указатель на структуру канала, ep
— указатель на сегмент памяти, куда должны быть записаны данные из канала, и последний, но самый интересный для нас — это аргумент block
. Если он установлен в false
, то функция будет работать в неблокирующем режиме. В этом режиме горутина проверяет буфер и очередь, возвращает true
и пишет данные в ep
или возвращает false
, если нет данных в буфере или нет отправителей в очереди. Проверки буфера и очереди реализованы как атомарные операции, и не требуют блокировки мьютекса.
Также есть функция для записи данных в очередь с аналогичной сигнатурой.
Мы разобрались как работают запись и чтение из канала, давайте теперь взглянём, что происходит при закрытии канала.
Закрытие канала
Закрытие канала это простая операция. Go проходит по всем ожидающим на чтение или запись горутинам и разблокирует их. Все получатели получают дефолтные значение переменных того типа данных канала, а все отправители паникуют.
Заключение
В этой статье мы рассмотрели, как каналы реализованы и как работают. Я постарался описать их как можно проще, поэтому упустил некоторые детали. Задача статьи — предоставить базовое понимание внутреннего устройства каналов и подтолкнуть вас к чтениею исходных кодов Go, если вы хотите получить более глубокое понимание. Просто почитайте код реализации каналов. Мне он кажется очень простым, хорошо документированным и довольно коротким, всего около 700 строк кода.
Ссылки
Исходный код
Каналы в спецификации Go
Каналы Go на стероидах