Конкурентность: горутины, каналы и контекст

Каналы и select: передача данных и координация событий

Содержание курса

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 или проверку флага, но это уже за пределами данного урока.

Начать заново?

Помощник начнёт новый диалог по этой задаче. Текущая переписка останется в истории до конца срока хранения.

О переписке

Можно приложить до трёх фото PNG, JPEG или WebP по 5 МБ.

Переписка и фото хранятся 90 дней после последнего принятого сообщения. Преподаватель получит доступ к диалогу, когда вы попросите его помочь.

Проверка решения остаётся отдельным действием.