Перевод познавательной статьи "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 на стероидах

Поделиться с друзьями
-->

Комментарии (11)


  1. Deosis
    19.08.2016 07:15

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


    1. divan0
      19.08.2016 11:57

      Если горутина читает, то горутина не знает, получила она действительное значение либо дефолтное при закрытии канала.
      Как разрешить следующую ситуацию?

      С помощью comma,ok синтаксиса:
      x, ok := <-ch
      

      Если канал закрылся, ok будет равен false. Это есть в спецификации, кстати.

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

      Нет, в буферизированном канале ничего не пропадёт. Дефолтное значение возвращается только заблокированным читателям (значит, они читают либо из небуферизированного канала, либо буфер пуст)
      Вообще, закрытие канала означает по сути сигнал «в этот канал больше писать ничего нельзя». Всё что есть в буфере — будет вычитано корректно.
      А close вообще нужно использовать осторожно и только с полным пониманием зачем и кто его делает (отправитель или получатель, в общем случае close каналу делает отправитель). И, кстати, совершенно легально вообще не закрывать канал. При завершении горутин в скопе которых определен канал, сборщик мусора сам корректно все закроет и почистит. Ну, или при выходе из программы.


    1. GeckoGreen
      19.08.2016 11:57

      Из закрытого буферизированного канала можно достать все данные. Потом пойдет дефолт.


  1. softaria
    19.08.2016 20:11

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


    1. divan0
      19.08.2016 20:17

      Это да, в статье select упоминается только для объяснения, как чтение из канала работает под капотом в случае default-case. Подразумевается, что читатель с самим языком знаком :)


      1. softaria
        20.08.2016 07:44

        Кстати, тут есть не понятный момент. Пусть у меня есть select, который читает из двух каналов.
        Default части нет.
        Как это работает? Мы ведь не можем заблокировать горутину на одном из каналов (вдруг сообщение придёт в другой?)
        Неужели оно бесконечно проверяет оба канала неблокируя совсем? Вряд ли же.


        1. neolink
          20.08.2016 12:38

          https://golang.org/ref/spec#Select_statements
          собственно в начале проверяются все канала, если есть куда писать, или откуда читать выбирается случайный их них, если нет то горутина добавляется в очереди всех каналов перечисленных в селекте


          1. softaria
            20.08.2016 19:20

            Здесь должен быть тонкий момент при разблокировки горутины, которая висит на нескольких каналах.
            Когда в один из каналов придут данные, нужно каким-то образом одной атомарной операцией убрать горутину из recvq всех каналов, которые она слушала. lock какого-либо канала для этого использовать нельзя.
            Интересно, как это делается.


            1. imrobot2002
              20.08.2016 22:26

              Это происходит тут: https://github.com/golang/go/blob/master/src/runtime/select.go#L454

              И в момент срабатывания select-а все каналы лочатся:
              https://github.com/golang/go/blob/master/src/runtime/select.go#L324


              1. neolink
                20.08.2016 22:51

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


              1. softaria
                21.08.2016 14:47

                спасибо