Context - управление временем жизни операций
Представь: твой HTTP-хендлер делает запрос в базу, потом идёт во внешний API. Пользователь закрыл вкладку. Зачем продолжать работу, если результат никому не нужен? Пакет context решает именно эту задачу - позволяет обрубить цепочку операций, когда они больше не нужны.
Зачем нужен context
Без контекста каждая горутина живёт сама по себе. Если главная функция решила, что ответ уже не нужен, фоновые задачи всё равно продолжат работать, тратя CPU и соединения с базой.
Context - это сигнал отмены, который пробрасывается по всей цепочке вызовов:
хендлер → сервис → репозиторий → SQL-запрос. Когда контекст отменяется, все
звенья цепочки могут об этом узнать - обычно через
канал ctx.Done().
cancel()закрывает каналctx.Done(). Всё. Он не прерывает выполнение, не убивает горутину и не откатывает начатое.
Горутина остановится только если сама проверит сигнал. Вот код, который переживёт любой таймаут:
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
go func() {
for i := 0; i < 1e10; i++ {
heavyStep(i) // ctx нигде не проверяется
}
}()
// Через секунду ctx.Done() закроется, а цикл продолжит работать
// до конца - и будет держать CPU и память.
Отсюда три следствия, которые дальше в уроке будут встречаться постоянно:
- Библиотека обязана поддерживать context, иначе он бесполезен.
database/sql,net/httpиos/execподдерживают:QueryContextдействительно прервёт запрос, аhttp.NewRequestWithContext- закроет соединение. Функция без параметраctxне прервётся никогда; - в своих длинных циклах проверку надо писать руками -
selectсctx.Done()илиif ctx.Err() != nil(см. правило 5 ниже); - отмена не откатывает побочные эффекты. Прервали запрос на середине транзакции - откат сделает база по закрытию соединения, а вот отправленное письмо или списанные деньги контекст не вернёт. Это работа саги или идемпотентного повтора.
Нет в Go способа «убить» горутину извне, и это сознательное решение авторов языка: принудительное завершение оставляет захваченные мьютексы и недописанные данные. Кооперативная отмена медленнее, зато состояние остаётся согласованным.
context.Background и context.TODO
Всё начинается с корневого контекста:
ctx := context.Background() // Корневой контекст, точка старта
ctx := context.TODO() // Заглушка - «тут будет контекст, но пока не решил какой»
Background() используется в main(), при инициализации и в тестах. TODO() - временная заглушка, чтобы компилятор не ругался, пока вы рефакторите код.
Отмена по тайм-ауту: WithTimeout
Самый частый сценарий - ограничить время выполнения операции:
package main
import (
"context"
"fmt"
"time"
)
func slowOperation(ctx context.Context) error {
select {
case <-time.After(3 * time.Second): // Операция занимает 3 секунды
fmt.Println("Операция завершена")
return nil
case <-ctx.Done(): // Контекст отменён
return ctx.Err()
}
}
func main() {
// Даём 1 секунду на всё
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel() // Всегда вызывайте cancel!
err := slowOperation(ctx)
if err != nil {
fmt.Println("Не успели:", err)
// Вывод: Не успели: context deadline exceeded
}
}
WithTimeout возвращает дочерний контекст и функцию cancel. Через 1 секунду канал ctx.Done() закроется, и все, кто его слушают, получат сигнал.
Правило: всегда вызывайте
defer cancel(), даже если тайм-аут сработает сам. Безcancel()ресурсы таймера утекут.
Ручная отмена: WithCancel
Иногда нужно отменить операцию не по времени, а по событию - например, когда получен первый результат из нескольких горутин:
package main
import (
"context"
"fmt"
"time"
)
func search(ctx context.Context, query string, resultCh chan<- string) {
// Имитация поиска с разной скоростью
delay := time.Duration(len(query)) * 100 * time.Millisecond
select {
case <-time.After(delay):
resultCh <- fmt.Sprintf("Результат для '%s'", query)
case <-ctx.Done():
return // Отменили - выходим тихо
}
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
resultCh := make(chan string, 3)
go search(ctx, "Go", resultCh) // Быстрый
go search(ctx, "Golang", resultCh) // Средний
go search(ctx, "Go context", resultCh) // Медленный
// Берём первый ответ и отменяем остальные
first := <-resultCh
cancel() // Останавливаем остальные горутины
fmt.Println("Первый результат:", first)
}
Дедлайн: WithDeadline
WithDeadline - то же, что WithTimeout, но указывается конкретный момент времени:
// Операция должна завершиться до 15:00
deadline := time.Date(2025, 1, 15, 15, 0, 0, 0, time.Local)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()
На практике WithTimeout используется чаще, потому что обычно думаешь в терминах «дай 5 секунд», а не «сделай до 15:04:32».
Context в HTTP-сервере
Каждый входящий HTTP-запрос уже несёт контекст. Если клиент разорвал соединение, контекст отменяется автоматически:
package main
import (
"fmt"
"log"
"net/http"
"time"
)
func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // Контекст запроса
select {
case <-time.After(5 * time.Second): // Тяжёлая работа
fmt.Fprintln(w, "Готово!")
case <-ctx.Done():
// Клиент ушёл - не тратим ресурсы
log.Println("Клиент отключился:", ctx.Err())
return
}
}
func main() {
http.HandleFunc("/slow", handler)
http.ListenAndServe(":8080", nil)
}
Context в HTTP-клиенте
При исходящих запросах контекст позволяет не зависать, если внешний API лёг:
package main
import (
"context"
"fmt"
"io"
"net/http"
"time"
)
func fetchURL(ctx context.Context, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return "", fmt.Errorf("создание запроса: %w", err)
}
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("выполнение запроса: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("чтение ответа: %w", err)
}
return string(body), nil
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
result, err := fetchURL(ctx, "https://httpbin.org/delay/10")
if err != nil {
fmt.Println("Ошибка:", err)
// Вывод: Ошибка: выполнение запроса: context deadline exceeded
return
}
fmt.Println(result)
}
Ключевое: используйте http.NewRequestWithContext, а не http.NewRequest + ручное управление. Контекст автоматически прервёт соединение при отмене.
Context в работе с базой данных
Те же принципы работают с database/sql:
func getUser(ctx context.Context, db *sql.DB, id int) (*User, error) {
// Запрос будет прерван, если контекст истечёт: database/sql
// поддерживает отмену и закроет соединение сам
row := db.QueryRowContext(ctx, "SELECT id, name FROM users WHERE id = $1", id)
var user User
if err := row.Scan(&user.ID, &user.Name); err != nil {
return nil, fmt.Errorf("scan user %d: %w", id, err)
}
return &user, nil
}
Все методы database/sql имеют *Context-варианты: QueryContext, ExecContext, QueryRowContext. Используйте именно их.
Передача значений: WithValue
Контекст может нести метаданные - request ID для логов, информацию об авторизации:
package main
import (
"context"
"fmt"
)
// Используем свой тип для ключа, чтобы избежать коллизий
type contextKey string
const requestIDKey contextKey = "requestID"
func processRequest(ctx context.Context) {
reqID, ok := ctx.Value(requestIDKey).(string)
if !ok {
reqID = "unknown"
}
fmt.Printf("[%s] Обработка запроса\n", reqID)
}
func main() {
ctx := context.WithValue(context.Background(), requestIDKey, "req-abc-123")
processRequest(ctx)
}
Антипаттерн: не используйте
WithValueдля передачи обычных аргументов функций. Контекст - для метаданных (request ID, трейсинг, авторизация), а не для бизнес-логики.
Правила работы с context
-
Context - первый параметр функции, всегда с именем
ctx:func DoSomething(ctx context.Context, id int) error { ... } -
Никогда не храните контекст в структуре. Передавайте через аргументы.
-
Всегда вызывайте
cancel()черезdefer- это освобождает ресурсы. -
Не передавайте
nilвместо контекста. Если не знаете, какой контекст нужен, используйтеcontext.TODO(). -
Проверяйте
ctx.Err()в длительных циклах:for _, item := range bigSlice { if ctx.Err() != nil { return ctx.Err() // Быстрый выход } process(item) }
Типичная цепочка в бэкенде
Вот как контекст проходит через типичный HTTP-хендлер:
HTTP запрос (ctx с автоотменой при дисконнекте)
→ handler (добавляет тайм-аут: WithTimeout)
→ service (пробрасывает ctx)
→ repository (QueryContext с этим ctx)
→ PostgreSQL (отменит запрос, если ctx истёк)
Если на любом этапе контекст отменяется, вся цепочка останавливается. Никаких зависших запросов и утечек горутин.
Мини-задание
- Сделай
context.WithTimeoutна 100 мс вокруг операции на секунду и напечатайctx.Err(). СравниDeadlineExceededиCanceled - Отмени контекст вручную и проверь, что
<-ctx.Done()срабатывает у всех, кто его слушает - Передай контекст в
http.NewRequestWithContextи отмени его во время запроса - соединение оборвётся - Забудь вызвать
cancel()и запустиgo vet ./...:lostcancelэто заметит - Положи значение в контекст через
context.WithValueи подумай, почему так не советуют передавать параметры
Что запомнить
context.WithTimeout- ограничить время операции (самый частый случай)context.WithCancel- ручная отмена (например, первый результат из нескольких)ctx.Done()- канал, который закрывается при отмене (используйте вselect)ctx.Err()- причина отмены (DeadlineExceededилиCanceled)- Всегда
defer cancel()- даже если тайм-аут сработает сам - Context передаётся первым аргументом, не хранится в структурах
WithValue- только для метаданных, не для бизнес-логики
Это был последний теоретический урок трека. Дальше - финальный проект: соберём REST API, в котором пригодится всё перечисленное выше.