Изучите основы контекста 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.valueCtx
it, чтобы убедиться, что вы можете передать значение во все подконтексты.
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 при его использовании и размышление о различиях между разными языками побудило меня стать более опытным и всесторонним разработчиком.
Спасибо за прочтение!