JavaRush /Курсы /Go SELF /Таймауты в коде — где context.WithTimeout

Таймауты в коде — где context.WithTimeout

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

1. Таймаут — это не “подождать”, а “ограничить”

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

Чтобы не путать эти вещи, будем держать в голове простую мысль. time.After — это удобный инструмент «подождать событие времени». А context.WithTimeout — это способ объявить правило «операция должна уложиться в дедлайн», причём это правило можно передать вниз по функциям как часть контракта. В описании context подчёркивается, что он нужен именно для передачи дедлайнов и сигналов отмены через границы API.

Небольшая схема, чтобы закрепить различие:

flowchart TD
    A[Начали операцию] --> B{Нужна пауза?}
    B -->|да| C["Ждём событие времени: <-time.After(d)"]
    B -->|нет| D{Нужен предел по времени?}
    D -->|да| E[Создаём ctx с дедлайном: context.WithTimeout]
    D -->|нет| F[Работаем без таймаута]
    E --> G["Во время работы проверяем ctx.Err()"]
    C --> H[Продолжаем после паузы]

2. time.After: когда нужен “одноразовый будильник”

time.After(d) удобно воспринимать как «разбуди меня через d». Он возвращает канал, который «подумает» d времени и потом отдаст событие. В классических материалах по Go это часто описывается как готовый механизм вместо ручного таймера: вместо «создать канал и заснуть в горутине» обычно используют time.After.

В рамках нашего дня мы трактуем это максимально скромно: мы не создаём каналы сами и не используем select, мы просто делаем ожидание вида <-time.After(d).

Мини‑пример: “подождать и продолжить”

package main

import (
	"fmt"
	"time"
)

func main() {
	fmt.Println("start")                 // start
	<-time.After(50 * time.Millisecond)  // ждём событие времени
	fmt.Println("done")                  // done
}

Здесь нет «таймаута операции». Это просто пауза. После паузы мы в любом случае продолжаем.

В учебном приложении: “чуть подождать перед повтором”

Пусть наше учебное приложение — это маленькая консольная утилита notebox, которая обрабатывает «заметки» (строки) и имитирует сохранение «куда‑то». Пока без файлов, без сети и без параллельности: чисто алгоритм и аккуратная работа со временем.

Сделаем функцию «пауза перед повторной попыткой»:

package main

import (
	"fmt"
	"time"
)

func waitBeforeRetry(d time.Duration) {
	fmt.Println("retry in:", d)
	<-time.After(d)
}

Она хороша тем, что читателю кода сразу видно: мы сознательно ждём d. Но заметьте: отменить это ожидание мы не можем (по нашей рамке мы не используем select, а значит не можем «ждать либо таймер, либо отмену»).

Где time.After не подходит

time.After плохо подходит, когда вы хотите выразить контракт «если операция затянулась — прекращаем». Главная причина простая: time.After сам по себе не говорит вашему коду «останавливайся»; он лишь даёт событие «время наступило». Чтобы превратить это в таймаут операции, обычно делают конкурентный select. Но сегодня мы сознательно не идём в эту сторону.

Ещё один практический момент: если вы вызываете time.After много раз в цикле «на каждую итерацию», вы фактически создаёте много отдельных таймеров. Иногда это нормально, но часто это признак того, что вы пытаетесь сделать «таймаут управления» там, где нужен другой инструмент (обычно контекст).

3. context.WithTimeout: таймаут как часть контракта

Если time.After — это будильник, то context.WithTimeout — это договор. Вы говорите: «вся операция имеет дедлайн», и дальше передаёте этот договор вниз по функциям.

В context есть важная механика: у него есть Done() (канал‑сигнал отмены) и Err() (причина завершения), а также Deadline() (момент дедлайна, если он установлен).

Ключевой момент: context.WithTimeout(parent, d) возвращает два значения — новый контекст и функцию cancel. И cancel() надо вызывать всегда (обычно через defer), даже если «вроде всё успели». Это не суеверие, а дисциплина: вы освобождаете ресурсы, связанные с таймером и дочерним контекстом.

Мини‑пример: “у контекста появился дедлайн”

package main

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

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

	<-time.After(50 * time.Millisecond)
	fmt.Println("ctx err:", ctx.Err()) // ctx err: context deadline exceeded
}

Мы снова используем <-time.After(...) как простое ожидание. Но «таймаут» здесь не time.After. Таймаут здесь — это состояние контекста: по истечении 30ms ctx.Err() станет context.DeadlineExceeded.

Важная правда: контекст не остановит ваш код сам

Контекст — это не полицейский с дубинкой, который выбивает у вас из рук time.Sleep. В нашем «без select и без конкурентности» режиме контекст работает кооперативно: вы должны проверять ctx.Err() и выходить сами.

Это даже полезно педагогически: вы начинаете видеть, где ваша логика «уважает дедлайн», а где просто делает вид.

Вот минимальный шаблон «кооперативной» функции:

package main

import (
	"context"
	"time"
)

func doStep(ctx context.Context) error {
	if err := ctx.Err(); err != nil {
		return err
	}

	time.Sleep(10 * time.Millisecond) // имитируем работу

	if err := ctx.Err(); err != nil {
		return err
	}
	return nil
}

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

4. Как выбрать: context.WithTimeout или time.After

Выбор между context.WithTimeout и time.After обычно превращается в спор «кто круче», но на практике это вопрос границ ответственности. Мы выбираем инструмент не по красоте, а по смыслу: «это локальное ожидание?» или «это контракт выполнения операции?»

Сравним в табличном виде — так меньше шансов случайно подменить смысл:

Ситуация Что выбрать Почему
Нужна пауза «перед следующим шагом» (ретрай, задержка UX, имитация)
time.After(d)
Это честная задержка: вы хотите подождать и продолжить.
Нужно ограничить операцию по времени, и это правило должно «ехать» вниз по функциям
context.WithTimeout(parent, d)
Это дедлайн как часть контракта: кто ниже по стеку, тот тоже должен его уважать.
Нужен таймаут в нескольких слоях (например, parsevalidatesave)
context.WithTimeout
Тогда каждый слой может проверять ctx.Err() и завершаться корректно.
Нужно просто «уснуть» на фиксированное время time.Sleep(d) или <-time.After(d) По сути это задержка. Если хочется единообразия с «каналом‑сигналом» — используйте After.

И ещё одно правило, которое экономит кучу нервов: если вы пишете функцию, которую потенциально будут переиспользовать, и она делает «долго» (I/O, вычисления, ожидание), то она почти всегда выигрывает от сигнатуры func(ctx context.Context) ... Тогда вызывающий решит, нужен дедлайн или нет, а ваша функция останется честной.

5. Пример notebox: общий таймаут на обработку пачки заметок

Сейчас мы соберём небольшой фрагмент нашего приложения так, чтобы он демонстрировал оба инструмента: time.After как задержку между элементами и context.WithTimeout как общий дедлайн на всю операцию.

Обратите внимание: мы не используем select, не запускаем горутины, не строим конкурентные схемы — всё линейно и прозрачно.

Сохранить одну заметку: имитация

package main

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

func saveNote(ctx context.Context, text string) error {
	if err := ctx.Err(); err != nil {
		return err
	}

	time.Sleep(15 * time.Millisecond) // имитируем сохранение
	fmt.Println("saved:", text)

	return ctx.Err()
}

Обработать несколько заметок с паузой между ними

Пауза между элементами — это не таймаут. Это просто «давай не спамить». Тут отлично подходит time.After.

package main

import (
	"context"
	"time"
)

func importNotes(ctx context.Context, notes []string) error {
	for _, n := range notes {
		if err := saveNote(ctx, n); err != nil {
			return err
		}
		<-time.After(5 * time.Millisecond) // маленькая пауза между сохранениями
	}
	return nil
}

Обвязка с общим таймаутом через context.WithTimeout

Вот здесь появляется «контракт»: вся операция импорта должна уложиться, скажем, в 40ms.

package main

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

func main() {
	notes := []string{"alpha", "beta", "gamma"}

	ctx, cancel := context.WithTimeout(context.Background(), 40*time.Millisecond)
	defer cancel()

	err := importNotes(ctx, notes)
	fmt.Println("import err:", err)
}

Если дедлайн прошёл, вы увидите ошибку context deadline exceeded. И это полезный сигнал: ваша функция не «повисла», а честно сообщила, что времени не хватило.

6. Практика: обрабатываем таймаут и не прячем контекст

Обработка таймаута как ошибки

Ошибки таймаута — это обычные error. Это значит, что мы должны обрабатывать их так же дисциплинированно, как любые другие ошибки: проверять err != nil, различать причины, добавлять контекст, но не устраивать «простыню».

Важно помнить, что ctx.Err() возвращает конкретные значения: чаще всего это context.DeadlineExceeded или context.Canceled. И лучше проверять именно их, чем сравнивать строки. В целом context задуман так, чтобы причина отмены читалась как ошибка‑значение.

Мини‑пример: делаем сообщение пользователю чуть человечнее, но техническую причину не теряем.

package main

import (
	"context"
	"errors"
	"fmt"
)

func renderError(err error) {
	if err == nil {
		return
	}
	if errors.Is(err, context.DeadlineExceeded) {
		fmt.Println("операция не успела за отведённое время")
		return
	}
	fmt.Println("ошибка:", err)
}

Нюанс проектирования: контекст передаём параметром

Иногда (особенно после пары дней «о, контекст везде!») появляется соблазн: «давайте я положу ctx в поле структуры, чтобы не передавать его в каждую функцию». Звучит удобно, но это почти всегда делает код хуже: неясно, чей именно дедлайн используется, какой у него жизненный цикл, и почему один вызов неожиданно влияет на другой.

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

7. Типичные ошибки в таймаутах

Ошибка №1: использовать time.Sleep как таймаут операции.
Sleep делает только одно: останавливает текущий поток выполнения на заданное время. Он не «ограничивает» чужую работу и не может внезапно прервать функцию. Если вы пишете «сделаю Sleep(2s), и если за это время не получилось — значит таймаут», вы на самом деле просто поспали 2 секунды и ничего не проконтролировали.

Ошибка №2: создать ctx, cancel := context.WithTimeout(...) и забыть cancel().
Это один из тех моментов, где Go суров, но справедлив. Отложенный defer cancel() — это не украшение, а уборка за собой. Без этого вы оставляете связанные с контекстом ресурсы жить дольше, чем нужно, особенно если подобные контексты создаются часто.

Ошибка №3: ожидать, что context.WithTimeout сам остановит долгий Sleep или другое блокирующее действие.
В нашем режиме без select контекст работает «по договорённости»: вы сами должны проверять ctx.Err() и выходить. Если вы поставили таймаут и затем сделали time.Sleep(10 * time.Second), то «таймаут» наступит, но ваш код всё равно будет спать, пока не проснётся и не проверит ctx.Err().

Ошибка №4: путать «пауза» и «дедлайн».
time.After — шикарен для «подождать и продолжить». Но если вы начинаете думать о нём как о «таймауте операции», вы быстро придёте к конкурентным паттернам (select), которые мы сегодня не используем. Если нужен именно дедлайн как правило — это территория context.WithTimeout.

Ошибка №5: проверять таймаут через err.Error() вместо context.DeadlineExceeded.
Строка ошибки — это текст для человека, а не контракт для программы. В нормальном Go‑коде мы различаем причины по значениям ошибок (errors.Is, сравнение с sentinel‑значениями), а не по строкам. Это делает код устойчивее и спокойнее в сопровождении.

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