Як влаштовані канали в 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 на стероїдах
Джерело: Хабрахабр
  • avatar
  • 0

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.