Golang: context изнутри
- среда, 10 июля 2024 г. в 00:00:07
Ни для кого не секрет, что стандартный пакет context широко используется для создания ваших сервисов. В данный момент, не побоюсь этого слова, любой сервис написанный на Go использует контексты повсеместно. Мое мнение таково - если ты хочешь прогрессировать как специалист, ты должен копать все глубже и глубже. Предлагаю рассмотреть context с призмы его работы внутри.
Существует несколько типов context, с которыми Golang разработчику приходится сталкиваться. Давайте кратко пробежимся и потом вникнем в суть каждого. Но сначала обратим внимание на функции создания контекста.
context.Background() Context
- используется для создания корневого контекста и не может быть отменен
TODO() Context
- используется как заглушка, если вы еще не определили какой контекст вам нужен и вы его переопределите
WithCancel(parent Context) (ctx Context, cancel CancelFunc)
- создает дочерний контекст с методом отмены из родительского контекста, который может быть вызван вручную
WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
- создает дочерний контекст с помощью метода отмены из родительского контекста, за исключением того, что контекст будет автоматически отменен по достижении заданного времени
context.WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
- то же самое, что и WithDeadline
, за исключением того, что он указывает время ожидания от текущего времени
WithValue(parent Context, key, val any) Context
- создает дочерний контекст из родительского контекста, который может хранить пару ключ-значение и является контекстом и также его нельзя отменить
Само по себе определение Context лежит в пакете context и это является интерфейсом
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
И можно прочесть описание этого интерфейса
A Context carries a deadline, a cancellation signal, and other values across API boundaries. Context's methods may be called by multiple goroutines simultaneously.
Также можно отметить, что входящие запросы к серверу должны создавать контекст, а исходящие вызовы к серверам должны принимать его. Цепочка функций вызовов между ними должна распространять контекст, при необходимости заменяя его с производным контекстом, созданным с помощью WithCancel
, WithDeadline
, withTimeout
или withValue
. Когда контекст отменяется, все контексты, производные от него, также отменяются.
Что означает, что мы должны принимать context и далее по цепочке вызовов его переопределять, так как context.Background()
должен являться по сути начальным контекстом, где дальше идут ответвления.
Можно увидеть совет, который спрятан в описании пакета
Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx:
func DoSomething(ctx context.Context, arg Arg) error {
... use ctx ...
}
И если дословно перевести, то это означает, что не надо хранить контексты внутри структуры, лучше их передавать функции, которая нуждается в нем, также можно заметить примечание, что следует передавать его первым параметром и обычно называть его ctx. Обычно эта ошибка встречается у новичков, которые только входят в мир Golang, что очень сказывается на читаемости кода.
Ну и можно выделить совсем уж простой совет, что нужно передавать context только в области данных запроса, а не в функцию или метод необязательным параметром.
Сам по себе метод Deadline возвращает время, когда задача была выполнена от имени текущего контекста. А если крайний срок не установлен, то возвращает значение ok, равное false. При последовательных вызовах функции Deadline результаты будут одинаковыми. Что полезно знать. как реализуют разные типы контекстов этот метод, мы посмотрим чуть позже.
Давайте разберем поближе этот метод.
Когда работа, выполняемая от имени контекста, должна быть отменена, Done возвращает канал, который закрыт
Если этот контекст никогда не может быть отменен, Done возвращает nil
Закрытие канала Done
может произойти асинхронно после возврата функции cancel
Последовательные вызовы Done возвращают одно и то же значение
И что самое удобное - мы можем использовать Done в select.
func StreamWithDone(ctx context.Context, out chan<- Value) error {
for {
value, err := Process(ctx)
if err != nil {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
case out <- value:
}
}
}
C ним не сложнее чем с Done.
Если параметр Done
еще не закрыт, Err
возвращает значение nil
Если параметр Done
закрыт, Err
возвращает ненулевую ошибку, объясняющую причину почему отменено, если контекст был отменен или отменено по истечении срока действия, если истек срок действия контекста
После того, как Err
возвращает ненулевую ошибку, последующие вызовы Err
возвращают ту же ошибку
Тут уже поинтереснее.
Возвращает значение, связанное с этим контекстом для key, или nil, если с key не связано ни одно значение
Последовательные вызовы Value
с помощью одного и того же ключа возвращают один и тот же результат
Ключ может быть любого типа, поддерживающего равенство - пакеты должны определять ключи как неэкспортируемый тип, чтобы избежать коллизий
Ключ идентифицирует конкретное значение в контексте
Пакеты, определяющие контекстный ключ, должны предоставлять типобезопасные средства доступа для значений, сохраненных с использованием этого ключа
Давайте разберем последнее утверждение. Хорошей практикой является создание отдельного типа ключа и доступа к значению к контексте по нему, причем ключ должен быть неэкспортируемый, так как это нужно, чтобы избежать конфликтов ключей с другими пакетами, например.
// Создаем ключ для контекста
type key int
// Инициализация переменной
var someKey key
// Создаем контекст
func NewContext(ctx context.Context, u *SomeStruct) context.Context {
return context.WithValue(ctx, someKey, u)
}
// Возращаем значение
func FromContext(ctx context.Context) (*SomeStruct, bool) {
someStruct, ok := ctx.Value(someKey).(*User)
return someStruct, ok
}
Важное замечание! Передача значений по контексту является плохой практикой. Передавайте значения по параметрам, используйте передачу по контексту только в вынужденных ситуациях. Например, можно использовать передачу по контексту логгера, какой-либо middleware айдишник, но опять же, все решения должны быть продуманы. Есть несколько причина этому, но одна из них это то, что при большой вложенности страдает перебор контекстов, также ухудшается документирование, создается проблема синхронизации работы команды (так как если мы будем использовать контекст как хранилище, то кто знает, вдруг по пути данные нечаянно потеряются незаметно и все :) )
Можно разобрать функцию Cause()
:
func Cause(c Context) error {
if cc, ok := c.Value(&cancelCtxKey).(*cancelCtx); ok {
cc.mu.Lock()
defer cc.mu.Unlock()
return cc.cause
}
return c.Err()
}
Cause
возвращает ненулевую ошибку, которая объясняет, почему был отменен контекст. Причина отмены — это первая отмена контекста или одного из его родительских элементов. Если отмена произошла с помощью CancelCauseFunc(err)
, то Cause
возвращает значение ошибки. В противном случае — значение c.Err()
. Если контекст еще не был отменен, Cause
возвращает значение nil.
Примечание: Мы знаем, что контекст не является потомком какого-либо контекста, созданного с помощью WithCancelCause
, так как у него нет значения cancelCtxKey
. Если это не один из стандартных типов контекста, может возникнуть ошибка, даже без причины.
Само использование мы рассмотрим ниже в статье.
emptyCtx
- это структура, которая никогда не отменяется, возращает nil всеми ее методами, не имеет значений и не имеет крайнего срока, также реализует интерфейс Context. Используется для создания корневого контекста, который возвращает context.Background()
и context.TODO()
в стандартной библиотеке.
type emptyCtx struct{}
func (emptyCtx) Deadline() (deadline time.Time, ok bool) {
return
}
func (emptyCtx) Done() <-chan struct{} {
return nil
}
func (emptyCtx) Err() error {
return nil
}
func (emptyCtx) Value(key any) any {
return nil
}
type backgroundCtx struct{ emptyCtx }
func (backgroundCtx) String() string {
return "context.Background"
}
type todoCtx struct{ emptyCtx }
func (todoCtx) String() string {
return "context.TODO"
}
Также мы можем увидеть определение методов String()
для todoCtx
и backgroundCtx
, что возращает именное представление TODO и Background.
Это контекст, который может быть отменен, также при отмене отменяются все его дочерние элементы, которые реализуют функцию отмены. Ее создает WithCancel()
.
type cancelCtx struct {
Context // родительский контекст
mu sync.Mutex // защищает следующие поля
done atomic.Value // из канала struct{}, созданной лениво, закрытым первым вызовом cancel
children map[canceler]struct{} // устанавливается равным nil при первом отмене вызова
err error // устанавливается на отличное от nil значение при первом вызове отмены
cause error // устанавливается на отличное от nil значение при первом вызове отмены
}
cancelCtx
определяется как контекст, который может быть отменен. Из-за древовидной структуры контекста при отмене все дочерние контексты должны быть отменены синхронно. Вам нужно просто пройти по структуре children map[canceler]structure{}
и отменить их по одному.
Что же по сути является canceler
? Это просто интерфейс с определенными полями. Его имплементацией являются *timerCtx
и *cancelCtx
type canceler interface {
cancel(removeFromParent bool, err, cause error)
Done() <-chan struct{}
}
func (c *cancelCtx) Value(key any) any {
if key == &cancelCtxKey {
return c
}
return value(c.Context, key)
}
func (c *cancelCtx) Done() <-chan struct{} {
d := c.done.Load()
if d != nil {
return d.(chan struct{})
}
c.mu.Lock()
defer c.mu.Unlock()
d = c.done.Load()
if d == nil {
d = make(chan struct{})
c.done.Store(d)
}
return d.(chan struct{})
}
// Просто лочим мьютекс и просто возращаем саму ошибку в струкутуре контекста
func (c *cancelCtx) Err() error {
c.mu.Lock()
err := c.err
c.mu.Unlock()
return err
}
type stringer interface {
String() string
}
func contextName(c Context) string {
if s, ok := c.(stringer); ok {
return s.String()
}
return reflectlite.TypeOf(c).String()
}
func (c *cancelCtx) String() string {
return contextName(c.Context) + ".WithCancel"
}
Мы видим обычное определение интерфейса Context, где внутри просто все синхронизировано мьютексами для безопасного доступа к каналу. Но более интереснее следующий метод.
func (c *cancelCtx) propagateCancel(parent Context, child canceler) {
// назначаем родительский контекст для текущего контекста отмены
c.Context = parent
// если родительский контекст не поддерживает отмену (его метод Done возвращает nil), метод завершается
done := parent.Done()
if done == nil {
return // родитель никогда не отменяется
}
select {
case <-done:
// родитель уже отменен
child.cancel(false, parent.Err(), Cause(parent))
return
default:
}
// добавляем дочерний контекст в список children родительского контекста, чтобы он мог быть отменен вместе с родительским
if p, ok := parentCancelCtx(parent); ok {
// родитель это *cancelCtx, или является производным от него
p.mu.Lock()
if p.err != nil {
// родитель уже отменен
child.cancel(false, p.err, p.cause)
} else {
if p.children == nil {
p.children = make(map[canceler]struct{})
}
p.children[child] = struct{}{}
}
p.mu.Unlock()
return
}
if a, ok := parent.(afterFuncer); ok {
// родитель имплементирует AfterFunc метод
c.mu.Lock()
stop := a.AfterFunc(func() {
child.cancel(false, parent.Err(), Cause(parent))
})
c.Context = stopCtx{
Context: parent,
stop: stop,
}
c.mu.Unlock()
return
}
// если ни один из предыдущих условий не выполнен,
// метод запускает новую горутину, которая ожидает сигнала об отмене
// родительского контекста и, в случае его получения, отменяет дочерний контекст
goroutines.Add(1)
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err(), Cause(parent))
case <-child.Done():
}
}()
}
propagateCancel
организует отмену дочернего элемента при наличии родительского, устанавливает родительский контекст cancelCtx
. Таким образом, метод propagateCancel
гарантирует, что отмена родительского контекста будет корректно передана всем дочерним контекстам, обеспечивая согласованность и упрощая управление временем жизни связанных операций.
Как видно из исходного кода создания cancelCtx
, внутренняя сигнализация cancelCtx
зависит от канала Done
. Если вы хотите отменить этот контекст, вам нужно заблокировать все <-c.Done ()
. Самый простой способ — закрыть этот канал или заменить его уже закрытым каналом.
func (c *cancelCtx) cancel(removeFromParent bool, err, cause error) {
// просто проверяется наличие ошибок
if err == nil {
panic("context: internal error: missing cancel error")
}
if cause == nil {
cause = err
}
c.mu.Lock()
if c.err != nil {
c.mu.Unlock()
return // уже отменен
}
c.err = err
c.cause = cause
d, _ := c.done.Load().(chan struct{})
if d == nil {
c.done.Store(closedchan)
} else {
close(d)
}
// отменяем все дочерние контексты
for child := range c.children {
child.cancel(false, err, cause)
}
// очищаем нашу мапу
c.children = nil
c.mu.Unlock()
// если removeFromParent == true, то удаляем текущий контекст из дочерних
if removeFromParent {
removeChild(c.Context, c)
}
}
В данном методе мы обеспечиваем корректную отмену текущего контекста и всех его дочерних контекстов, гарантируя, что все связанные с ними операции будут надлежащим образом завершены.
Можно посмотреть для большей ясности определение removeChild()
func removeChild(parent Context, child canceler) {
// Проверяем, является ли родительский контекст типом stopCtx
if s, ok := parent.(stopCtx); ok {
s.stop() // Останавливаем родительский контекст
return
}
// Проверяем, является ли родительский контекст типом cancelCtx
p, ok := parentCancelCtx(parent)
if !ok {
return
}
p.mu.Lock()
// если у родительского контекста есть дочерние контексты,
// удаляем текущий дочерний контекст
if p.children != nil {
delete(p.children, child)
}
p.mu.Unlock()
}
В целом логика должна быть понятна, единственное, определение stopCtx
мы разберем ниже в статье.
Создается же этот контекст с помощью WithCancel(parent Context) (ctx Context, cancel CancelFunc)
.
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := withCancel(parent)
return c, func() { c.cancel(true, Canceled, nil) }
}
WithCancel
возвращает копию родителя с новым каналом Done
. Канал Done
контекста закрывается при вызове функции отмены или при закрытии канала Done
родительского контекста. Отмена этого контекста освобождает ресурсы, поэтому код должен вызывать cancel
после завершения операций в этом контексте.
Также отмену нужно вызывать на уровне, где она была создана. Вызывать ее в другом месте - антипаттерн, так как это может приводить к утечке незакрытых контекстов.
CancelFunc
является обычной функцией.
type CancelFunc func()
Функция CancelFunc
информирует операцию о прекращении работы, но не ждет ее завершения. Ее можно вызывать одновременно несколькими подпрограммами. После первого вызова последующие вызовы ничего не делают.
Можем обратить внимание на withCancel()
.
func withCancel(parent Context) *cancelCtx {
if parent == nil {
panic("cannot create context from nil parent")
}
c := &cancelCtx{}
c.propagateCancel(parent, c)
return c
}
В ней ничего сверхъестественного нет, все, что разбирали выше тут выполняется.
Он построен поверх cancelCtx
. Единственное отличие в добавлении таймера и времени отсечки. С помощью этих двух конфигураций можно автоматически отменить таймер в определенное время с помощью методов Deadline
и WithTimeout
.
type timerCtx struct {
cancelCtx
timer *time.Timer // Под cancelCtx.mu.
deadline time.Time
}
func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
return c.deadline, true
}
func (c *timerCtx) String() string {
return contextName(c.cancelCtx.Context) + ".WithDeadline(" +
c.deadline.String() + " [" +
time.Until(c.deadline).String() + "])"
}
func (c *timerCtx) cancel(removeFromParent bool, err, cause error) {
c.cancelCtx.cancel(false, err, cause)
if removeFromParent {
// Удаляет этот timerCtx из дочерних элементов его родительского cancelCtx
removeChild(c.cancelCtx.Context, c)
}
c.mu.Lock()
if c.timer != nil {
c.timer.Stop()
c.timer = nil
}
c.mu.Unlock()
}
То есть тут определение достаточно простое и понятное.
Чтобы рассмотреть как создается наш timerCtx
, обратим внимание на WithDeadline
.
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
return WithDeadlineCause(parent, d, nil)
}
Просмотрим основные положения:
WithDeadline
возвращает копию родительского контекста с измененным сроком, который должен быть установлен не позднее d
.
Если срок для родительского контекста уже установлен до d
, WithDeadline(parent, d)
семантически эквивалентен parent
.
Возращенный канал Context.Done
, который закрывается по истечении срока, при вызове функции cancel
или при завершении канала родительского контекста closed
, в зависимости от того, что произойдет раньше.
Отмена этого контекста высвобождает связанные с ним ресурсы, поэтому код должен отменить вызов, как только завершатся операции, выполняемые в этом.
Мы возращаем WithDeadLineCause()
:
func WithDeadlineCause(parent Context, d time.Time, cause error) (Context, CancelFunc) {
// Проверка наличия родительского контекста
if parent == nil {
panic("cannot create context from nil parent")
}
// Проверка текущего дедлайна родительского контекста
if cur, ok := parent.Deadline(); ok && cur.Before(d) {
// Текущий дедлайн уже раньше нового дедлайна
return WithCancel(parent)
}
// Создание нового контекста с таймером
c := &timerCtx{
deadline: d,
}
c.cancelCtx.propagateCancel(parent, c)
// Расчет времени до дедлайна и проверка его прошедшего
dur := time.Until(d)
if dur <= 0 {
c.cancel(true, DeadlineExceeded, cause) // дедлайн уже прошел
return c, func() { c.cancel(false, Canceled, nil) }
}
// Настройка таймера для отмены контекста по истечении дедлайна
c.mu.Lock()
defer c.mu.Unlock()
if c.err == nil {
c.timer = time.AfterFunc(dur, func() {
c.cancel(true, DeadlineExceeded, cause)
})
}
// Возвращение контекста и функции отмены
return c, func() { c.cancel(true, Canceled, nil) }
}
Он содержит экземпляр интерфейса Context
, который является дочерним контекстом, и два поля key, val interface {}
. При вызове valueCtx.Value(key interface ())
выполняется рекурсивный поиск. Он отвечает только за поиск контекста. Невозможно узнать, содержит ли родственный контекст этот ключ.
type valueCtx struct {
Context
key, val any
}
Он имеет всего 2 метода.
func (c *valueCtx) String() string {
return contextName(c.Context) + ".WithValue(" +
stringify(c.key) + ", " +
stringify(c.val) + ")"
}
func (c *valueCtx) Value(key any) any {
if c.key == key {
return c.val
}
return value(c.Context, key)
}
Ну и само определение value
.
func value(c Context, key any) any {
for {
// сперва определяем тип контекста
switch ctx := c.(type) {
// если контекст является типа valueCtx,
// проверяется, совпадает ли ключ key с ключом,
// хранящимся в этом контексте (ctx.key). Если да, то
// возвращается соответствующее значение ctx.val
case *valueCtx:
if key == ctx.key {
return ctx.val
}
c = ctx.Context
// тут возращает сам контекст
case *cancelCtx:
if key == &cancelCtxKey {
return c
}
c = ctx.Context
// если контекст является типа withoutCancelCtx,
// проверяется ключ на совпадение с cancelCtxKey.
// Если да, возвращается nil, что указывает на то,
// что контекст создан без поддержки отмены (метод Cause(ctx) возвращает nil).
// Если нет, переход к следующему контексту
case withoutCancelCtx:
if key == &cancelCtxKey {
// Имплементирует Cause(ctx) == nil
// когда ctx создан с использованием WithoutCancel
return nil
}
c = ctx.c
case *timerCtx:
if key == &cancelCtxKey {
return &ctx.cancelCtx
}
c = ctx.Context
case backgroundCtx, todoCtx:
return nil
default:
return c.Value(key)
}
}
}
Метод рекурсивно проходит по всей цепочке контекстов и возвращает значение, связанное с указанным ключом, если такое значение найдено. Если ключ не найден ни в одном из контекстов, возвращается nil
.
type stopCtx struct {
Context
stop func() bool
}
Используется в качестве родительского контекста для функции stop, которая отменяет регистрацию функции AfterFunc. Методов у нее нет.
type afterFuncCtx struct {
cancelCtx
once sync.Once // либо запускает f, либо останавливает запуск f
f func()
}
func (a *afterFuncCtx) cancel(removeFromParent bool, err, cause error) {
a.cancelCtx.cancel(false, err, cause)
if removeFromParent {
removeChild(a.Context, a)
}
a.once.Do(func() {
go a.f()
})
}
У него только один метод и содержит контекст отмены и примитив синхронизации Once
, что выполняется только один раз.
func AfterFunc(ctx Context, f func()) (stop func() bool) {
a := &afterFuncCtx{
f: f,
}
a.cancelCtx.propagateCancel(ctx, a)
return func() bool {
stopped := false
a.once.Do(func() {
stopped = true
})
if stopped {
a.cancel(true, Canceled, nil)
}
return stopped
}
}
Функция AfterFunc
принимает функцию, которая будет выполнена после завершения работы контекста, включая случаи истечения таймаута. Если контекст уже завершился, функция запустится немедленно. Выполнение функции происходит в отдельном потоке. При этом каждый вызов AfterFunc
выполняется независимо от других.
AfterFunc возвращает функцию остановки. При вызове функции остановки разрывается связь между функцией и контекстом. Если контекст уже находится в состоянии Done
и функция уже была запущена, или если функция уже была остановлена, то функция остановки возвращает false.
Функция завершает работу, если значение true. Функция stop не ожидает завершения работы функции, поэтому для контроля состояния рекомендуется явно взаимодействовать с ней.
type withoutCancelCtx struct {
c Context
}
func (withoutCancelCtx) Deadline() (deadline time.Time, ok bool) {
return
}
func (withoutCancelCtx) Done() <-chan struct{} {
return nil
}
func (withoutCancelCtx) Err() error {
return nil
}
func (c withoutCancelCtx) Value(key any) any {
return value(c, key)
}
func (c withoutCancelCtx) String() string {
return contextName(c.c) + ".WithoutCancel"
Содержит стандартное определение интерфейса контекста. возвращает копию родительского контекста, которая не будет отменена при отмене родительского контекста.
func WithoutCancel(parent Context) Context {
if parent == nil {
panic("cannot create context from nil parent")
}
return withoutCancelCtx{parent}
}
В этой же функции создаем наш экзмепляр. Вот примеры использования:
Логгирование - Предположим, что у вас есть служба, которая выполняет длинные запросы к базе данных и одновременно логирует эти запросы. Вы хотите, чтобы процесс логирования завершился, даже если сам запрос отменен.
Иногда нужно кэшировать результаты операции, чтобы ускорить последующие вызовы. Даже если операция отменена, вы хотите сохранить результаты в кэше, чтобы они могли быть использованы позже.
WithoutCancel
полезен в ситуациях, где определенные операции должны завершиться независимо от состояния родительского контекста. Это может быть полезно для фоновых задач, логирования, кэширования, и любых других задач, которые должны завершиться даже при отмене основной операции, например, rollback операции.
Контекст не возвращает Deadline
или Err
. Значение канала Done
— nil. Чтение из него приведёт к блокировке программы.
Надеюсь я помог немного понять внутреннее представление о работе контекста. Опять же, чтобы лучше понять его работу, лучше вникнуться, нужно просто потыкать ручками исходный код и пописать какие-то необычные кейсы использования контекстов на практике.