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.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Кто и когда закрывает
r.Context()? - Почему ключом в контексте не берут строку?
- Почему
defer cancel()обязателен даже там, где работа успела закончиться?
Задание
Обязательное. Переведите блог на контекст: методы хранилища принимают ctx первым аргументом и зовут ...Context, обработчики передают r.Context(), а вошедшего человека находит обёртка и кладёт в контекст под ключом своего типа.
Всё это сделано в step-28 — сверьтесь после того, как напишете сами.
По желанию.
- Дайте всем запросам к базе срок в три секунды через
http.TimeoutHandlerили свою обёртку и проверьте, что медленный запрос обрывается. - Добавьте в журнал причину:
context.Cause(ctx)расскажет точнее, чемErr(). - Уберите
defer cancel()и посмотрите, что скажетgo vet.
Куда это встанет в блоге
Блог перестаёт работать в пустоту: ушёл читатель — остановились и мы. А вошедшего человека больше не ищут в каждом обработчике.
Долги. Сроков на запросы к базе пока нет нигде, кроме примера. Фоновая работа из прошлого урока не умеет останавливаться по контексту. И плавной остановки сервера, при которой недоделанные запросы доживают свои секунды, тоже пока нет — это урок про деплой.
Ответы
Показать ответы
- Его закрывает
http.Server, когда соединение с читателем оборвалось — вкладку закрыли, связь пропала, запрос отменили. Руками его никто не создаёт и не закрывает. - Потому что ключ сравнивается по значению и типу, а строка
"user"из вашего пакета и такая же строка из чужой библиотеки — один и тот же ключ. Значения затирают друг друга молча. Свой тип повторить снаружи нельзя. - Потому что
WithTimeoutзаводит таймер, иcancelего освобождает. Без этого таймер доживёт до конца срока, а на нагруженном сервере такие таймеры копятся.
Источники
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.