Go-каналы изнутри

Так как Go стал для меня вторым основным языком после C++, стало очевидно, что надо понимать как он работает не только снаружи, но и изнутри. Я немного сомневался с чего начать, то-ли с горутин, то-ли с каналов. Приблизительно представляя как может быть реализовано и то и другое, первым и наиболее разумным кандидатом на пристальное изучение оказались каналы. Ну что сказать, интересно!

Реализация каналов вместе со всей остальной низкоуровневой частью лежит в src/runtime/chan.go, и довольно легко поддается анализу. Физически, канал представлен структурой hchan, где наиболее интересно выглядят следующие моменты:

type hchan struct {
   qcount   uint           // total data in the queue
   dataqsiz uint           // size of the circular queue
   elemsize uint16
   elemtype *_type
   buf      unsafe.Pointer // points to an array of dataqsiz elements
   recvq    waitq  // list of recv waiters
   sendq    waitq  // list of send waiters

...
}

Как всем известно, канал в Go может работать как в синхронном, так и асинхронном (насколько хватает внутренней очереди) режиме. Очередь создается только один раз на этапе создания самого канала в функции makechan и не может менять свой размер или тип позднее. Сама очередь представляет собой ни что иное как кусок памяти, куда/откуда через memmove пишутся и читаются данные, размер которых хранится в elemsize, а тип определяется через elemtype. Просто, незатейливо и более чем ожидаемо при отсутствии обобщенного программирования в языке. С другой стороны, были бы конструкторы, деструкторы, наследование и прочее, то такой красоты уже особо б не вышло, а вышла бы метамагия с enable_if.

У каждого канала имеется список ожидающих данные go-рутин recvq, представляющий собой обычный двунаправленный список. Если на момент отправки сообщений несколько go-рутин ожидают сообщения, то из списка берется первая ожидающая (FIFO) go-рутина и ей отправляется сообщение без его копирования во внутренний буфер канала, а планировщик уведомляется о том, что необходимо запланировать выполнение выбранной для приема сообщения go-рутины. Если сообщения никто не ждет и в буфере еще есть свободное место, то элемент копируется. Ни места в буфере ни получателя сообщения? Ждите отправки, ваша go-рутина зарегистрирована в очереди sendq, а планировщик уведомлен о том, что дальнейшее выполнение отправляющей go-рутины необходимо отложить.

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

Модуль runtime экспортирует ряд функций, таких как reflect_makechan, reflect_chanclose, reflect_chansend и т.п., которые на этапе компиляции трансформируются в привычные ch <- data вызовы. Более подробно про то, как создать каналы динамически и с ними работать стоит поглядеть тут, отличный пример работы с каналами напрямую, без синтаксического сахара. А вот наглядный пример трансформации кода на этапе компиляции:

// compiler implements
select {
case v, ok = <-c:
  ... foo
default:
  ... bar
}

// as
if c != nil && selectnbrecv2(&v, &ok, c) {
  ... foo
} else {
  ... bar
}

Так что, все очень просто и при этом очень эффективно.

Leave a Reply