引言
context 是 Go 中广泛使用的程序包,由 Google 官方开发,在 1.7 版本引入。它用来简化在多个 go routine 传递上下文数据、(手动/超时)中止 routine 树等操作,比如,官方 http 包使用 context 传递请求的上下文数据,gRpc 使用 context 来终止某个请求产生的 routine 树。由于它使用简单,现在基本成了编写 go 基础库的通用规范。笔者在使用 context 上有一些经验,遂分享下。
本文主要谈谈以下几个方面的内容:
context 的使用。
context 实现原理,哪些是需要注意的地方。
在实践中遇到的问题,分析问题产生的原因。
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 树得以退出。
WithDeadline 和 WithTimeout 比 WithCancel 多了一个时间参数,它指示 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() 来终止所有的 cancelCtx 。done 用来标识是否已被 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 班、架构师班、自动化运维班、区块链 正在招生中
各位小伙伴们,欢迎试听和咨询: