Изучите основы контекста Go и избегайте ловушек

Контекст Go считается одним из наиболее широко используемых типов Go после его появления в версии 1.7. Часто в сочетании с горутинами он упрощает обработку данных, отмену и другие операции. Как интерфейс только с четырьмя функциями, временем (Deadline), сигналом (Done), исключением (error) и данными (Value), он не сложен, но очень полезен в различных сценариях, охватывая почти все аспекты общего использования.

type Context interface {
// Deadline returns the time when work done on behalf of this context
// should be canceled. Deadline returns ok==false when no deadline is
// set.
Deadline() (deadline time.Time, ok bool)
// Done returns a channel that's closed when work done on behalf of this
// context should be canceled.
Done() <-chan struct{}
// Err returns a non-nil error value after Done is closed.
Err() error
// Value returns the value associated with this context for key.
Value(key interface{}) interface{}
}

Хотя я много использовал его в своем проекте Go, как и многие разработчики, я не планировал разбираться в нем дальше, пока не столкнулся с трудностями в отладке в производственной среде.

Я резюмирую то, что я узнал в этой статье.

  • Понять контекст и его вариант использования
  • Составьте список распространенных ошибок, основанных на моем опыте
  • Предоставьте базовый принцип использования контекста

Понять контекст

Благодаря облегченной функции горутин очень часто программы включают сотни из них, что является одним из преимуществ Go перед другими высокоуровневыми языками, такими как Java. Это также жизненно важно в эпоху облачных вычислений, позволяя экономить ресурсы и быть более масштабируемым.

Почему контекст? Подумайте над следующими вопросами.

Учитывая производительность, как мы можем преждевременно завершить горутины, когда они нам больше не нужны, пока тысячи горутин все еще выполняются? Разберем вопрос на практике. Сервер получил HTTP-запрос, и он должен иметь возможность завершить этот поток и освободить ресурсы для следующего запроса, как только мы закроем браузер.

Как этого добиться на Java? Мы используем семафор или метод статической переменной. А для более сложных систем мы используем очереди сообщений или реализуем функцию обратного вызова. Но некоторые из этих методов полагаются на особенности языка, в то время как некоторые используют «тяжелую» структуру, которую нельзя применить в Go.

Помните об этом, и давайте рассмотрим следующий вопрос.

Как мы все знаем, мы используем каналы при управлении группой горутин; в противном случае горутины завершатся, когда завершится поток main метода. To sleep in main - наиболее распространенный подход к тестированию, который на практике нецелесообразен. Итак, как лучше использовать горутину и канал вместе?

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

Контекст в Go

Во-первых, позвольте мне прояснить четыре метода в интерфейсе Context.

  • Deadline - вернуть время отмены context.Context.
  • Done - вернуть канал, который будет закрыт после завершения текущей работы или отмены контекста. Несколько вызовов метода Done вернут один и тот же канал.
  • Err - вернуть причину context.Context прекращения. Он вернет ненулевое значение только тогда, когда канал, возвращаемый Done, закрыт.

Если context.Context отменен, будет возвращена Canceled ошибка.

Если context.Context истекло время, будет возвращена DeadlineExceeded ошибка.

  • Value - получить значение, соответствующее ключу из context.Context. Для одного и того же контекста многократный вызов Value и передача одного и того же ключа вернет тот же результат.

В Go Context доставляется слой за уровнем с помощью Goroutine, поэтому, когда на верхнем уровне возникает ошибка и требуется завершение, Goroutine на нижнем уровне может получать уведомление и обрабатывать, избегая дополнительных накладных расходов. . Самая значимая роль контекста!

Кроме того, разработчикам не нужно напрямую реализовывать контекстный интерфейс, поскольку Go уже предоставляет две полезные реализации: background и todo.

var (
background = new(emptyCtx)
todo = new(emptyCtx)
)
func Background() Context {
return background
}
func TODO() Context {
return todo
}
type emptyCtx int
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 interface{}) interface{} {
return nil
}
func (e *emptyCtx) String() string {
switch e {
case background:
return "context.Background"
case todo:
return "context.TODO"
}
return "unknown empty Context"
}
  • context.Background() возвращает пустой context, обычно в main или основном потоке, чтобы создать parent context.
  • context.TODO() также создает пустой context и используется в основном или основном потоке, но имеет более широкое применение. Вы можете использовать его, если не знаете, что context использовать, или когда планируете получить context.

Исходя из исходного кода, функции context.Background и context.TODO - это просто псевдонимы друг для друга без особой разницы. Следовательно, мы будем использовать context.Background для создания parent context и передачи его, если текущая функция не имеет контекста в качестве входного параметра.

Реализация контекста

После создания parent context через context.Background(), мы можем извлечь из него больше подконтекстов с помощью четырех With* функций, предоставляемых пакетом контекста.

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context

Все первые три функции содержат Cancel func, который может распространяться на подконтексты. ПринимаяWithCancel в качестве примера, исходный код:

func newCancelCtx(parent Context) cancelCtx {
return cancelCtx{
Context: parent,
done:    make(chan struct{}),
}
}
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := newCancelCtx(parent)
propagateCancel(parent, &c)
return &c, func() { c.cancel(true, Canceled) }
}

newCancelCtx возвращает инициализированный cancelCtx. И структура cancelCtx наследует Context и реализует canceler интерфейс.

type canceler interface {
cancel(removeFromParent bool, err error)
Done() <-chan struct{}
}

Ключ - это cancel func, который обрабатывает три процедуры.

  • Отменить текущий контекст.
  • Вызовите все cancel func в подконтекстах. Здесь отмена распространяется на все подконтексты рекурсии.
  • Отмените регистрацию от родителя.

func (c *cancelCtx) cancel(removeFromParent bool, err error) {
if err == nil {
panic("context: internal error: missing cancel error")
}
c.mu.Lock()
if c.err != nil {
c.mu.Unlock()
return
}
c.err = err
close(c.done)
for child := range c.children {
child.cancel(false, err)
}
c.children = nil
c.mu.Unlock()

if removeFromParent {
removeChild(c.Context, c)
}
}

WithDeadline и WithTimeout в основном то же самое. Они используют таймер, чтобы решить, когда отменить / сделать, за исключением реализации canceler. Единственная разница в том, что WithDeadline устанавливает момент времени завершения, аWithTimeout устанавливает максимальное время работы.

Что касается WithValue, он не поддерживает CancelFunc, и его единственный эффект - делегировать вызовы встроенному контексту с помощью thecontext.valueCtxit, чтобы убедиться, что вы можете передать значение во все подконтексты.

type valueCtx struct {
Context
key, val interface{}
}

Как использовать контекст

Использовать Context намного проще, чем понимать его исходный код. Четыре With extensions в основном охватывают все возможные сценарии.

Отмена из-за ошибки❌

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

Отмена из-за тайм-аута⏰

По соображениям производительности использование контекста тайм-аута является обычной защитой в некоторых методах, которые занимают много времени и имеют решающее значение для повышения стабильности и устойчивости системы. При нормальной работе системы мы можем вовремя обнаруживать проблемы, добавляя мониторинг или другие методы. Вы можете увидеть мое внедрение в предыдущем примере оператора, который я использовал, чтобы гарантировать, что один цикл согласования не выполняется более 20 минут.

То же самое относится и к вызовам внешних запросов, каждый из которых не выполняется более 10 мс.

Контекст со значением

Мы можем нести ценность в контексте, как показано ниже.

Подводные камни👿

Журнал повторных ошибок контекста отменен

Я работал над проектом, в котором используется микросервисная архитектура «RESTful», в которой разные модули взаимодействуют через HTTP-интерфейсы. Служба будет прервана, когда пользователь выйдет из приложения или переключит службу. Затем текущая цепочка вызовов получает запрос на завершение, включая вызовы базы данных. Так возникают журналы, подобные следующему.

sql: Transaction has already been committed or rolled back

Каждый раз, когда выполняется sql , запускается db.BeginTx(), а затем запускается awaitDone горутина.

Предоставленный контекст используется до тех пор, пока транзакция не будет зафиксирована или откатится. Если контекст отменен, пакет sql откатит транзакцию. Tx.Commit вернет ошибку, если контекст, предоставленный BeginTx, будет отменен. - из Go sql lib

func (tx *Tx) awaitDone() {
// Wait for either the transaction to be committed or rolled
// back, or for the associated context to be closed.
<-tx.ctx.Done()
// Discard and close the connection used to ensure the
// transaction is closed and the resources are released.  This
// rollback does nothing if the transaction has already been
// committed or rolled back.
tx.rollback(true)
}

Когда tx.Commit() находит rollback == true, он выдаст ошибку.
Правильный подход - определить, является ли это ошибкой отключения. Если да, игнорируйте исключение.

if err != nil && err.Error() != "pq: canceling statement due to user request" {
return fmt.Errorf("%w: %v", ctx.Err(), err)
}

Контекст в кеше

При выполнении определенной бизнес-логики мы вставляем определенный контекст в кеш, чтобы завершить выполнение всех подконтекстов, вызывая его cancel в последующей логике. Однако наше более позднее добавление периодического обновления кеша вызовет ошибки при обновлении отмененного контекста.

Это напоминание о том, что мы должны осторожно обращаться с контекстом, особенно когда контекст может быть отменен.

Значение контекста не является каналом

В начале разработки оператора мы ожидали инициировать согласование через context.Value с общей бизнес-логикой чтения PubsubTopic, помещения значения в контекст подписчика, а затем чтения context.value в цикле контроллера и выполнения последующей логики.

Это типичный пример неправильного понимания контекста Go, что приводит к блокировке логики контроллера в select и заставляет нас отложить context и обратиться к другим методам для ответа на запросы согласования.

Невозможно определить тип значения в контексте

Мы определяем значение в контексте как interface{} type, чтобы гарантировать, что оно применимо ко всем ситуациям, вызывая сбой компилятора при проверке типа при использовании значения. И вы можете обнаружить ошибку во время выполнения, только если int ошибочно принимают за string. Это обычная проблема, которая вас сильно беспокоит, когда ваш код постоянно повторяется и обновляется, но с помощью отличных стандартов командного кодирования и комментариев к документам вы можете обойти ее.

Злоупотребление контекстом

Я столкнулся с типичным примером использования map[interface{}]interface{} в контексте для доставки ценности. Анализировать такое значение Context, когда Context используется как очередь сообщений, является такой болезненной задачей. При этом могут возникать различные ошибки, и десятки предлагаемых нами решений могут привести к дальнейшим ошибкам. Следовательно, в контексте мы должны попытаться использовать простые типы или объекты, определенные struct, чтобы уменьшить вероятность ошибки и сделать код более удобным для сопровождения.

Принципы, которым нужно следовать

  • Только context.Background может быть корневым контекстом.
  • Контекст можно отменить только один раз.
  • Не используйте WithTimeout или WithCancel для переноса контекста, который может быть отменен.
  • Не добавляйте context var в struct. Вместо этого передайте его напрямую как параметр функции.
  • Всегда ставьте ctx context.Context на первое место в параметрах. например func test(ctx context.Context, …) error.
  • Не передавайте ноль при передаче контекста функции. Если вы не знаете, что доставить, используйте context.TODO.
  • Контекст является потокобезопасным и может безопасно передаваться между несколькими горутинами. И несколько горутин могут одновременно обращаться к одному и тому же контексту.
  • Используйте контекст только для переноса значения, если это необходимо.

Лично я предпочитаю использовать контекст для управления жизненным циклом подпрограмм только с тайм-аутом, но без привязки значений. Применяя его как условность в команде, мы можем избежать многих недоразумений и злоупотреблений в будущем.

Альтернативы

Контекст настолько необходим, что разработчики больше стараются избегать ненадлежащего использования, а не обходить его стороной. Я до сих пор не нашел лучшей замены после прочтения различных обсуждений на StackOverflow, Github и Reddit. Даже если есть похожая реализация, она также относится к контексту.

Конец

Я написал длинную статью, пытаясь охватить все аспекты Go Context. С другой стороны, упоминается почти все, что нужно знать разработчику Go о контексте. И правильное его использование после понимания его внутренней логики во многом будет способствовать нашему развитию. Поглощение философии дизайна Go при его использовании и размышление о различиях между разными языками побудило меня стать более опытным и всесторонним разработчиком.

Спасибо за прочтение!