Урок курса

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

Golang с нуля

В прошлом уроке stopCh — закрытый канал — служил сигналом остановки горутин. Он прекрасно справлялся с одной функцией, но как только вызовов становится несколько, нужно вручную прокидывать stopCh по всей цепочке и синхронизировать закрытие. context.Context решает именно эту задачу: отмена распространяется по дереву вызовов автоматически, а тип и соглашения стандартизированы.

context.Background() как корень и соглашение о передаче ctx первым аргументом

context.Background() — это отправная точка. Он не имеет дедлайна, никогда не отменяется и не несёт никаких значений. Его единственная роль — быть корнем, от которого вы потом получите дочерние контексты через WithCancel или WithTimeout.

В main это выглядит так:

package main

import (
    "context"
    "fmt"
)

func doWork(ctx context.Context, name string) {
    fmt.Println("working:", name)
}

func main() {
    ctx := context.Background()
    doWork(ctx, "task-1")
}

Обратите внимание на сигнатуру doWork: ctx context.Context идёт первым параметром, тип — интерфейс context.Context, а не какой-либо конкретный тип. Это устоявшееся соглашение Go: если функция принимает контекст, он обычно передаётся первым параметром и называется ctx. Стандартная библиотека и большинство популярных пакетов придерживаются этого правила, поэтому функцию можно вызвать с любым контекстом — фоновым, с таймаутом или тестовым.

Почему ctx не хранят в поле структуры? Контекст описывает время жизни конкретного вызова или запроса: у каждого HTTP-запроса свой ctx, который отменяется, когда клиент отключился. Если поместить контекст в поле, он теряет эту привязку: непонятно, когда его отменять, кто владеет его жизненным циклом и откуда пришёл сигнал отмены. Это делает код непрозрачным и трудным для тестирования. Передача через аргумент явно показывает: «этот контекст действует только во время данного вызова».

WithCancel и WithTimeout: создание дочернего контекста и обязательный defer cancel

От корневого context.Background() вы не можете послать сигнал отмены — он неотменяем по определению. Чтобы получить контекст, который можно остановить, нужен дочерний:

ctx, cancel := context.WithCancel(context.Background())
defer cancel()

Две вещи происходят одновременно: появляется дочерний контекст ctx и функция cancel, которая его останавливает. defer cancel() должна быть зарегистрирована немедленно — следующей строкой после вызова. Это не формальность.

Что делает cancel? Она закрывает ctx.Done(), удаляет связь дочернего контекста с родителем и отменяет всех потомков ctx. Без вызова cancel эта связь остаётся живой до тех пор, пока родительский контекст сам не завершится — возможно, никогда. Повторный вызов cancel() безопасен: он просто ничего не делает.

WithTimeout добавляет автоматический таймер:

ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()

Через 2 секунды контекст отменится сам. Но defer cancel() всё равно нужен — и вот почему: если работа завершится за 500 мс, внутренний таймер продолжит жить ещё 1.5 секунды, удерживая ресурсы. cancel() гасит таймер досрочно.

Как различить причину отмены? Через ctx.Err():

  • Явный вызов cancel()context.Canceled
  • Истёк таймаут → context.DeadlineExceeded
  • Контекст ещё активен → nil

Пока функция работает нормально, ctx.Err() возвращает nil. Как только контекст отменён — любым способом — значение фиксируется и уже не меняется.

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() фиксируется в момент отмены и уже не меняется: функция, которая проверит его чуть позже, увидит ровно ту же причину остановки.

Попробуйте решить

Разработчик написал:

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// ... запуск горутины с ctx ...
cancel() // явная отмена

Что произойдёт, когда defer cancel() сработает при выходе из функции, если cancel() уже был вызван явно ранее?

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

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

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