Урок курса

Каналы и 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 в этот момент?

Продолжить с проверкой и прогрессом

Откройте интерактивный раннер с заданиями урока.

Перейти к интерактивному уроку