Go Context 的踩坑经历

0.558字数 2916阅读 1391

引言

context 是 Go 中广泛使用的程序包,由 Google 官方开发,在 1.7 版本引入。它用来简化在多个 go routine 传递上下文数据、(手动/超时)中止 routine 树等操作,比如,官方 http 包使用 context 传递请求的上下文数据,gRpc 使用 context 来终止某个请求产生的 routine 树。由于它使用简单,现在基本成了编写 go 基础库的通用规范。笔者在使用 context 上有一些经验,遂分享下。

本文主要谈谈以下几个方面的内容:

  1. context 的使用。

  2. context 实现原理,哪些是需要注意的地方。

  3. 在实践中遇到的问题,分析问题产生的原因。

1.使用

1.1 使用核心接口 Context
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{}}

简单介绍一下其中的方法:

  • Done 会返回一个 channel,当该 context 被取消的时候,该 channel 会被关闭,同时对应的使用该 context 的 routine 也应该结束并返回。

  • Context 中的方法是协程安全的,这也就代表了在父 routine 中创建的context,可以传递给任意数量的 routine 并让他们同时访问。

  • Deadline 会返回一个超时时间,routine 获得了超时时间后,可以对某些 io 操作设定超时时间。

  • Value 可以让 routine 共享一些数据,当然获得数据是协程安全的。

在请求处理的过程中,会调用各层的函数,每层的函数会创建自己的 routine,是一个 routine 树。所以,context 也应该反映并实现成一棵树。

要创建 context 树,第一步是要有一个根结点。context.Background 函数的返回值是一个空的 context,经常作为树的根结点,它一般由接收请求的第一个 routine 创建,不能被取消、没有值、也没有过期时间。

func Background() Context

之后该怎么创建其它的子孙节点呢?context包为我们提供了以下函数:

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 interface{}, val interface{}) Context

这四个函数的第一个参数都是父 context,返回一个 Context 类型的值,这样就层层创建出不同的节点。子节点是从复制父节点得到的,并且根据接收的函数参数保存子节点的一些状态值,然后就可以将它传递给下层的 routine 了。

WithCancel 函数,返回一个额外的 CancelFunc 函数类型变量,该函数类型的定义为:

type CancelFunc func()

调用 CancelFunc 对象将撤销对应的 Context 对象,这样父结点的所在的环境中,获得了撤销子节点 context 的权利,当触发某些条件时,可以调用 CancelFunc 对象来终止子结点树的所有 routine。在子节点的 routine 中,需要用类似下面的代码来判断何时退出 routine:

select {   case <-cxt.Done():       // do some cleaning and return}

根据 cxt.Done() 判断是否结束。当顶层的 Request 请求处理结束,或者外部取消了这次请求,就可以 cancel 掉顶层 context,从而使整个请求的 routine 树得以退出。

WithDeadlineWithTimeoutWithCancel 多了一个时间参数,它指示 context 存活的最长时间。如果超过了过期时间,会自动撤销它的子 context。所以 context 的生命期是由父 context 的 routine 和 deadline 共同决定的。

WithValue 返回 parent 的一个副本,该副本保存了传入的 key/value,而调用Context 接口的 Value(key) 方法就可以得到 val。注意在同一个 context 中设置key/value,若 key 相同,值会被覆盖。

关于更多的使用示例,可参考官方博客。

2.原理
2.1 输入标题上下文数据的存储与查询
type valueCtx struct {   Context   key, val interface{}}func WithValue(parent Context, key, val interface{}) Context {   if key == nil {       panic("nil key")   }   ......   return &valueCtx{parent, key, val}}func (c *valueCtx) Value(key interface{}) interface{} {   if c.key == key {       return c.val   }   return c.Context.Value(key)}

context 上下文数据的存储就像一个树,每个结点只存储一个 key/value 对。WithValue() 保存一个 key/value 对,它将父 context 嵌入到新的子 context,并在节点中保存了 key/value 数据。Value() 查询 key 对应的 value 数据,会从当前 context 中查询,如果查不到,会递归查询父 context 中的数据。

值得注意的是,context 中的上下文数据并不是全局的,它只查询本节点及父节点们的数据,不能查询兄弟节点的数据。

2.2 手动 cancel 和超时 cancel

cancelCtx 中嵌入了父 Context,实现了canceler 接口:

type cancelCtx struct {   Context      // 保存parent Context   done chan struct{}   mu       sync.Mutex   children map[canceler]struct{}   err      error}// A canceler is a context type that can be canceled directly. The// implementations are *cancelCtx and *timerCtx.type canceler interface {   cancel(removeFromParent bool, err error)   Done() <-chan struct{}}

cancelCtx 结构体中 children 保存它的所有子 canceler, 当外部触发 cancel时,会调用 children 中的所有 cancel() 来终止所有的 cancelCtxdone 用来标识是否已被 cancel。当外部触发 cancel、或者父 Context 的 channel 关闭时,此 done 也会关闭。

type timerCtx struct {   cancelCtx     //cancelCtx.Done()关闭的时机:1)用户调用cancel 2)deadline到了 3)父Context的done关闭了   timer    *time.Timer   deadline time.Time}func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc) {   ......   c := &timerCtx{       cancelCtx: newCancelCtx(parent),       deadline:  deadline,   }   propagateCancel(parent, c)   d := time.Until(deadline)   if d <= 0 {       c.cancel(true, DeadlineExceeded) // deadline has already passed       return c, func() { c.cancel(true, Canceled) }   }   c.mu.Lock()   defer c.mu.Unlock()   if c.err == nil {       c.timer = time.AfterFunc(d, func() {           c.cancel(true, DeadlineExceeded)       })   }   return c, func() { c.cancel(true, Canceled) }}

timerCtx 结构体中 deadline 保存了超时的时间,当超过这个时间,会触发cancel

可以看出,cancelCtx 也是一棵树,当触发 cancel 时,会 cancel 本结点和其子树的所有 cancelCtx。

3.遇到的问题
3.1 背景

某天,为了给我们的系统接入 etrace (内部的链路跟踪系统),需要在 gRpc/Mysql/Redis/MQ 操作过程中传递 requestId、rpcId,我们的解决方案是 Context

所有 Mysql、MQ、Redis 的操作接口的第一个参数都是 context,如果这个context (或其父 context )被 cancel了,则操作会失败。

func (tx *Tx) QueryContext(ctx context.Context, query string, args ...interface{}) (*Rows, error)func(process func(context.Context, redis.Cmder) error) func(context.Context, redis.Cmder) errorfunc (ch *Channel) Consume(ctx context.Context, handler Handler, queue string, dc <-chan amqp.Delivery) errorfunc (ch *Channel) Publish(ctx context.Context, exchange, key string, mandatory, immediate bool, msg Publishing) (err error)

上线后,遇到一系列的坑......

3.2 Case 1

现象:上线后,5 分钟后所有用户登录失败,不断收到报警。

原因:程序中使用 localCache,会每 5 分钟 Refresh (调用注册的回调函数)一次所缓存的变量。localCache 中保存了一个 context,在调用回调函数时会传进去。如果回调函数依赖 context,可能会产生意外的结果。

程序中,回调函数 getAppIDAndAlias 的功能是从 mysql 中读取相关数据。如果 ctx 被 cancel 了,会直接返回失败。

func getAppIDAndAlias(ctx context.Context, appKey, appSecret string) (string, string, error)

第一次 localCache.Get(ctx, appKey, appSeret) 传的 ctx 是 gRpc call 传进来的 context,而 gRpc 在请求结束或失败时会 cancel 掉 context,导致之后 cache Refresh() 时,执行失败。

解决方法:在 Refresh 时不使用 localCache 的 context,使用一个不会 cancel的 context。

3.3 Case 2

现象:上线后,不断收到报警( sys err 过多)。看 log/etrace 产生 2 种 sys err:

  • context canceled

  • sql: Transaction has already been committed or rolled back

3.3.1 背景及原因

Ticket 是处理 Http 请求的服务,它使用 Restful 风格的协议。由于程序内部使用的是 gRpc 协议,需要某个组件进行协议转换,我们引入了 grpc-gateway,用它来实现 Restful 转成 gRpc 的互转。

复现 context canceled 的流程如下:

  • 客户端发送 http restful 请求。

  • grpc-gateway 与客户端建立连接,接收请求,转换参数,调用后面的 grpc-server。

  • grpc-server 处理请求。其中,grpc-server 会对每个请求启一个stream,由这个 stream 创建 context。

  • 客户端连接断开。

  • grpc-gateway 收到连接断开的信号,导致 context cancel。grpc client 在发送 rpc 请求后由于外部异常使它的请求终止了(即它的 context 被cancel ),会发一个 RST_STREAM。

  • grpc server 收到后,马上终止请求(即 grpc server 的 stream context被 cancel )。

可以看出,是因为 gRpc handler 在处理过程中连接被断开。

sql: Transaction has already been committed or rolled back 产生的原因:

程序中使用了官方 database 包来执行 db transaction。其中,在 db.BeginTx 时,会启一个协程 awaitDone:

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)}

在 context 被 cancel 时,会进行 rollback(),而 rollback 时,会操作原子变量。之后,在另一个协程中 tx.Commit() 时,会判断原子变量,如果变了,会抛出错误。

3.3.2 解决方法

这两个 error 都是由连接断开导致的,是正常的。可忽略这两个 error。

3.4 Case 3

上线后,每两天左右有 1~2 次的 mysql 事务阻塞,导致请求耗时达到 120 秒。在盘古(内部的 mysql 运维平台)中查询到所有阻塞的事务在处理同一条记录。

3.4.1 处理过程

1. 初步怀疑是跨机房的多个事务操作同一条记录导致的。由于跨机房操作,耗时会增加,导致阻塞了其他机房执行的 db 事务。

2. 出现此现象时,暂时将某个接口降级。降低多个事务操作同一记录的概率。

3. 减少事务的个数。

  • 将单条 sql 的事务去掉

  • 通过业务逻辑的转移减少不必要的事务

4. 调整 db 参数 innodb_lock_wait_timeout(120s->50s)。这个参数指示 mysql 在执行事务时阻塞的最大时间,将这个时间减少,来减少整个操作的耗时。考虑过在程序中指定事务的超时时间,但是 innodb_lock_wait_timeout 要么是全局,要么是 session 的。担心影响到 session 上的其它 sql,所以没设置。

5. 考虑使用分布式锁来减少操作同一条记录的事务的并发量。但由于时间关系,没做这块的改进。

6. DAL 同事发现有事务没提交,查看代码,找到 root cause。

原因是 golang 官方包 database/sql 会在某种竞态条件下,导致事务既没有 commit,也没有 rollback。

3.4.2 源码描述

开始事务 BeginTxx() 时会启一个协程:

// awaitDone blocks until the context in Tx is canceled and rolls back// the transaction if it's not already done.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.rollback(true) 中,会先判断原子变量 tx.done 是否为 1,如果 1,则返回;如果是 0,则加 1,并进行 rollback 操作。

在提交事务 Commit() 时,会先操作原子变量 tx.done,然后判断 context 是否被 cancel 了,如果被 cancel,则返回;如果没有,则进行 commit 操作。

// Commit commits the transaction.func (tx *Tx) Commit() error {   if !atomic.CompareAndSwapInt32(&tx.done, 0, 1) {       return ErrTxDone   }   select {   default:   case <-tx.ctx.Done():       return tx.ctx.Err()   }   var err error   withLock(tx.dc, func() {       err = tx.txi.Commit()   })   if err != driver.ErrBadConn {       tx.closePrepared()   }   tx.close(err)   return err}

如果先进行 commit() 过程中,先操作原子变量,然后 context 被 cancel,之后另一个协程在进行 rollback() 会因为原子变量置为 1 而返回。导致 commit() 没有执行,rollback() 也没有执行。

3.4.3 解决方法

解决方法可以是如下任一个:

  • 在执行事务时传进去一个不会 cancel 的 context

  • 修正 database/sql 源码,然后在编译时指定新的 go 编译镜像

我们之后给 Golang 提交了 patch,修正了此问题 ( 已合入 go 1.9.3)。

4.经验教训

由于 go 大量的官方库、第三方库使用了 context,所以调用接收 context 的函数时要小心,要清楚 context 在什么时候 cancel,什么行为会触发 cancel。笔者在程序经常使用 gRpc 传出来的 context,产生了一些非预期的结果,之后花时间总结了 gRpc、内部基础库中 context 的生命期及行为,以避免出现同样的问题。

转载
作者:包增辉
原文链接:https://zhuanlan.zhihu.com/p/34417106

公告通知

Golang 班、架构师班、自动化运维班、区块链 正在招生中

各位小伙伴们,欢迎试听和咨询:


扫码添加小助手微信,备注"公开课,来源简书",进入分享群

推荐阅读更多精彩内容