一、context
什么是context?
Go 1.7 标准库引入 context,中文译作“上下文”,准确说它是 goroutine 的上下文,包含 goroutine 的运行状态、环境、现场等信息。context 主要用来在 goroutine 之间传递上下文信息,包括:取消信号、超时时间、截止时间、k-v 等。随着 context 包的引入,标准库中很多接口因此加上了 context 参数,例如 database/sql 包。context 几乎成为了并发控制和超时控制的标准做法。
go 为什么要引入 context?
在并发程序中,由于超时、取消操作或者一些异常情况,往往需要进行抢占操作或者中断后续操作。例如在在 Go 的 server 里,通常每来一个请求都会启动若干个 goroutine 同时工作:有些去数据库拿数据,有些调用下游接口获取相关数据……,这些 goroutine 需要共享这个请求的基本数据,例如登录token,处理请求的最大超时时间(如果超过此值再返回数据,请求方因为超时接收不到)等等。当请求被取消或者是处理时间太长(可能是使用者关闭了浏览器或是已经超过了请求方规定的超时时间,请求方直接放弃了这次请求结果),这时所有正在为该请求工作的 goroutine 需要快速并优雅的推出,因为这时候不需要他们的任何工作成果了,在相关的 goroutine 退出后,系统就可以回收相关的资源,Go 语言中的 server 实际上是一个“协程模型”,也就是说一个协程处理一个请求。例如在业务的高峰期,某个下游服务的响应变慢,而当前系统的请求又没有超时控制,或者超时时间设置地过大,那么等待下游服务返回数据的协程就会越来越多。而我们知道,协程是要消耗系统资源的,后果就是协程数激增,内存占用飙涨,甚至导致服务不可用。更严重的会导致雪崩效应,整个服务对外表现为不可用,其实前面描述的问题通过设置“允许下游最长处理时间”就可以避免。例如,给下游设置的 timeout 是 50 ms,如果超过这个值还没有接收到返回数据,就直接向客户端返回一个默认值或者错误。context 包就是为了解决上面所说的这些问题而开发的:在 一组 goroutine 之间传递共享的值、取消信号、deadline……,这也就是为什么我们常常将context搭配channel一起使用。
1.1、context的核心方法
- context.WithValue:设置键值对,并且返回一个新的 context 实例;
- context.WithCancel;
- context.WithDeadline;
- context.WithTimeout:三者都返回一个可取消的 context 实例,和取消函数
注意:context 实例是不可变的,每一次都是新创建的。
1.2、Context 核心接口
- Deadline:返回过期时间,如果ok为false,说明没有设置过期时间。不常用;
- Done:返回一个channel,一般用于监听Context实例的信号,比如说过期,或者正常关闭。常用;
- Err:返回一个错误用于表达Context发生了什么,Canceled=>正常关闭,DeadlineExceeded=>过期超时。比较常用;
- context.Value:取值。非常常用;
源码如下:
1.3 valueCtx实现
valueCtx用于存储key-value数据,特点:
- 典型的装饰器模式:在已有Context的基础上附加一个存储key-value的功能;
- 只能存储一个key,val:为什么不用map?
- map要求key是comparable的,而我们可能用不是comparable的key;
- context包的设计理念就是将Context设计成不可变;
func TestContext(t *testing.T) {
ctx := context.Background() // context.Background() 代表这是一个 上下文的 开始/起点
valCtx := context.WithValue(ctx, "key1", 999)
val := valCtx.Value("kay1")
fmt.Println(val)
}
func SomeBusiness() {
ctx := context.TODO() // 意味着对当前的上下文位置模糊, 未确定状态
Step1()
}
复制代码
1.4、安全传递数据
context包我们就用来做两件事:
- 安全传递数据
- 安全传递数据,是指在请求执行上下文中线程安全地传递数据, 依赖于 WithValue 方法;
- 控制链路
- 因为Go本身没有thread-local机制,所以大部分类似的功能都是借助于context来实现的。
例子:
- 分库分表中间件中传递shardinghint
- ORM中间件传递SQLhint
- Web框架传递上下文
- 链路追踪的 trace id
- AB测试的标记位
- 压力测试标记位
进程内传递就是依赖于context.Context传递的,也就是意味着所有的方法都必须有context.Context参数。
1.5、父子关系
特点:context 的实例之间存在父子关系:
- 当父亲取消或者超时,所有派生的子context 都被取消或者超时;
- 当找 key 的时候,子 context 先看自己有没有,没有则去祖先里面找;
控制是从上至下的,查找是从下至上的。
func TestParentCtx(t *testing.T) {
ctx := context.Background()
//dlCtx, cancel := context.WithDeadline(ctx, time.Now().Add(time.Minute))
// ctx, cancel := context.WithCancel(ctx)
toCtx, cancel := context.WithTimeout(ctx, 3 * time.Second)
// childCtx := context.WithValue(dlCtx, "name", "kfc")
// childCtx := context.WithValue(ctx, "name", "kfc")
childCtx := context.WithValue(toCtx, "name", "kfc")
defer cancel()
err := childCtx.Err()
fmt.Println(err)
}
复制代码
父无法访问子内容:
因为父context始终无法拿到子context设置的值,所以在逼不得已的时候我们可以在父context里面放一个map,后续都是修改这个map。
示例如下:
func TestMapParentValueCtx() {
ctx := context.Background()
childCtx := context.WithValue(ctx, "map", map[string]string{})
ccChild := context.WithValue(childCtx, "age", "111")
m := ccChild.Value("map").(map[string]string)
m["age"] = "121"
val := childCtx.Value("age") // 正常情况父 ctx 是无法获取子 ctx设置的value
fmt.Println(val)
val = childCtx.Value("map") // 由于父ctx中设置了一个引用数据结构,当引用数据结构修改时,也能被父ctx获取
fmt.Println(val)
}
执行结果:
<nil>
map[age:121]
复制代码
1.6、控制
context 包提供了三个控制方法, WithCancel、WithDeadline 和 WithTimeout。三者用法大同小异:
- 没有过期时间,但是又需要在必要的时候取 消,使用 WithCancel
- 在固定时间点过期,使用 WithDeadline
- 在一段时间后过期,使用 WithTimeout
而后便是监听 Done(); Done()返回一个 channel,不管是主动调用 cancel() 还是超时,都能从这个 channel 里面取出来数据。后面可以用 Err() 方法来判断究竟是哪种情况。
代码示例:
func CtxTimeOut() {
bg := context.Background()
timeOutCtx, cancel1 := context.WithTimeout(bg, 1*time.Second)
subCtx, cancel2 := context.WithTimeout(timeOutCtx, 3*time.Second)
go func() {
// 一秒钟后就会过期, 然后输出timeout
<- subCtx.Done()
fmt.Println("timeout")
}()
time.Sleep(2 * time.Second)
cancel2()
cancel1()
}
复制代码
子 context 试图重新设置超时时间,然而并没有成功,它依旧受到了父亲的控制; 父亲可以控制儿子,但是儿子控制不了父亲。
1.7、cancelCtx 实现
cancelCtx 也是典型的装饰器模式:在已有 Context 的基础上,加上取消/停止的功能。
核心实现:
- Done 方法是通过类似于 double-check 的机制写的。这种原子操作和锁结合的用法比较罕见。(思考:能不能换成读写锁?);
- 利用 children 来维护了所有的衍生节点, 难点就在于它是如何维护这个衍生节点。
源码如下:
// A cancelCtx can be canceled. When canceled, it also cancels any children
// that implement canceler.
type cancelCtx struct {
Context
mu sync.Mutex // protects following fields
done atomic.Value // of chan struct{}, created lazily, closed by first cancel call
children map[canceler]struct{} // set to nil by the first cancel call
err error // set to non-nil by the first cancel call
}
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{})
}
复制代码
- children:核心是儿子把自己加进去父亲的 children 字段里面。
- 但是因为 Context 里面存在非常多的层级, 所以父亲不一定是 cancelCtx,因此本质上 是找最近属于 cancelCtx 类型的祖先,然后儿子把自己加进去。
- cancel 就是遍历 children,挨个调用 cancel。然后儿子调用孙子的 cancel,子 子孙孙无穷匮也。
源码如下:
// propagateCancel arranges for child to be canceled when parent is.
func propagateCancel(parent Context, child canceler) {
done := parent.Done()
if done == nil {
return // parent is never canceled
}
select {
case <-done:
// parent is already canceled
child.cancel(false, parent.Err())
return
default:
}
// 找最近属于 cancelCtx 类型的祖先,然后child把自己加进祖先的children里
if p, ok := parentCancelCtx(parent); ok {
p.mu.Lock()
if p.err != nil {
// parent has already been canceled
child.cancel(false, p.err)
} else {
if p.children == nil {
p.children = make(map[canceler]struct{})
}
p.children[child] = struct{}{}
}
p.mu.Unlock()
} else {
// 找不到就只需要监听parent的信号,或者自己的信号,这些信号源自cancel或者超时
atomic.AddInt32(&goroutines, +1)
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err())
case <-child.Done():
}
}()
}
}
复制代码
核心的 cancel 方法,做了两件事:
- 遍历所有的 children
- 关闭 done 这个 channel:这个符合谁 创建谁关闭的原则
cancelCtx.cancel 方法 (源码)
// cancel closes c.done, cancels each of c's children, and, if
// removeFromParent is true, removes c from its parent's children.
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 // already canceled
}
c.err = err
d, _ := c.done.Load().(chan struct{})
if d == nil {
c.done.Store(closedchan)
} else {
close(d)
}
for child := range c.children {
// NOTE: acquiring the child's lock while holding parent's lock.
child.cancel(false, err)
}
c.children = nil
c.mu.Unlock()
if removeFromParent {
removeChild(c.Context, c)
}
}
复制代码
1.8、timerCtx 实现
timerCtx 也是装饰器模式:在已有 cancelCtx 的基础上增加了超时的功能。 实现要点:
- WithTimeout 和 WithDeadline 本质一样
- WithDeadline 里面,在创建 timerCtx 的时 候利用 time.AfterFunc 来实现超时
源码示例:
// A timerCtx carries a timer and a deadline. It embeds a cancelCtx to
// implement Done and Err. It implements cancel by stopping its timer then
// delegating to cancelCtx.cancel.
type timerCtx struct {
cancelCtx
timer *time.Timer // Under cancelCtx.mu.
deadline time.Time
}
// Canceling this context releases resources associated with it, so code should
// call cancel as soon as the operations running in this Context complete.
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
if parent == nil {
panic("cannot create context from nil parent")
}
if cur, ok := parent.Deadline(); ok && cur.Before(d) {
// The current deadline is already sooner than the new one.
return WithCancel(parent)
}
c := &timerCtx{
cancelCtx: newCancelCtx(parent),
deadline: d,
}
propagateCancel(parent, c) // cancal 关系依旧要建立起来
dur := time.Until(d)
if dur <= 0 {
c.cancel(true, DeadlineExceeded) // deadline has already passed
return c, func() { c.cancel(false, Canceled) }
}
c.mu.Lock()
defer c.mu.Unlock()
if c.err == nil {
c.timer = time.AfterFunc(dur, func() {
// 超时就 cancal
c.cancel(true, DeadlineExceeded)
})
}
return c, func() { c.cancel(true, Canceled) }
}
复制代码
1.9、控制超时
最经典用法是利用 context 来控制超时;
控制超时,相当于我们同时监听两个 channel,一个是正常业务结束的 channel,Done() 返回的。
代码实例:
func TimeOutContral() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
baChan := make(chan struct{})
go func() {
slowBiz()
baChan <- struct{}{}
}()
select {
case <- ctx.Done():
fmt.Println("timeout")
case <- baChan:
fmt.Println("biz end")
}
}
func slowBiz() {
time.Sleep(2 * time.Second)
}
复制代码
1.10、time.AfterFunc
另外一种超时控制是采用 time.AfterFunc:一般这种用 法我们会认为是定时任务,而不是超时控制。
这种超时控制有两个弊端:
- 如果不主动取消,那么 AfterFunc 是必然会执行的;
- 如果主动取消,那么在业务正常结束到主动取消之 间,有一个短时间的时间差;
代码实例:
func timeOutTimeAfer() {
baChan := make(chan struct{})
go func() {
slowBiz()
baChan <- struct{}{}
}()
timer := time.AfterFunc(3 * time.Second, func() {
fmt.Println("timeout")
})
<- baChan
fmt.Println("biz end")
timer.Stop()
}
func main() {
//TestContext()
//TestParentCtx()
TestMapParentValueCtx()
}
复制代码
例子1:DB.conn 控制超
- 首先直接检查一次 context.Context 有没有超时。
- 这种提前检测一下的用法还是比较常见的。比 如说 RPC 链路超时控制就可以先看看 context 有没有超时。
- 如果超时则可以不发送请求,直接返回超时响应。
这是一段来自 Database/sql 的例子(源码):
超时控制至少两个分支:
- 超时分支
- 正常业务分支 这段源码同样来自 Database/sql:
所以普遍来说 context.Context 会和 select- case 一起使用。
例子2:http.Request 使用 context 作为字段类型
- http.Request 本身就是 request-scope 的;
- http.Request 里面的 ctx 依旧设计为不可变的,我们只能创建一个新的 http.Request;
- 所以实际上我们没有办法修改一个已有的 http.Request 里面的 ctx;
- 即便我们要把 context.Context 做成字段, 也要遵循类似的用法;
http.Request 的WithContext方法:
例子3:errgroup.WithContext 利用 context 来传递信号
- WithContext 会返回一个 context.Context 实例;
- 如果 errgroup.Group 的 Wait 返回,或 者任何一个 Group 执行的函数返回 error,context.Context 实例都会被取消 (一损俱损);
- 所以用户可以通过监听 context.Context 来判断 errgroup.Group 的执行情;
这是典型的将 context.Context 作为信号载体 的用法,本质是依赖于 channel 的特性。
下边是 Kratos 利用这个特性来优雅启动服务实例,并且监听服务实例启动情况的代码片 段。
- 如果黄色框返回(这里的start会进行阻塞,当异常/退出服务器时才会返回),说明是启动有问题,那么 其它启动没有问题的 Server 也会退出,确保要么全部成功,要么全部失败;
- 如果是蓝色框返回,说明监听到了退出信 号,比如说 ctrl+ C,Server 都会退出;
- 红色的框里等待退出信号,等到了就关闭服务器。服务器关闭包括异常退出和正常退出。
1.11、使用注意事项:
- 一般只用做方法参数,而且是作为第一个参数;
- 所有公共方法,除非是 util,helper 之类的方法,否则都加上 context 参数;
- 不要用作结构体字段,除非你的结构体本身也是表达一个上下文的概念。
1.12、面试要点
- context.Context 使用场景:上下文传递和超时控制
- context.Context 原理:
- 父亲如何控制儿子:通过儿子主动加入到父亲的 children 里面,父亲只需要遍历 就可以;
- valueCtx 和 timeCtx 的原理。