Урок курса

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

Golang с нуля

Короткий пример WithTimeout целиком

Сохраните код в main.go и выполните go run main.go. Программа ждёт закрытия канала Done и печатает причину:

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
    defer cancel()
    <-ctx.Done()
    fmt.Println(ctx.Err())
}

Результат — context deadline exceeded. Если перед ожиданием вызвать cancel(), получите context canceled. Context сообщает об отмене, но не останавливает произвольную функцию принудительно: рабочий код должен читать Done, проверять Err или передавать ctx операции, которая поддерживает отмену.

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

Задача 1

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

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

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

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 2

Горутина выполняет:

func worker(ctx context.Context) {
    select {
    case <-ctx.Done():
        fmt.Println(ctx.Err())
        return
    case <-time.After(10 * time.Second):
        fmt.Println("done")
    }
}

Контекст создан через context.WithTimeout(context.Background(), 200*time.Millisecond). Что напечатает горутина?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 3

Что вернёт ctx.Err() для контекста, созданного через context.WithCancel, если cancel() ещё не был вызван?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 4

Работа завершилась раньше таймаута, созданного через context.WithTimeout. Зачем всё равно вызывать возвращённый cancel?

Выберите один вариант
Без регистрации · результат не сохраняется

Задача 5

Поиск с ограничением по времени

Любая справочная система, работающая с несколькими независимыми источниками знаний, рано или поздно сталкивается с простым вопросом: что делать, если один из источников отвечает слишком долго? Пользователь не готов ждать бесконечно, и сервис должен уметь вовремя остановиться — не потеряв при этом те ответы, которые уже успели прийти.

Именно здесь в дело вступает контекст. Вызывающий код отводит на весь поиск строго ограниченное время и передаёт контекст первым аргументом при каждом обращении к источнику. Сервис обходит источники последовательно и на каждом шаге одновременно следит за двумя событиями: либо источник вернул очередной ответ, либо время вышло и контекст сообщил об отмене. Как только поступает сигнал об отмене, сервис прекращает дальнейшие запросы, не дожидаясь оставшихся источников.

Итогом работы становится пара: срез уже собранных ответов и ошибка, которую вернул контекст. Если все источники успели ответить в отведённое время, ошибка окажется пустой. Если же таймаут или явная отмена прервали поиск раньше срока, вызывающий код получает частичный результат вместе с понятным объяснением причины остановки — и может решить, достаточно ли собранных данных или стоит повторить запрос.

Что нужно сделать

Реализуйте Search(ctx context.Context, sources []Source). Передавайте ctx первым аргументом каждому Query. Для текущего источника ждите через select либо ответ, либо ctx.Done(). При отмене немедленно верните уже собранные ответы и ctx.Err(); следующие источники не вызывайте. Готовый вызывающий код показывает context.WithTimeout и обязательный defer cancel().

Открыть задание в уроке →

Продолжить с сохранением прогресса

Перейдите к уроку в учебном кабинете.

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