Shanraq.org Shanraq.org
`context`: вовремя остановить запрос
IT

Go: с нуля до своего блога Урок 43 из 50

`context`: вовремя остановить запрос

Сорок третий урок курса по Go. Читатель закрыл вкладку — и обработчик узнаёт об этом сам: измерено, как он бросает работу через двести миллисекунд. Срок на запрос к базе, ключ своего типа вместо строки и обещанный переезд вошедшего человека в контекст запроса.

Зачем это нужно

Обработчик из прошлых уроков считает, что читатель ждёт. Читатель — не всегда.

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

Есть и обратная беда: запрос к базе, который завис. Без срока он держит соединение из пула, потом второе, потом все — и блог перестаёт отвечать всем из-за одного медленного запроса.

Обе беды решает одна вещь — контекст.

Сразу целиком

Новая папка, go mod init sabaq40:

package main

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"log"
	"net/http"
	"net/http/httptest"
	"time"

	_ "modernc.org/sqlite"
)

func main() {
	db, err := sql.Open("sqlite", ":memory:")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()

	fmt.Println("== читатель закрыл вкладку")
	cancelled()

	fmt.Println()
	fmt.Println("== запрос, которому дали полсекунды")
	fmt.Println("быстрый:  ", count(db, 100*1000, 500*time.Millisecond))
	fmt.Println("медленный:", count(db, 100*1000*1000, 500*time.Millisecond))
}

// cancelled поднимает медленный обработчик и обрывает запрос на середине.
func cancelled() {
	done := make(chan string, 1)

	srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		select {
		case <-time.After(2 * time.Second):
			done <- "доработал до конца"
		case <-r.Context().Done():
			// Контекст запроса закрывается сам, когда читатель ушёл.
			done <- fmt.Sprintf("бросил работу через %s: %v",
				time.Since(start).Round(10*time.Millisecond), r.Context().Err())
		}
	}))
	defer srv.Close()

	// Клиент, который сдаётся через двести миллисекунд.
	ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
	defer cancel()

	req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL, nil)
	if _, err := http.DefaultClient.Do(req); err != nil {
		fmt.Println("читатель:  ушёл —", context.Cause(ctx))
	}
	fmt.Println("обработчик:", <-done)
}

// count считает в базе долго и намеренно: рекурсивный запрос на n шагов.
func count(db *sql.DB, n int, limit time.Duration) string {
	ctx, cancel := context.WithTimeout(context.Background(), limit)
	defer cancel()

	start := time.Now()
	var got int
	err := db.QueryRowContext(ctx, `with recursive nums(i) as (
		select 1 union all select i + 1 from nums where i < ?
	) select count(*) from nums`, n).Scan(&got)

	switch {
	case errors.Is(err, context.DeadlineExceeded):
		return fmt.Sprintf("оборвали за %s: %v", time.Since(start).Round(10*time.Millisecond), err)
	case err != nil:
		return "ошибка: " + err.Error()
	}
	return fmt.Sprintf("посчитали %d за %s", got, time.Since(start).Round(10*time.Millisecond))
}

Вывод:

== читатель закрыл вкладку
читатель:  ушёл — context deadline exceeded
обработчик: бросил работу через 200ms: context canceled

== запрос, которому дали полсекунды
быстрый:   посчитали 100000 за 60ms
медленный: оборвали за 500ms: context deadline exceeded

Разбор

Контекст запроса закрывается сам

У каждого запроса есть свой контекст: r.Context(). Его никто не создаёт руками — его делает http.Server, и он закрывается сам, когда соединение с читателем пропало.

Обработчику остаётся его слушать:

select {
case <-time.After(2 * time.Second):
	// доработали
case <-r.Context().Done():
	// читатель ушёл
}

Done() — канал, который закрывается в момент отмены. В выводе видно: читатель сдался через двести миллисекунд, и ровно через двести миллисекунд обработчик бросил двухсекундную работу.

Err() говорит почему: context canceled — отменили; context deadline exceeded — вышел срок.

Образ. Официант, которому сказали, что гость ушёл. Он не понесёт горячее к пустому столу и не будет ждать, пока оно остынет, — он развернётся на полпути.

Срок на работу: WithTimeout

Вторая половина вывода — про другое: не читатель ушёл, а работа затянулась.

ctx, cancel := context.WithTimeout(context.Background(), limit)
defer cancel()

WithTimeout делает новый контекст, который отменится сам через заданное время. Быстрый запрос успел за семьдесят миллисекунд, медленный оборвали ровно на пятистах.

defer cancel() обязателен, и не для красоты: WithTimeout заводит таймер, и cancel его отпускает. Забыли — таймер живёт до срока, и на нагруженном сервере такие забытые таймеры копятся. Go на это ругается при проверке кода, и правильно делает.

Заметьте, откуда взялся родитель: context.Background() — «пустой» контекст, корень цепочки. В обработчике родителем должен быть r.Context(), а не Background: тогда отмена читателем оборвёт и запрос к базе тоже.

Почему у database/sql есть ...Context

Теперь понятно, зачем в базе методы-двойники: QueryContext, QueryRowContext, ExecContext.

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

Правило простое: в обработчике всегда ...Context, и всегда с r.Context(). Обычные версии остаются для скриптов и миграций, где отменять некому.

Контекст передают первым аргументом

func (s *Store) Get(ctx context.Context, slug string) — так выглядит любая функция, которая может ждать.

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

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

Значение в контексте и ключ своего типа

Контекст умеет носить значения — и здесь легко сделать плохо:

package main

import (
	"context"
	"fmt"
)

// Свой тип для ключа — не прихоть. Значение в контексте ищут по ключу, и ключ
// сравнивается вместе с типом: userKey(0) и другой тип с тем же нулём — разные
// ключи, а вот две строки "user" из разных пакетов — один и тот же.
type userKey struct{}

type otherKey struct{}

func main() {
	ctx := context.Background()

	fmt.Println("== строковые ключи")
	ctx = context.WithValue(ctx, "user", "айгуль")     //lint:ignore SA1029 нарочно
	ctx = context.WithValue(ctx, "user", "библиотека") //lint:ignore SA1029 нарочно
	fmt.Println("что осталось:", ctx.Value("user"))

	fmt.Println("== свои типы")
	ctx = context.WithValue(ctx, userKey{}, "айгуль")
	ctx = context.WithValue(ctx, otherKey{}, "библиотека")
	fmt.Println("наше:  ", ctx.Value(userKey{}))
	fmt.Println("чужое: ", ctx.Value(otherKey{}))
}
== строковые ключи
что осталось: библиотека
== свои типы
наше:   айгуль
чужое:  библиотека

Первая пара строк: два WithValue со строковым ключом "user" — и наше значение исчезло, осталось чужое. Так и происходит в жизни: ваш ключ "user" и ключ "user" из чужой библиотеки — один и тот же ключ, и кто записал последним, тот и прав.

Вторая пара: ключи своих типов. Ключ сравнивается вместе с типом, а тип, объявленный в вашем пакете, повторить снаружи невозможно. Оба значения на месте.

Поэтому ключ всегда объявляют своим типом, а доступ к значению прячут за двумя маленькими функциями — withUser и userFrom, — чтобы снаружи никто не знал ни ключа, ни того, что там вообще контекст.

Вошедший переезжает в контекст

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

func withUser(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if u, ok := currentUser(r); ok {
			r = r.WithContext(context.WithValue(r.Context(), userKey{}, u))
		}
		next.ServeHTTP(w, r)
	})
}

Обёртка ищет человека один раз и кладёт находку в контекст запроса. Дальше любой обработчик достаёт её функцией userFrom(r.Context()) — без похода в базу и без повторения кода.

r.WithContext возвращает копию запроса с новым контекстом: сам запрос неизменяем. Отсюда r = r.WithContext(...) — забудете присвоить, и обёртка не сделает ничего, ровно как с append из урока про срезы.

Чего в контекст не кладут

Контекст — для того, что относится к запросу целиком: отмена, срок, вошедший человек, номер запроса для журнала, язык страницы.

Не для параметров функции. Если функции нужен slug, его передают аргументом, а не прячут в контекст: аргумент виден в сигнатуре, а значение из контекста — нет, и его отсутствие обнаружится только на работающем сервере.

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

Карта урока

Карта урока: отмена сверху вниз по цепочке

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Кто и когда закрывает r.Context()?
  2. Почему ключом в контексте не берут строку?
  3. Почему defer cancel() обязателен даже там, где работа успела закончиться?

Задание

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

Всё это сделано в step-28 — сверьтесь после того, как напишете сами.

По желанию.

  • Дайте всем запросам к базе срок в три секунды через http.TimeoutHandler или свою обёртку и проверьте, что медленный запрос обрывается.
  • Добавьте в журнал причину: context.Cause(ctx) расскажет точнее, чем Err().
  • Уберите defer cancel() и посмотрите, что скажет go vet.

Куда это встанет в блоге

Блог перестаёт работать в пустоту: ушёл читатель — остановились и мы. А вошедшего человека больше не ищут в каждом обработчике.

Долги. Сроков на запросы к базе пока нет нигде, кроме примера. Фоновая работа из прошлого урока не умеет останавливаться по контексту. И плавной остановки сервера, при которой недоделанные запросы доживают свои секунды, тоже пока нет — это урок про деплой.

Ответы

Показать ответы
  1. Его закрывает http.Server, когда соединение с читателем оборвалось — вкладку закрыли, связь пропала, запрос отменили. Руками его никто не создаёт и не закрывает.
  2. Потому что ключ сравнивается по значению и типу, а строка "user" из вашего пакета и такая же строка из чужой библиотеки — один и тот же ключ. Значения затирают друг друга молча. Свой тип повторить снаружи нельзя.
  3. Потому что WithTimeout заводит таймер, и cancel его освобождает. Без этого таймер доживёт до конца срока, а на нагруженном сервере такие таймеры копятся.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.