JavaRush /Курсы /Go SELF /`WithCancel` / `WithTimeout` / `WithDeadline`: где примен...

`WithCancel` / `WithTimeout` / `WithDeadline`: где применять и типовые ошибки

Go SELF
70 уровень , 0 лекция
Открыта

1. Контекст как «дерево операций»

После того как вы пару раз поймали зависшую горутину или «вечный» запрос к сети, появляется естественное желание: «Хочу красную кнопку “СТОП”». В Go эта кнопка почти всегда выглядит как context.Context. Важно понимать, что контексты не «плоские»: они образуют дерево, где дочерний контекст наследует отмену и дедлайн родителя. То есть если сверху «операцию закрыли», снизу она тоже должна закрыться. Это не магия — это дисциплина, которую стандартная библиотека делает удобной.

Представьте это так: пользовательский запрос (в CLI-команде или HTTP-handler’е) — это корень операции. Внутри вы делаете подоперации: чтение из хранилища, поход в сеть, импорт файла. Каждая подоперация должна жить не «сама по себе», а внутри времени жизни запроса. Именно поэтому мы почти всегда «растим» новый контекст от входного, а не начинаем новый мир с Background().

Наглядная схема:

flowchart TD
    A[Корневой ctx операции] --> B[Подоперация: storage]
    A --> C[Подоперация: http client]
    C --> D[Подоперация: json decode]
    A --> E[Подоперация: параллельные шаги]

И вот тут на сцену выходят три героя: WithCancel, WithTimeout, WithDeadline. Они все создают производный контекст от родителя.

2. WithCancel, WithTimeout, WithDeadline: когда какой

WithCancel: ручная отмена, когда вы точно знаете момент «хватит»

Когда вы пишете программу, иногда хочется отменить работу не потому, что «время вышло», а потому что логика решила остановиться. Например, вы нашли нужную задачу в кеше и больше не хотите читать диск. Или одна из параллельных попыток уже дала результат, и остальные можно гасить. Для такого сценария существует context.WithCancel(parent) — он возвращает новый ctx и функцию cancel(), которую вы вызываете сами.

Ключевая идея: cancel() закрывает канал ctx.Done(), и всё, что «уважает контекст», должно это заметить и закончиться. Это стандартный паттерн: один компонент подаёт сигнал остановки, остальные корректно выходят.

Мини-пример «на пальцах»:

package main

import (
	"context"
	"fmt"
)

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	<-ctx.Done()
	fmt.Println("reason:", ctx.Err()) // reason: context canceled
}

Здесь важно увидеть две вещи. Во-первых, Done() — это именно сигнал, по которому мы прекращаем работу. Во-вторых, причину прекращения мы читаем через Err() (а не гадаем по кофейной гуще).

В прикладном коде WithCancel чаще всего встречается там, где вы хотите преждевременно остановить подоперации: «уже достаточно», «нашли ответ», «пользователь отменил действие», «дальше не имеет смысла».

WithTimeout: ограничение «не дольше N секунд от текущего момента»

Если WithCancel — это «ручная кнопка», то WithTimeout — это «таймер на духовке»: по истечении времени всё выключается, даже если вы забыли. Вызов выглядит так: ctx, cancel := context.WithTimeout(parent, d). Он ставит дедлайн «сейчас + d». И как только время проходит, ctx.Done() закрывается, а ctx.Err() становится context.DeadlineExceeded.

Самое типичное место для WithTimeout — операции I/O: сеть, база данных, файловая система. То есть всё, что может «подвиснуть» по внешним причинам.

Мини-пример:

package main

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

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
	defer cancel()

	<-ctx.Done()
	fmt.Println("reason:", ctx.Err()) // reason: context deadline exceeded
}

Обратите внимание на defer cancel(). Да, таймаут и так «сработает». Но cancel() всё равно нужно вызывать как дисциплину освобождения ресурсов, потому что внутри контекста живут таймеры и связанная инфраструктура, и её лучше отпускать сразу, когда она больше не нужна.

Практический пример в нашем приложении задач

Представим, что у нас есть команда task list, которая ходит в хранилище. Мы хотим: если хранилище тормозит дольше 2 секунд — прекращаем и возвращаем понятную ошибку.

package main

import (
	"context"
	"time"
)

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

	return storageList(ctx) // допустим, функция уже существует в проекте
}

Тут мысль простая: таймаут задаёт верхний слой, потому что именно он отвечает за UX операции («сколько мы готовы ждать?»). Внутри storageList вы обязаны периодически проверять ctx.Done() или передавать ctx дальше в функции, которые умеют его уважать (например, драйвер БД, HTTP-клиент и т.д.).

WithDeadline: «не позже конкретного времени»

После WithTimeout возникает логичный вопрос: «А зачем тогда WithDeadline?» Затем, что иногда у нас есть не «продолжительность», а точка во времени, после которой операция не имеет смысла. Например: запрос должен быть завершён до определённого дедлайна (условно, до конца окна обработки), или вы синхронизируетесь с расписанием.

Технически: ctx, cancel := context.WithDeadline(parent, t), где t — это time.Time.

Мини-пример:

package main

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

func main() {
	deadline := time.Now().Add(200 * time.Millisecond)
	ctx, cancel := context.WithDeadline(context.Background(), deadline)
	defer cancel()

	<-ctx.Done()
	fmt.Println("err:", ctx.Err()) // err: context deadline exceeded
}

Главный смысловой критерий выбора между WithTimeout и WithDeadline такой: если в требованиях звучит «не позже 18:00» — это дедлайн; если звучит «не дольше 2 секунд» — это таймаут.

3. Как читать контекст и выбирать ограничение

Done(), Err() и Deadline()

Когда вы только начинаете, очень хочется писать код в стиле «ну если Done() закрылся, значит всё плохо». Но контекст специально даёт вам информацию о причине, и лучше ей пользоваться. В Go-блогах про контекст это прямо подчёркивается: Done() — сигнал отмены, Err() — причина завершения, Deadline() — возможность узнать дедлайн и принять решение, стоит ли вообще начинать работу.

Сведём это в маленькую таблицу, чтобы мозгу было легче:

Метод Что даёт Когда полезен
ctx.Done()
канал-сигнал завершения в select, чтобы “уметь остановиться”
ctx.Err()
nil или причина (Canceled / DeadlineExceeded) чтобы различать «отменили руками» и «время вышло»
ctx.Deadline()
(time.Time, bool) чтобы понять, есть ли дедлайн и сколько времени осталось

Мини-пример проверки дедлайна «есть ли вообще смысл начинать»:

package main

import (
	"context"
	"time"
)

func ShouldStart(ctx context.Context) bool {
	d, ok := ctx.Deadline()
	if !ok {
		return true
	}
	return time.Until(d) > 50*time.Millisecond
}

Это выглядит почти смешно, но на практике спасает от бессмысленной работы: если осталось 5 миллисекунд, а операция обычно занимает 200 — лучше сразу вернуть ctx.Err() и не создавать лишнюю нагрузку.

Короткий «навигатор выбора»

В реальном проекте самая частая проблема не «как написать WithTimeout», а «куда его поставить, чтобы не получилось три таймаута один внутри другого, и всё “иногда” отваливается». Поэтому держим в голове очень приземлённую логику.

WithCancel уместен там, где у вас есть логическое событие отмены, не связанное со временем: нашли результат, пользователь передумал, получили первую ошибку и гасим остальное (аккуратно, без фанатизма). Контекст в Go безопасен для использования несколькими горутинами, то есть один ctx реально может быть общим «рубильником» для группы работ.

WithTimeout ставится там, где вы хотите ограничить длительность: обычно это граница операции (CLI-команда, HTTP-handler) или конкретный «опасный» вызов к внешнему ресурсу.

WithDeadline ставится там, где дедлайн задан в абсолютном времени: например, когда у вас есть заранее вычисленная точка завершения, и вы хотите, чтобы вложенные операции знали именно её, а не «сколько осталось по пути».

Если попытаться изобразить это как компактную схему выбора:

flowchart TD
    A[Нужно ограничить работу] --> B{Ограничение по времени?}
    B -->|нет| C[WithCancel]
    B -->|да| D{Есть конкретное время?}
    D -->|да| E[WithDeadline]
    D -->|нет, нужна длительность| F[WithTimeout]

4. Почему defer cancel() — это реальная гигиена

На этом месте у новичков появляется ощущение: «Я же и так сделал WithTimeout, зачем ещё cancel()? Оно само умрёт». И вот здесь Go немного как спортзал: если делать правильно, больно меньше.

Дело в том, что производный контекст может держать внутренние ресурсы (таймеры, ссылки на родителя и прочее). Если вы завершили операцию раньше дедлайна, cancel() позволяет освободить эти ресурсы сразу, а не ждать, пока «прозвенит будильник». Это часть практики работы с производными контекстами.

Правильная привычка выглядит так: создали WithTimeout/WithDeadlineсразу написали defer cancel(). Даже если дальше в функции будет десять return и три ветки if — вы не забудете.

5. Паттерны для приложения задач: “оборачиваем” подоперации

Когда вы встраиваете контекст в прикладной код (например, в менеджер задач), важно не превращать всё в «контекстный суп»: ctx1, ctx2, ctx3, и каждый со своим таймаутом, а где-то ещё time.After для красоты. Начните с маленьких, понятных паттернов.

Паттерн: короткий таймаут на внешний вызов

Представим, что у вас есть функция LoadFromRemote(ctx) (например, синхронизация задач). На уровне сервиса вы хотите: общий запрос может жить до 2 секунд, но сетевой шаг — максимум 300 миллисекунд (потому что «подвисшие сети» — отдельный вид боли).

package main

import (
	"context"
	"time"
)

func Sync(ctx context.Context) error {
	netCtx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
	defer cancel()

	return loadFromRemote(netCtx)
}

Обратите внимание: дочерний контекст строится от входного ctx, а не от Background(). Это сохраняет дерево отмены: если пользовательская операция отменена, сетевой шаг тоже остановится.

Паттерн: ручная отмена, когда результат уже найден

Иногда в приложении задач есть «быстрый путь», например, поиск задачи сначала в памяти, потом в файле. Когда вы нашли задачу в памяти, вы хотите дать сигнал остальным подоперациям: «ребята, спасибо, расходимся».

package main

import (
	"context"
)

func FindTask(ctx context.Context, id int) error {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel()

	if err := findInMemory(ctx, id); err == nil {
		cancel()
		return nil
	}
	return findInFile(ctx, id)
}

Это упрощённый пример (в реальности вы часто делаете это конкурентно), но смысл WithCancel передан честно: отмена — по решению логики.

6. Типичные ошибки

Ошибка №1: забыли вызвать cancel() у WithTimeout или WithDeadline.
Код часто работает и без этого, поэтому ошибка коварная: она не “падает”, а медленно накапливает технический долг в виде лишних ресурсов и трудноуловимых эффектов. Лечится механически: создали контекст — сразу пишите defer cancel() рядом, не откладывая на «чуть позже допишу».

Ошибка №2: внутри функции берут context.Background() вместо того, чтобы использовать входной ctx.
Так вы обрываете дерево отмены: родительская операция может завершиться (например, пользователь отменил запрос), а ваша подоперация продолжит жить своей жизнью, как кот, который не признал правила дома. Производные контексты должны строиться от родителя, чтобы отмена и дедлайны наследовались.

Ошибка №3: путают смысл WithTimeout и WithDeadline.
В результате появляются странные конструкции: “таймаут” считается через time.Until(deadline), или дедлайн вычисляется как time.Now().Add(d) в десяти местах. Если ограничение задано длительностью — берите WithTimeout. Если задано конкретным временем — WithDeadline. Это не про «как удобнее», а про читаемость намерения.

Ошибка №4: после <-ctx.Done() не смотрят ctx.Err() и теряют причину остановки.
Когда операция завершается, вам часто важно различить: это пользователь отменил (context.Canceled) или время вышло (context.DeadlineExceeded). Если вы это не различаете, вы начинаете печатать одинаковые сообщения и в UX получается «просто не получилось», без объяснения. А ещё сложнее отлаживать: вы не понимаете, кто “виноват” — логика отмены или слишком короткий таймаут.

Ошибка №5: ставят таймауты “наугад” и слишком короткие.
Пять миллисекунд на чтение файла иногда работает… пока ваш ноутбук не решит обновить антивирус, а SSD — не заняться философией. Таймаут должен быть осмысленным: исходите из реальных ожиданий (и лучше чуть с запасом), а если операция важная — различайте “мягкие” и “жёсткие” ограничения (например, короткий таймаут на сеть и более длинный на всю команду).

1
Задача
Go SELF, 70 уровень, 0 лекция
Недоступна
Пульт отмены
Пульт отмены
1
Задача
Go SELF, 70 уровень, 0 лекция
Недоступна
Секундомер таймаута
Секундомер таймаута
1
Задача
Go SELF, 70 уровень, 0 лекция
Недоступна
Два источника
Два источника
1
Задача
Go SELF, 70 уровень, 0 лекция
Недоступна
Дедлайн миссии
Дедлайн миссии
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ