Перейти к содержимому

context.Context

context.Context — стандартный механизм передачи cancellation, deadline и request-scoped values через цепочки вызовов и горутин. Без context невозможно написать корректный HTTP-сервис, gRPC-клиент или graceful shutdown. На собеседовании про context спросят обязательно: про четыре метода интерфейса, про разницу WithCancel/WithTimeout/WithDeadline, про anti-pattern WithValue для параметров, про propagation в HTTP/SQL/gRPC, про leak без cancel().

  1. Зачем нужен context
  2. Базовое API
  3. Под капотом
  4. Использование в практике
  5. Тонкие моменты / Gotchas
  6. Best practices
  7. Типичные вопросы на собесе
  8. Practice
  9. Источники

Представьте HTTP-сервис: пришёл запрос, запустилось 10 горутин (запросы в БД, в кеш, в внешние API). Клиент закрыл соединение через 500 ms. Что должно произойти?

  • Все 10 горутин должны остановиться (зачем тратить ресурсы на ответ, который никто не прочтёт?).
  • Открытые соединения с БД — освобождены.
  • Память — освобождена.

Без context.Context это невозможно реализовать идиоматично. Нужен механизм:

  1. Сигнала “пора отменять” — распространяющийся по всему дереву вызовов.
  2. Deadline — “не дольше N ms”.
  3. Request-scoped значенийrequest_id, user_id, trace_id для логов.

Context решает всё это в одном интерфейсе.


type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}

Четыре метода:

  • Deadline() — возвращает время дедлайна (или ok=false, если без дедлайна).
  • Done() — возвращает канал, закрывающийся, когда context отменён. (close → broadcast всем listeners.)
  • Err()nil, если ещё активен; context.Canceled или context.DeadlineExceeded — после отмены.
  • Value(key) — request-scoped значение по ключу.
ctx := context.Background() // корневой, никогда не отменяется
ctx := context.TODO() // "пока не знаю, какой нужен"
  • Background — для main, init, top-level handlers. Стандартный корень.
  • TODO — семантически “нужно подумать, что сюда положить”. Используется как placeholder в legacy-коде, когда добавляют context, но ещё не пробросили из caller-а. Linter может предупреждать о TODO.

Технически Background() и TODO() идентичны.

ctx, cancel := context.WithCancel(parent)
defer cancel() // ВАЖНО!
go func() {
select {
case <-ctx.Done():
return // ctx.Err() = context.Canceled
case <-time.After(time.Hour):
// ...
}
}()
cancel() // явная отмена: закрывает ctx.Done()

cancel() — функция, отменяющая context. Идемпотентна (можно вызывать многократно). Обязательно вызвать (через defer), иначе утечка ресурсов (timer-ов внутри, дочерних контекстов).

ctx, cancel := context.WithTimeout(parent, 5*time.Second)
defer cancel()
// ctx отменится автоматически через 5 секунд
// (или раньше, если parent отменится).

WithTimeout(parent, d) = WithDeadline(parent, time.Now().Add(d)).

deadline := time.Now().Add(30 * time.Second)
ctx, cancel := context.WithDeadline(parent, deadline)
defer cancel()

То же, что timeout, но указываете абсолютное время.

⚠️ Если deadline раньше parent’s deadline — наш ctx отменится раньше. Если позже — игнорируется (parent ограничивает).

type ctxKey struct{} // приватный тип ключа
ctx := context.WithValue(parent, ctxKey{}, "user-123")
v := ctx.Value(ctxKey{}).(string) // "user-123"

⚠️ Anti-pattern для передачи обычных параметров функции! Используйте WithValue только для:

  • request-scoped values (request ID, user info, trace ID).
  • Метаданных, “просвечивающих” сквозь много слоёв (нельзя протащить аргументом).

НЕ кладите в context:

  • Бизнес-параметры (имя пользователя для логина).
  • Зависимости (database connection).
  • Optional flags функции.
detached := context.WithoutCancel(ctx)

Возвращает context, который не отменяется, когда parent отменён. Сохраняет только values parent-а. Use case: фоновая задача, которую нужно дописать даже после ответа клиенту.

func handler(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
writeResponse(ctx, w)
// Запускаем фоновую задачу — она НЕ должна отмениться,
// когда client закроет соединение.
bgCtx := context.WithoutCancel(ctx)
go saveAnalytics(bgCtx)
}
ctx, cancel := context.WithCancelCause(parent)
cancel(errors.New("user logged out"))
// context.Cause(ctx) вернёт "user logged out"
// ctx.Err() всё равно вернёт context.Canceled

Позволяет указать причину отмены, доступную через context.Cause(ctx). Полезно для диагностики (логи, метрики).

stop := context.AfterFunc(ctx, func() {
log.Println("ctx canceled, cleanup")
})
// stop() отменяет регистрацию (если хочется)

Регистрирует функцию-callback, которая вызовется, когда ctx отменится. Полезно для cleanup-логики (вместо отдельной горутины с <-ctx.Done()).


Контексты образуют дерево. Корень — Background(). Каждый WithCancel/WithTimeout/... создаёт дочерний context. Отмена parent → отмена всех children (рекурсивно).

Background
└── WithTimeout(30s) [server handler]
├── WithCancel [DB query 1]
├── WithCancel [DB query 2]
└── WithTimeout(5s) [external API]
└── WithCancel [retry attempt]

Если “server handler” отменится — отменяются все потомки. Если “external API” отменится — потомок “retry attempt” тоже.

type cancelCtx struct {
Context // wrapped parent
mu sync.Mutex
done atomic.Value // chan struct{}, lazily created
children map[canceler]struct{} // set of children
err error // = ctx.Err()
cause error // = context.Cause()
}

Когда вызывается cancel:

  1. Берётся mu.
  2. Ставится err = Canceled (или DeadlineExceeded).
  3. Закрывается канал done (broadcast: все, кто <-ctx.Done(), проснутся).
  4. Рекурсивно вызывается cancel у всех children.
  5. Отвязывается от parent (parent больше не держит ссылку → GC).
  6. Release mu.

WithTimeout — это cancelCtx + time.Timer:

type timerCtx struct {
cancelCtx
timer *time.Timer
deadline time.Time
}

При создании запускается time.AfterFunc(deadline-now, cancel). Если cancel вызван вручную раньше — таймер останавливается.

⚠️ Если не вызвать cancel(), таймер останется в heap до своего срабатывания, удерживая context и его children. Это утечка (короткая, но всё же).

type valueCtx struct {
Context
key, val any
}
func (c *valueCtx) Value(key any) any {
if c.key == key {
return c.val
}
return c.Context.Value(key) // рекурсия вверх по дереву
}

Каждый WithValue оборачивает parent. Value(k) ищет снизу вверх:

ctx5 (key=A) -> ctx4 (key=B) -> ctx3 (key=C) -> Background

ctx5.Value(C) пройдёт всю цепочку. Поэтому много WithValue = медленный Value() (O(depth)).

⚠️ Не используйте WithValue в hot path для частого Value().

type emptyCtx int
// Все методы возвращают nil/zero.
var background = new(emptyCtx)
var todo = new(emptyCtx)
func Background() Context { return background }
func TODO() Context { return todo }

Это константные объекты, не отменяются, не имеют values. Никогда не передавайте nil вместо context — это panic в любой имплементации.

Отмена распространяется через закрытие канала и рекурсивный обход children:

// псевдокод
func (c *cancelCtx) cancel(removeFromParent bool, err, cause error) {
c.mu.Lock()
c.err = err
c.cause = cause
close(c.done.Load().(chan struct{})) // ← broadcast
for child := range c.children {
child.cancel(false, err, cause) // ← рекурсия
}
c.children = nil
c.mu.Unlock()
if removeFromParent {
removeChild(c.Context, c)
}
}

После cancel — все горутины, ждущие на <-ctx.Done(), сразу разблокируются.


http.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() // отменяется, когда клиент закроет соединение
result, err := fetchData(ctx)
if err != nil {
if ctx.Err() != nil {
return // клиент ушёл, ничего не пишем
}
http.Error(w, err.Error(), 500)
return
}
json.NewEncoder(w).Encode(result)
})

В r.Context():

  • Отменяется, когда client закрывает TCP-соединение.
  • Отменяется, когда server делает shutdown.
  • Содержит values, добавленные middleware.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) {
// таймаут
}
return err
}
defer resp.Body.Close()

http.NewRequestWithContext привязывает context к запросу — отмена прервёт TCP-соединение.

ctx, cancel := context.WithTimeout(r.Context(), 100*time.Millisecond)
defer cancel()
rows, err := db.QueryContext(ctx, "SELECT * FROM users WHERE id=?", id)
if err != nil {
return err
}
defer rows.Close()

Если таймаут истёк — драйвер отменит запрос на стороне БД (если БД поддерживает; PostgreSQL — да).

ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
resp, err := client.SayHello(ctx, &pb.HelloRequest{Name: "world"})

gRPC автоматически сериализует deadline в HTTP/2 заголовок grpc-timeout и передаёт на сервер. Сервер видит дедлайн в ctx.

func main() {
srv := &http.Server{Addr: ":8080", Handler: mux}
go func() {
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatal(err)
}
}()
// ждём SIGINT/SIGTERM
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Println("shutdown error:", err)
}
}

Shutdown(ctx) отказывает в новых connection-ах, ждёт окончания текущих, отменяет их через ctx по таймауту.

type ctxKey string
const reqIDKey ctxKey = "request_id"
func withReqID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := uuid.New().String()
ctx := context.WithValue(r.Context(), reqIDKey, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func getReqID(ctx context.Context) string {
id, _ := ctx.Value(reqIDKey).(string)
return id
}
func handler(w http.ResponseWriter, r *http.Request) {
log.Printf("[%s] processing", getReqID(r.Context()))
}
func process(ctx context.Context, items []Item) error {
g, ctx := errgroup.WithContext(ctx)
for _, item := range items {
item := item
g.Go(func() error {
return handleItem(ctx, item)
})
}
return g.Wait()
}

errgroup + context — стандартный паттерн для параллельной обработки с отменой.


ctx, _ := context.WithTimeout(parent, time.Second)
// забыли defer cancel()

⚠️ Утечка ресурсов:

  • timer внутри ctx остаётся жить до своего срабатывания (1 sec).
  • ctx удерживается в children parent-а — не GC-ится.
  • Если в ctx есть горутины — они работают до тайаута, а могли бы быть остановлены раньше.

go vet ловит этот случай (-vet=lostcancel).

fetchData(nil) // ⚠️ panic в любой операции с ctx

Никогда не передавайте nil. Если нет контекста — используйте context.Background() или context.TODO().

type Server struct {
ctx context.Context // ⚠️ не идиоматично!
}

Официальная рекомендация — не класть context в struct. Передавайте как первый аргумент:

func (s *Server) Process(ctx context.Context, req Request) Response { ... }

Исключение: request-scoped структуры (живущие на один запрос), где context — часть state-а запроса:

type RequestProcessor struct {
ctx context.Context
request *http.Request
}

Но даже в этом случае контекст обычно прячут от пользователя API.

Если использовать string как ключ — коллизия с другими пакетами:

ctx = context.WithValue(ctx, "user", u)
// внутри другого пакета:
ctx = context.WithValue(ctx, "user", differentU) // override!

Фикс: приватный тип:

type userKey struct{}
ctx = context.WithValue(ctx, userKey{}, u)
// в другом пакете userKey недоступен → нет коллизии
v := ctx.Value(key) // O(depth) — обход дерева снизу вверх

Если делаете это много раз в hot path — кешируйте локально:

user := getUserFromCtx(ctx) // один раз
for _, item := range items {
process(user, item)
}
ctx, cancel := context.WithCancel(parent)
go worker(ctx)
cancel() // worker уже МОЖЕТ ЕЩЁ работать, cancel не блокирует

cancel() — это сигнал. Чтобы дождаться окончания горутины — sync.WaitGroup или возврат канала.

5.7. Не используйте context для контроля логики

Заголовок раздела «5.7. Не используйте context для контроля логики»
func bad(ctx context.Context) {
if ctx.Value("admin") == true { // ⚠️ бизнес-логика через ctx
doAdminStuff()
}
}

Это anti-pattern. Бизнес-параметры передавайте как аргументы:

func good(ctx context.Context, isAdmin bool) {
if isAdmin {
doAdminStuff()
}
}
select {
case <-ctx.Done():
return ctx.Err() // безопасно вернуть err
default:
}
// vs
if ctx.Err() != nil {
return ctx.Err() // тоже ок, но без блокировки
}

После <-ctx.Done() метод ctx.Err() гарантированно вернёт Canceled или DeadlineExceeded.

// ⚠️ Anti-pattern:
ctx = context.WithValue(ctx, "limit", 100)
ctx = context.WithValue(ctx, "offset", 200)
queryUsers(ctx)
// ✅ Идиома:
queryUsers(ctx, QueryOpts{Limit: 100, Offset: 200})
ctx, cancel := context.WithCancel(parent)
go func() {
cancel() // race? нет — атомарно с close(done)
}()
<-ctx.Done() // безопасно

cancel() и <-ctx.Done() синхронизированы через close(done). Не race.


// ✅
func Process(ctx context.Context, items []Item) error { ... }
// ❌
func Process(items []Item, ctx context.Context) error { ... }

Линтер revive поймает.

// ❌
Process(nil, items)
// ✅
Process(context.Background(), items)
ctx, cancel := context.WithTimeout(parent, time.Second)
defer cancel()

Всегда. Даже если ctx отменится сам по таймауту — лишний cancel не сделает плохо (идемпотентно).

Context — request-scoped, недолгоживущий. Не кладите в long-living struct.

// ❌ Если ctx никогда не отменяется — leak.
<-ctx.Done()
// ✅
select {
case <-ctx.Done():
return
case msg := <-input:
handle(msg)
}

Но в main или top-level handler <-ctx.Done() — это норма (явное ожидание сигнала).

// ❌
rows, _ := db.Query("...") // без ctx
// ✅
rows, _ := db.QueryContext(ctx, "...")

Аналогично для HTTP клиента (NewRequestWithContext).

type ctxKey struct{ name string }
var userKey = ctxKey{"user"}
func WithUser(ctx context.Context, u *User) context.Context {
return context.WithValue(ctx, userKey, u)
}
func UserFrom(ctx context.Context) (*User, bool) {
u, ok := ctx.Value(userKey).(*User)
return u, ok
}

Скрывайте WithValue/Value за функциями. Это делает API чище и type-safe.

if errors.Is(err, context.Canceled) { ... }
if errors.Is(err, context.DeadlineExceeded) { ... }

Не сравнивайте err == context.Canceled — может быть обёртка через fmt.Errorf("%w", err).


  1. Что такое context.Context? Какие у него методы? Интерфейс с 4 методами: Deadline, Done, Err, Value.

  2. Чем отличается Background от TODO? Технически одинаковы. Background — стандартный корень. TODO — placeholder, когда не решили, какой нужен.

  3. Что делает WithCancel? Возвращает child-context и функцию cancel. cancel закрывает Done канал.

  4. Чем WithTimeout отличается от WithDeadline? Timeout — длительность от now. Deadline — абсолютное время. WithTimeout(d) = WithDeadline(now+d).

  5. Зачем вызывать cancel(), если ctx сам отменится по таймауту? Освободить ресурсы (timer, child entries в parent) раньше. Иначе утечка до срабатывания таймера.

  6. Что произойдёт, если не вызвать cancel? Утечка: timer не остановится, ctx останется в children parent-а, GC не соберёт.

  7. Когда использовать WithValue? Только для request-scoped значений (request ID, user, trace ID). НЕ для бизнес-параметров.

  8. Какой тип использовать для ключа WithValue? Приватный тип в вашем пакете (например, type ctxKey struct{}) для избежания коллизий.

  9. Можно ли передать nil вместо context? Нет, panic. Используйте context.Background() или context.TODO().

  10. Что вернёт ctx.Err() до cancel? nil.

  11. Что вернёт ctx.Err() после cancel? context.Canceled или context.DeadlineExceeded.

  12. Распространяется ли отмена parent на children? Да, рекурсивно. Все children закрывают свой Done канал.

  13. Распространяется ли отмена child на parent? Нет. Cancel дочернего не отменяет parent.

  14. Почему context — первый аргумент? Конвенция, делает API единообразным, линтеры это проверяют.

  15. Можно ли хранить context в struct? Официально — нет. Исключение: request-scoped структуры. Лучше передавать аргументом.

  16. Что такое context.WithoutCancel? Go 1.21+. Возвращает ctx с values от parent, но не отменяется при отмене parent.

  17. Что такое context.Cause? Возвращает причину отмены, переданную в WithCancelCause/WithDeadlineCause. ctx.Err() возвращает обобщённую ошибку, Cause — конкретную.

  18. Как происходит propagation cancel в дереве контекстов? cancel() закрывает Done канал и рекурсивно вызывает cancel у всех children, отвязывается от parent.

  19. Как работает Value() — за какое время? O(depth) — обход дерева снизу вверх до Background.

  20. Чем context.AfterFunc отличается от <-ctx.Done() в горутине? AfterFunc регистрирует callback без отдельной горутины — экономнее. Доступен с Go 1.21.


Напишите функцию fetch(ctx, url) (string, error), которая делает HTTP GET, возвращает body. Должна корректно отменяться при ctx.Done().

func fetch(ctx context.Context, url string) (string, error) {
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil { return "", err }
resp, err := http.DefaultClient.Do(req)
if err != nil { return "", err }
defer resp.Body.Close()
data, err := io.ReadAll(resp.Body)
return string(data), err
}
// Использование:
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
body, err := fetch(ctx, "https://example.com")

Напишите worker pool из 3 горутин, обрабатывающий jobs из канала. Должен корректно завершиться при <-ctx.Done().

Реализуйте WithUserID(ctx, id) / UserIDFrom(ctx) (string, bool) с приватным типом ключа.

Напишите HTTP server, который при SIGINT начинает graceful shutdown с таймаутом 10 секунд. Все handler-ы должны видеть отменённый ctx.

ctxA, _ := context.WithTimeout(context.Background(), 10*time.Second)
ctxB, _ := context.WithTimeout(ctxA, 30*time.Second) // ⚠️ что произойдёт?

Ответ: ctxB отменится через 10 секунд (вместе с parent), а не через 30. Дочерний не может пережить родителя.

Используя golang.org/x/sync/errgroup, реализуйте параллельную обработку списка URL-ов. Если один упал — отменить все остальные.

g, ctx := errgroup.WithContext(parent)
for _, u := range urls {
u := u
g.Go(func() error {
return process(ctx, u)
})
}
if err := g.Wait(); err != nil {
// отменены все, err — первая ошибка
}
func startWorker() {
ctx, _ := context.WithTimeout(context.Background(), time.Hour)
go func() {
for {
select {
case <-ctx.Done():
return
case <-time.After(time.Second):
work()
}
}
}()
}

Проблема: cancel не вызывается. Через час ctx отменится, горутина завершится. До этого момента — таймер удерживается. Если startWorker вызывается часто — память растёт.

Фикс: хранить cancel и вызывать при остановке worker-а.


  1. context пакетhttps://pkg.go.dev/context — официальная документация.
  2. Go Concurrency Patterns: Context — Sameer Ajmani, Go Blog: https://go.dev/blog/context
  3. context.WithoutCancel proposalhttps://github.com/golang/go/issues/40221 (Go 1.21).
  4. context sourcehttps://github.com/golang/go/blob/master/src/context/context.go — небольшой и читабельный.
  5. Context isn’t for cancellation — Dave Cheney: https://dave.cheney.net/2017/01/26/context-is-for-cancelation — критика и идеи.
  6. errgrouphttps://pkg.go.dev/golang.org/x/sync/errgroup — must-have для параллельной обработки.