Irbisa · Шпаргалка

Context и отмена

Дерево контекстов, таймауты, дедлайны, значения, распространение отмены

Middle-разработчик · 21 вопрос

  1. 01

    Что такое context.Context в Go, для чего он нужен и какие конструкторы есть в пакете context?

    context.Context переносит сигнал отмены, необязательный дедлайн и значения уровня запроса через границы API и горутин, чтобы всё дерево вызовов одного запроса можно было остановить разом.

    func handle(w http.ResponseWriter, r *http.Request) {	// A child of the request context with a 2 s budget.	ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)	defer cancel() 	rows, err := db.QueryContext(ctx, "SELECT id FROM orders WHERE user_id = $1", userID(r))…
  2. 02

    Чем context.Background() отличается от context.TODO()?

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

  3. 03

    Что выведет эта программа и что она показывает о том, как отмена проходит между родительским и дочерним контекстом?

    func main() {	parent, cancelParent := context.WithCancel(context.Background())	child, cancelChild := context.WithCancel(parent) 	cancelChild()	fmt.Println(parent.Err(), child.Err()) 	child2, cancelChild2 := context.WithTimeout(parent, time.Hour)	defer cancelChild2() 	cancelParent()	fmt.Println(parent.Err(), child2.Err())}

    Она выведет <nil> context canceled, затем context canceled context canceled: отмена потомка никак не затрагивает родителя, а отмена родителя отменяет все контексты, производные от него.

  4. 04

    Чем ctx.Done() отличается от ctx.Err() и какие ошибки может вернуть Err()?

    Done() возвращает канал, который закрывается, когда контекст завершился, и его можно ждать в select; Err() говорит, почему он завершился: context.Canceled или context.DeadlineExceeded, а пока контекст жив, возвращает nil.

    func worker(ctx context.Context, jobs <-chan Job) error {	for {		select {		case <-ctx.Done():			return ctx.Err() // context.Canceled or context.DeadlineExceeded		case j := <-jobs:…
  5. 05

    Почему функцию cancel, которую вернули context.WithTimeout или context.WithCancel, нужно вызывать всегда?

    cancel освобождает то, что держит производный контекст: снимает регистрацию потомка в родителе и останавливает его таймер.

    // wrong: vet (lostcancel) reports the discarded cancelfunc fetchBad(parent context.Context, id string) (*User, error) {	ctx, _ := context.WithTimeout(parent, time.Second)	return get(ctx, id)}…
  6. 06

    Почему context.Context передают первым параметром функции, а не хранят в структуре, и почему нельзя передавать nil?

    Контекст принадлежит одному вызову, а не объекту: первым параметром ctx он делает время жизни каждого вызова явным, а сохранённый в структуре привязывает все последующие вызовы к дедлайну, отмене и значениям того запроса, который эту структуру создал.

  7. 07

    Останавливает ли отмена контекста горутину, которая его использует? Что должна делать сама горутина?

    Нет. Отмена лишь закрывает канал Done() и выставляет Err(); убить горутину снаружи в Go нельзя, поэтому она останавливается, только когда её собственный код замечает сигнал и выходит.

    // wrong: time.Sleep ignores ctx, so the loop runs on after cancelfunc pollBad(ctx context.Context) {	for {		check()		time.Sleep(10 * time.Second)	}…
  8. 08

    В каких случаях использовать context.WithCancel, когда выбирать context.WithTimeout, а когда context.WithDeadline?

    WithCancel берут, когда момент остановки решает ваш собственный код, WithTimeout подходит, когда операции выделяют относительный бюджет («не больше 2 с»), а WithDeadline нужен, когда у вас уже есть абсолютный момент времени, общий для нескольких шагов.

  9. 09

    Слой репозитория оборачивает каждый запрос к базе в context.WithTimeout(ctx, 5*time.Second), но в продакшене запросы обрываются через 300 мс. Откуда берутся 300 мс?

    От родителя: у входящего запроса оставалось всего 300 мс, а производный контекст никогда не живёт дольше родителя; WithTimeout фактически оставляет более ранний из двух дедлайнов.

    func (r *Repo) Load(ctx context.Context, id int64) (*Row, error) {	// A ceiling on what is left, not a grant of five seconds.	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)	defer cancel() 	if dl, ok := ctx.Deadline(); ok && time.Until(dl) < 20*time.Millisecond {…
  10. 10

    Поиск в кэше в коде ниже должен получать 100 мс, а база должна получать до 2 с, но h.db.Load падает с context deadline exceeded уже через 100 мс. Почему?

    func (h *Handler) Get(ctx context.Context, id string) (*Page, error) {	ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)	defer cancel() 	if p, err := h.cache.Get(ctx, id); err == nil {		return p, nil	}	return h.db.Load(ctx, id) // the query needs about 300 ms}

    ctx, cancel := context.WithTimeout(ctx, ...) перепривязывает ctx, поэтому запрос к базе получает стомиллисекундный контекст кэша, а ни один вызов не получит больше времени, чем есть у переданного ему контекста.

  11. 11

    Что выведет эта программа и как объявлять ключи контекста, чтобы два пакета не могли столкнуться?

    type key string func main() {	ctx := context.WithValue(context.Background(), key("user"), "alice")	ctx = context.WithValue(ctx, "user", "bob") 	fmt.Println(ctx.Value("user"))	fmt.Println(ctx.Value(key("user")))	fmt.Println(ctx.Value("tenant"))}

    Она выведет bob, alice, <nil>: Value идёт вверх по цепочке и сравнивает ключи через ==, а ключи разных типов никогда не равны, даже если в них одинаковый текст.

  12. 12

    Как ретраи и обработка ошибок должны по-разному относиться к context.Canceled и context.DeadlineExceeded?

    Canceled значит, что вызывающий ушёл: остановитесь сразу и не считайте это сбоем; DeadlineExceeded значит, что работа шла слишком медленно (это ваша проблема латентности), и ретрай имеет смысл, только пока у общего контекста ещё есть время.

    func retry(ctx context.Context, attempts int, f func(context.Context) error) error {	var err error	for i := range attempts {		actx, cancel := context.WithTimeout(ctx, time.Second) // per-attempt budget		err = f(actx)		cancel()…
  13. 13

    Найдите баг: куча этого воркера очереди растёт всё время, пока он работает, хотя каждая задача выполняется за миллисекунды.

    func (w *Worker) Run(ctx context.Context) error {	for {		select {		case <-ctx.Done():			return ctx.Err()		case job := <-w.jobs:			jobCtx, cancel := context.WithTimeout(ctx, 30*time.Second)			defer cancel()			if err := w.handle(jobCtx, job); err != nil {				slog.ErrorContext(ctx, "job failed", "id", job.ID, "err", err)			}		}	}}

    defer cancel() выполняется, когда возвращается Run, а не в конце итерации; а Run возвращается только при остановке, поэтому каждая задача оставляет отложенный вызов, который держит её контекст и таймер, и куча растёт вместе с числом обработанных задач.

  14. 14

    Что происходит с r.Context(), когда HTTP-клиент отключается, и как сделать так, чтобы исходящие вызовы хендлера остановились вместе с ним?

    net/http отменяет контекст запроса, когда закрывается соединение клиента, когда сбрасывается HTTP/2-стрим или когда возвращается ServeHTTP, но останавливается только та работа, которой этот контекст передали.

    var client = &http.Client{} // shared; no Client.Timeout, which would cut long streams func proxy(w http.ResponseWriter, r *http.Request) {	// http.NewRequest would use context.Background() and ignore the disconnect.	req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstream+r.URL.Path, nil)	if err != nil {…
  15. 15

    Найдите баг: под нагрузкой число горутин в этом сервисе растёт каждый раз, когда апстрим начинает тормозить.

    func fetch(ctx context.Context, url string) ([]byte, error) {	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)	defer cancel()	ch := make(chan result)	go func() {		body, err := download(url) // legacy client: takes no context		ch <- result{body, err}	}() 	select {	case r := <-ch:		return r.body, r.err	case <-ctx.Done():		return nil, ctx.Err()	}}

    Канал небуферизованный, поэтому, когда в select побеждает таймаут, никто так и не читает из канала, и рабочая горутина навсегда блокируется на ch <-: по одной утёкшей горутине, со стеком и скачанным телом ответа, на каждый вызов, упавший по таймауту.

  16. 16

    Что делает context.WithoutCancel и когда он нужен?

    context.WithoutCancel(ctx) (Go 1.21) возвращает контекст, который сохраняет значения родителя, но никогда не отменяется и не имеет дедлайна; он нужен для работы, которая должна пережить запрос и при этом логироваться с его trace id и тенантом.

    func handler(w http.ResponseWriter, r *http.Request) {	// wrong: go audit.Write(r.Context(), ev) is cancelled when the handler returns 	// right: keep the values, drop the cancellation, add our own bound	ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 5*time.Second)	ev := eventFrom(r) // read r now, not after the handler has returned…
  17. 17

    Как пакет context устроен внутри: как отмена доходит до потомков и почему поиск в Value линейный?

    Каждый вызов With* оборачивает родителя в небольшую структуру, и контексты образуют связное дерево: отменяемый узел хранит множество своих потомков и при отмене закрывает свой канал и по очереди отменяет их, а Value просто опрашивает родителей вверх по цепочке.

  18. 18

    Каждая ошибка в fan-out сообщает только context canceled. Как сделать, чтобы отмена несла свою причину: какой шард упал, какой бюджет закончился?

    Используйте context.WithCancelCause (Go 1.20): cancel(err) запоминает причину, а context.Cause(ctx) возвращает её на этом контексте и на всех потомках, при этом ctx.Err() остаётся обычным context.Canceled, на который опираются существующие проверки.

    var ErrSearchBudget = errors.New("search budget exhausted") func search(parent context.Context, q string) ([]Hit, error) {	ctx, cancel := context.WithTimeoutCause(parent, 200*time.Millisecond, ErrSearchBudget)	defer cancel()…
  19. 19

    У стороннего клиента есть Close(), но он не принимает context.Context. Как прервать его по отмене, не паркуя по горутине на каждый вызов?

    Зарегистрируйте колбэк через context.AfterFunc(ctx, f) (Go 1.21): f запустится в своей горутине, когда ctx завершится, а до тех пор ничего не висит в ожидании.

    // right: a registered callback instead of a waiting goroutinefunc call(ctx context.Context, c *vendor.Client) error {	stop := context.AfterFunc(ctx, func() {		c.Close() // runs in its own goroutine when ctx ends	})	err := c.Run()…
  20. 20

    Отменяет ли srv.Shutdown(ctx) контексты запросов, которые ещё выполняются? Как заставить долгие хендлеры остановиться при завершении?

    Нет. Shutdown закрывает листенеры и простаивающие соединения, а затем ждёт, пока активные запросы завершатся; его ctx ограничивает только время ожидания, и когда он истекает, хендлеры продолжают работать с живым r.Context().

    func main() {	sigCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)	defer stop() 	base, cancelBase := context.WithCancel(context.Background())	defer cancelBase()…
  21. 21

    Сервис A даёт B 500 мс, но B вызывает C с фиксированным таймаутом 2 с, и C тратит CPU на запросы, которых уже никто не ждёт. Как передавать дедлайны между сервисами?

    Контекст сам по себе не пересекает сеть, поэтому каждый хоп должен отправлять оставшийся бюджет, а следующий должен превращать его обратно в дедлайн; тогда работа останавливается везде, как только исходный вызывающий сдался.

    const hopHeadroom = 50 * time.Millisecond // Caller: put the remaining time on the wire, never an absolute timestamp.func forward(ctx context.Context, req *http.Request) (*http.Response, error) {	if dl, ok := ctx.Deadline(); ok {		budget := time.Until(dl) - hopHeadroom…