Урок курса
Каналы и select: передача данных и координация событий
Golang с нуляВ прошлом уроке горутины координировались через sync.WaitGroup и sync.Mutex: одна горутина ждала завершения других, а мьютекс защищал общий счётчик. Это работает, но требует явного разделения памяти и блокировок вокруг каждого чтения и записи. Каналы предлагают другой контракт: вместо того чтобы несколько горутин читали одну переменную по очереди, одна горутина передаёт значение другой — и передача сама по себе является синхронизацией.
make(chan T), буфер, отправка и приём: механика блокировки
Канал создаётся через make. Две формы:
ch := make(chan int) // небуферизованный
buf := make(chan int, 3) // буферизованный, ёмкость 3
Тип элементов фиксируется при создании: chan int не примет строку, компилятор это проверяет статически.
Посмотрим на небуферизованный случай. Если горутина A выполняет ch <- 42, она замирает — не в смысле sleep, а в смысле «планировщик её не будет трогать» — до тех пор, пока другая горутина не выполнит v := <-ch. Оба события должны случиться одновременно: это рандеву. Именно поэтому нельзя отправить в небуферизованный канал из единственной горутины без другого получателя — программа намертво зависнет.
Буфер меняет контракт: отправка buf <- 1 не блокирует, пока в буфере есть место. Отправитель заполнил три слота — на четвёртой попытке он снова блокируется, пока получатель не вытащит хотя бы одно значение.
Минимальный рабочий пример — обмен числом между двумя горутинами:
package main
import "fmt"
func main() {
ch := make(chan int)
go func() {
ch <- 7 // отправитель блокируется здесь
}()
v := <-ch // получатель разблокирует отправителя
fmt.Println(v) // 7
}
Последовательность: горутина пытается отправить 7, встаёт в ожидание; main доходит до <-ch, забирает значение — и в этот момент обе стороны продолжают выполнение. Канал здесь — точка встречи, а не хранилище.
С буфером ёмкостью 1 та же программа работала бы даже без горутины, потому что отправка не блокируется сразу. Но это уже другая семантика: отправитель не знает, получено ли значение — он знает только, что оно положено в буфер. Для рандеву-синхронизации нужен именно небуферизованный канал.
close, идиома v, ok и range по каналу: распознавание закрытия и обход до конца
Канал не закрывается сам по себе. Отправляющая сторона явно вызывает close(ch) — это сигнал: «новых значений не будет». После закрытия получатели могут дочитать то, что уже лежит в буфере, а потом приём начнёт возвращать нулевое значение типа.
Чтобы отличить «получил реальное значение» от «канал закрыт и пуст», используется двойное присваивание:
v, ok := <-ch
if !ok {
// канал закрыт, v == нулевое значение типа
}
ok == true означает, что значение пришло от отправителя. ok == false — канал закрыт и опустел. Важно: даже после close приём с ok == true возможен, пока в буфере что-то есть.
Цикл for v := range ch делает именно это в цикле — читает, пока ok == true, и завершается, когда канал закрыт и буфер пуст. Это удобнее ручной проверки ok:
ch := make(chan int, 3)
ch <- 10
ch <- 20
ch <- 30
close(ch)
for v := range ch {
fmt.Println(v) // 10, 20, 30
}
// после range программа продолжается здесь
Ключевой момент: range не завершится, если канал никогда не закрыт. Горутина зависнет навсегда. Поэтому close — это не опция, а обязательство отправителя перед всеми, кто обходит канал через range.
Правило владения: close вызывает тот, кто отправляет — и только он один. Получатель не знает, есть ли ещё другие отправители, поэтому он физически не может принять решение о закрытии. Если попытаться закрыть канал дважды или отправить в уже закрытый:
close(ch)
close(ch) // паника: close of closed channel
ch <- 1 // паника: send on closed channel
Обе паники — рантаймовые, не компиляторные. Go не отслеживает состояние канала статически. Дисциплина «один владелец — одно закрытие» — архитектурная договорённость, которую программист соблюдает вручную.
select и локальный pipeline: координация data channel и stop channel без блокировки горутины
select — это способ ждать сразу на нескольких каналах. Горутина блокируется до тех пор, пока хотя бы один из перечисленных case не готов:
select {
case v := <-dataCh:
// обработать v
case <-stopCh:
// получен сигнал остановки
}
Если оба канала готовы одновременно — Go выбирает один из них случайным образом. Приоритета нет: даже если stopCh закрыт, select может выбрать dataCh, пока в нём есть данные.
Почему не busy loop? Без select пришлось бы опрашивать каналы по очереди в цикле — это занимало бы CPU впустую. select без default блокируется до первого готового события: планировщик не касается горутины, пока ничего не происходит.
Как остановить сразу несколько получателей одним сигналом
Здесь важна семантика закрытия канала против отправки значения:
close(stopCh)— broadcast: все горутины, ожидающие<-stopCh, получают сигнал одновременно.stopCh <- struct{}{}— unicast: значение получает ровно одна горутина. Если consumer заберёт его, producer не получит сигнала и может зависнуть наdataCh <- iбез получателя.
Поэтому, когда один и тот же stopCh слушают и producer, и consumer, единственный безопасный способ остановить обоих — закрыть канал, а не отправить в него значение.
Посмотрим на pipeline, где эти механизмы работают вместе:
package main
import "fmt"
func producer(dataCh chan<- int, stopCh <-chan struct{}, doneCh chan<- struct{}) {
defer close(doneCh)
defer close(dataCh)
for i := 0; i < 10; i++ {
select {
case dataCh <- i:
case <-stopCh:
return
}
}
}
func consumer(dataCh <-chan int, stopCh <-chan struct{}, doneCh <-chan struct{}) int {
sum := 0
for {
select {
case v, ok := <-dataCh:
if !ok {
<-doneCh
return sum
}
sum += v
case <-stopCh:
<-doneCh
return sum
}
}
}
func main() {
dataCh := make(chan int)
stopCh := make(chan struct{})
doneCh := make(chan struct{})
go producer(dataCh, stopCh, doneCh)
result := consumer(dataCh, stopCh, doneCh)
fmt.Println(result) // 45
}
Разбор контракта. Producer — единственный владелец dataCh и doneCh: он их закрывает через defer. defer close(dataCh) выполняется при любом выходе — и после штатного завершения цикла, и при выходе по stopCh. doneCh сигнализирует, что producer завершил работу.
Consumer в каждой итерации ждёт одно из двух событий:
dataChвернулok == false— канал закрыт, все данные получены;<-stopCh— внешний сигнал остановки.
В обоих случаях consumer выполняет <-doneCh перед возвратом. Это принципиально: без этого ожидания consumer мог бы вернуться, пока producer ещё пытается выполнить dataCh <- i. Получателя нет — producer блокируется навсегда, doneCh не закрывается, и программа зависает в deadlock. <-doneCh гарантирует, что consumer не уходит раньше producer.
Почему stopCh здесь закрывается, а не получает значение. Producer и consumer оба ждут <-stopCh. Если бы внешний код сделал stopCh <- struct{}{}, сигнал получил бы только один из них — и второй остался бы заблокированным. close(stopCh) одновременно разблокирует обоих: это broadcast через закрытие. В примере выше никто не закрывает stopCh, поэтому эта ветка никогда не активируется — программа завершается штатно через закрытие dataCh.
Псевдослучайность select при конкурирующих готовых ветках. Если stopCh закрыт и в dataCh одновременно есть данные, select выберет одну из веток случайно. Приоритета у сигнала остановки нет — это следствие того, как Go реализует select. Если нужна гарантированная обработка стоп-сигнала в первую очередь, придётся добавлять вложенный select или проверку флага, но это уже за пределами данного урока.
Попробуйте решить
Небуферизованный канал ch := make(chan int). Горутина A выполняет ch <- 42. Горутина B ещё не дошла до <-ch. Что происходит с горутиной A в этот момент?
Продолжить с проверкой и прогрессом
Откройте интерактивный раннер с заданиями урока.
