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

context.Context: отмена и таймаут по цепочке вызовов

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

ctx.Done() и ctx.Err(): остановка doWork через select при отмене и таймауте

ctx.Done() возвращает канал типа <-chan struct{}. Здесь важен один нюанс: для неотменяемого контекста — например, context.Background() — этот метод возвращает nil, и такой канал никогда не закрывается. Для контекстов, созданных через WithCancel или WithTimeout, канал ненулевой: когда контекст отменяется, он закрывается, и операция receive по нему становится постоянно готовой.

Типичная рабочая функция использует select для одновременного ожидания двух событий:

func doWork(ctx context.Context, workCh <-chan string) error {
    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case item, ok := <-workCh:
            if !ok {
                return nil
            }
            fmt.Println("processing:", item)
        }
    }
}

Горутина заблокирована до тех пор, пока хотя бы один из каналов не станет готов — это не busy loop. После закрытия ctx.Done() ветка отмены становится постоянно готовой и сигнал уже не теряется. Однако приоритета у неё нет: если одновременно готов и workCh, select выбирает одну из веток псевдослучайно. Обработка отмены гарантированно произойдёт при одном из последующих проходов цикла — но не обязательно немедленно. Именно поэтому длительную работу внутри рабочей ветки стоит ограничивать или также делать отменяемой.

Посмотрим на оба сценария:

func main() {
    workCh := make(chan string)

    // Сценарий 1: явная отмена
    ctx1, cancel1 := context.WithCancel(context.Background())
    defer cancel1()
    go func() {
        time.Sleep(100 * time.Millisecond)
        cancel1()
    }()
    err := doWork(ctx1, workCh)
    fmt.Println(err) // context canceled

    // Сценарий 2: таймаут
    ctx2, cancel2 := context.WithTimeout(context.Background(), 100*time.Millisecond)
    defer cancel2()
    err = doWork(ctx2, workCh)
    fmt.Println(err) // context deadline exceeded
}

В первом случае горутина явно вызывает cancel1()doWork в итоге получает context.Canceled. Во втором никто cancel2 явно не зовёт, но через 100 мс таймер срабатывает сам — doWork получает context.DeadlineExceeded. Значение ctx.Err() фиксируется в момент отмены и уже не меняется: функция, которая проверит его чуть позже, увидит ровно ту же причину остановки.