本文让我们一起来学习 golang Context 的使用和标准库中的Context的实现。
golang context 包 一开始只是 Google 内部使用的一个 Golang 包,在 Golang 1.7的版本中正式被引入标准库。下面开始学习。
简单介绍
在学习 context 包之前,先看几种日常开发中经常会碰到的业务场景:
业务需要对访问的数据库,RPC ,或API接口,为了防止这些依赖导致我们的服务超时,需要针对性的做超时控制。
为了详细了解服务性能,记录详细的调用链Log。
上面两种场景在web中是比较常见的,context 包就是为了方便我们应对此类场景而使用的。
接下来, 我们首先学习 context 包有哪些方法供我们使用;接着举一些例子,使用 context 包应用在我们上述场景中去解决我们遇到的问题;最后从源码角度学习 context 内部实现,了解 context 的实现原理。
Context 包
Context 定义
context 包中实现了多种 Context 对象。Context 是一个接口,用来描述一个程序的上下文。接口中提供了四个抽象的方法,定义如下:
1
2
3
4
5
6
type Context interface {
Deadline () ( deadline time . Time , ok bool )
Done () <- chan struct {}
Err () error
Value ( key interface {}) interface {}
}
Deadline() 返回的是上下文的截至时间,如果没有设定,ok 为 false
Done() 当执行的上下文被取消后,Done返回的chan就会被close。如果这个上下文不会被取消,返回nil
Err() 有几种情况:
如果Done() 返回 chan 没有关闭,返回nil
如果Done() 返回的chan 关闭了, Err 返回一个非nil的值,解释为什么会Done()
如果Canceled,返回 “Canceled”
如果超过了 Deadline,返回 “DeadlineEsceeded”
Value(key) 返回上下文中 key 对应的 value 值
Context 构造
为了使用 Context,我们需要了解 Context 是怎么构造的。
Context 提供了两个方法做初始化:
1
2
func Background () Context {}
func TODO () Context {}
上面方法均会返回空的 Context,但是 Background 一般是所有 Context 的基础,所有 Context 的源头都应该是它。TODO 方法一般用于当传入的方法不确定是哪种类型的 Context 时,为了避免 Context 的参数为nil而初始化的 Context。
其他的 Context 都是基于已经构造好的 Context 来实现的。一个 Context 可以派生多个子 context。基于 Context 派生新Context 的方法如下:
1
2
3
func WithCancel ( parent Context ) ( ctx Context , cancel CancelFunc ){}
func WithDeadline ( parent Context , d time . Time ) ( Context , CancelFunc ) {}
func WithTimeout ( parent Context , timeout time . Duration ) ( Context , CancelFunc ) {}
上面三种方法比较类似,均会基于 parent Context 生成一个子 ctx,以及一个 Cancel 方法。如果调用了cancel 方法,ctx 以及基于 ctx 构造的子 context 都会被取消。不同点在于 WithCancel 必需要手动调用 cancel 方法,WithDeadline 可以设置一个时间点,WithTimeout 是设置调用的持续时间,到指定时间后,会调用 cancel 做取消操作。
除了上面的构造方式,还有一类是用来创建传递 traceId, token 等重要数据的 Context。
1
func WithValue ( parent Context , key , val interface {}) Context {}
withValue 会构造一个新的context,新的context 会包含一对 Key-Value 数据,可以通过Context.Value(Key) 获取存在 ctx 中的 Value 值。
通过上面的理解可以直到,Context 是一个树状结构,一个 Context 可以派生出多个不一样的Context。我们大概可以画一个如下的树状图:
一个background,衍生出一个带有traceId的valueCtx,然后valueCtx衍生出一个带有cancelCtx 的context。最终在一些db查询,http查询,rpc沙逊等异步调用中体现。如果出现超时,直接把这些异步调用取消,减少消耗的资源,我们也可以在调用时,通过Value 方法拿到traceId,并记录下对应请求的数据。
当然,除了上面的几种 Context 外,我们也可以基于上述的 Context 接口实现新的Context.
使用方法
下面我们举几个例子,学习上面讲到的方法。
超时查询的例子
在做数据库查询时,需要对数据的查询做超时控制,例如:
1
2
ctx = context . WithTimeout ( context . Background (), time . Second )
rows , err := pool . QueryContext ( ctx , "select * from products where id = ?" , 100 )
上面的代码基于 Background 派生出一个带有超时取消功能的ctx,传入带有context查询的方法中,如果超过1s未返回结果,则取消本次的查询。使用起来非常方便。为了了解查询内部是如何做到超时取消的,我们看看DB内部是如何使用传入的ctx的。
在查询时,需要先从pool中获取一个db的链接,代码大概如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
// src/database/sql/sql.go
// func (db *DB) conn(ctx context.Context, strategy connReuseStrategy) *driverConn, error)
// 阻塞从req中获取链接,如果超时,直接返回
select {
case <- ctx . Done ():
// 获取链接超时了,直接返回错误
// do something
return nil , ctx . Err ()
case ret , ok := <- req :
// 拿到链接,校验并返回
return ret . conn , ret . err
}
req 也是一个chan,是等待链接返回的chan,如果Done() 返回的chan 关闭后,则不再关心req的返回了,我们的查询就超时了。
在做SQL Prepare、SQL Query 等操作时,也会有类似方法:
1
2
3
4
5
6
7
8
select {
default :
// 校验是否已经超时,如果超时直接返回
case <- ctx . Done ():
return nil , ctx . Err ()
}
// 如果还没有超时,调用驱动做查询
return queryer . Query ( query , dargs )
上面在做查询时,首先判断是否已经超时了,如果超时,则直接返回错误,否则才进行查询。
可以看出,在派生出的带有超时取消功能的 Context 时,内部方法在做异步操作(比如获取链接,查询等)时会先查看是否已经 Done了,如果Done,说明请求已超时,直接返回错误;否则继续等待,或者做下一步工作。这里也可以看出,要做到超时控制,需要不断判断 Done() 是否已关闭。
链路追踪的例子
在做链路追踪时,Context 也是非常重要的。(所谓链路追踪,是说可以追踪某一个请求所依赖的模块,比如db,redis,rpc下游,接口下游等服务,从这些依赖服务中找到请求中的时间消耗)
下面举一个链路追踪的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 建议把key 类型不导出,防止被覆盖
type traceIdKey struct {}{}
// 定义固定的Key
var TraceIdKey = traceIdKey {}
func ServeHTTP ( w http . ResponseWriter , req * http . Request ){
// 首先从请求中拿到traceId
// 可以把traceId 放在header里,也可以放在body中
// 还可以自己建立一个 (如果自己是请求源头的话)
traceId := getTraceIdFromRequest ( req )
// Key 存入 ctx 中
ctx := context . WithValue ( req . Context (), TraceIdKey , traceId )
// 设置接口1s 超时
ctx = context . WithTimeout ( ctx , time . Second )
// query RPC 时可以携带 traceId
repResp := RequestRPC ( ctx , ... )
// query DB 时可以携带 traceId
dbResp := RequestDB ( ctx , ... )
// ...
}
func RequestRPC ( ctx context . Context , ... ) interface {} {
// 获取traceid,在调用rpc时记录日志
traceId , _ := ctx . Value ( TraceIdKey )
// request
// do log
return
}
上述代码中,当拿到请求后,我们通过req 获取traceId, 并记录在ctx中,在调用RPC,DB等时,传入我们构造的ctx,在后续代码中,我们可以通过ctx拿到我们存入的traceId,使用traceId 记录请求的日志,方便后续做问题定位。
当然,一般情况下,context 不会单纯的仅仅是用于 traceId 的记录,或者超时的控制。很有可能二者兼有之。
如何实现
知其然也需知其所以然。想要充分利用好 Context,我们还需要学习 Context 的实现。下面我们一起学习不同的 Context 是如何实现 Context 接口的,
空上下文
Background(), Empty() 均会返回一个空的 Context emptyCtx。emptyCtx 对象在方法 Deadline(), Done(), Err(), Value(interface{}) 中均会返回nil,String() 方法会返回对应的字符串。这个实现比较简单,我们这里暂时不讨论。
有取消功能的上下文
WithCancel 构造的context 是一个cancelCtx实例,代码如下。
1
2
3
4
5
6
7
8
9
10
11
type cancelCtx struct {
Context
// 互斥锁,保证context协程安全
mu sync . Mutex
// cancel 的时候,close 这个chan
done chan struct {}
// 派生的context
children map [ canceler ] struct {}
err error
}
WithCancel 方法首先会基于 parent 构建一个新的 Context,代码如下:
1
2
3
4
5
func WithCancel ( parent Context ) ( ctx Context , cancel CancelFunc ) {
c := newCancelCtx ( parent ) // 新的上下文
propagateCancel ( parent , & c ) // 挂到parent 上
return & c , func () { c . cancel ( true , Canceled ) }
}
其中,propagateCancel 方法会判断 parent 是否已经取消,如果取消,则直接调用方法取消;如果没有取消,会在parent的children 追加一个child。这里就可以看出,context 树状结构的实现。 下面是propateCancel 的实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 把child 挂在到parent 下
func propagateCancel ( parent Context , child canceler ) {
// 如果parent 为空,则直接返回
if parent . Done () == nil {
return // parent is never canceled
}
// 获取parent类型
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 {
// 启动goroutine,等待parent/child Done
go func () {
select {
case <- parent . Done ():
child . cancel ( false , parent . Err ())
case <- child . Done ():
}
}()
}
}
Done() 实现比较简单,就是返回一个chan,等待chan 关闭。可以看出 Done 操作是在调用时才会构造 chan done,done 变量是延时初始化的。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
func ( c * cancelCtx ) Done () <- chan struct {} {
c . mu . Lock ()
if c . done == nil {
c . done = make ( chan struct {})
}
d := c . done
c . mu . Unlock ()
return d
}
在手动取消 Context 时 , 会调用 cancelCtx 的 cancel 方法 , 代码如下 :
func ( c * cancelCtx ) cancel ( removeFromParent bool , err error ) {
// 一些判断,关闭 ctx.done chan
// ...
if c . done == nil {
c . done = closedchan
} else {
close ( c . done )
}
// 广播到所有的child,需要cancel goroutine 了
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 ()
// 然后从父context 中,删除当前的context
if removeFromParent {
removeChild ( c . Context , c )
}
}
这里可以看到,当执行cancel时,除了会关闭当前的cancel外,还做了两件事,① 所有的child 都调用cancel方法,② 由于该上下文已经关闭,需要从父上下文中移除当前的上下文。
定时取消功能的上下文
WithDeadline, WithTimeout 提供了实现定时功能的 Context 方法,返回一个timerCtx结构体。WithDeadline 是给定了执行截至时间,WithTimeout 是倒计时时间,WithTImeout 是基于WithDeadline实现的,因此我们仅看其中的WithDeadline 即可。WithDeadline 内部实现是基于cancelCtx 的。相对于 cancelCtx 增加了一个计时器,并记录了 Deadline 时间点。下面是timerCtx 结构体:
1
2
3
4
5
6
7
type timerCtx struct {
cancelCtx
// 计时器
timer * time . Timer
// 截止时间
deadline time . Time
}
WithDeadline 的实现:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func WithDeadline ( parent Context , d time . Time ) ( Context , CancelFunc ) {
// 若父上下文结束时间早于child,
// 则child直接挂载在parent上下文下即可
if cur , ok := parent . Deadline (); ok && cur . Before ( d ) {
return WithCancel ( parent )
}
// 创建个timerCtx, 设置deadline
c := & timerCtx {
cancelCtx : newCancelCtx ( parent ),
deadline : d ,
}
// 将context挂在parent 之下
propagateCancel ( parent , c )
// 计算倒计时时间
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 {
// 设定一个计时器,到时调用cancel
c . timer = time . AfterFunc ( dur , func () {
c . cancel ( true , DeadlineExceeded )
})
}
return c , func () { c . cancel ( true , Canceled ) }
}
构造方法中,将新的context 挂在到parent下,并创建了倒计时器定期触发cancel。
timerCtx 的cancel 操作,和cancelCtx 的cancel 操作是非常类似的。在cancelCtx 的基础上,做了关闭定时器的操作
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func ( c * timerCtx ) cancel ( removeFromParent bool , err error ) {
// 调用cancelCtx 的cancel 方法 关闭chan,并通知子context。
c . cancelCtx . cancel ( false , err )
// 从parent 中移除
if removeFromParent {
removeChild ( c . cancelCtx . Context , c )
}
c . mu . Lock ()
// 关掉定时器
if c . timer != nil {
c . timer . Stop ()
c . timer = nil
}
c . mu . Unlock ()
}
timeCtx 的 Done 操作直接复用了cancelCtx 的 Done 操作,直接关闭 chan done 成员。
传递值的上下文
WithValue 构造的上下文与上面几种有区别,其构造的context 原型如下:
1
2
3
4
5
type valueCtx struct {
// 保留了父节点的context
Context
key , val interface {}
}
每个context 包含了一个Key-Value组合。valueCtx 保留了父节点的Context,但没有像cancelCtx 一样保留子节点的Context. 下面是valueCtx的构造方法:
1
2
3
4
5
6
7
8
9
10
func WithValue ( parent Context , key , val interface {}) Context {
if key == nil {
panic ( "nil key" )
}
// key 必须是课比较的,不然无法获取Value
if ! reflect . TypeOf ( key ). Comparable () {
panic ( "key is not comparable" )
}
return & valueCtx { parent , key , val }
}
直接将Key-Value赋值给struct 即可完成构造。下面是获取Value 的方法:
1
2
3
4
5
6
7
func ( c * valueCtx ) Value ( key interface {}) interface {} {
if c . key == key {
return c . val
}
// 从父context 中获取
return c . Context . Value ( key )
}
Value 的获取是采用链式获取的方法。如果当前 Context 中找不到,则从父Context中获取。如果我们希望一个context 多放几条数据时,可以保存一个map 数据到 context 中。这里不建议多次构造context来存放数据。毕竟取数据的成本也是比较高的。
注意事项
最后,在使用中应该注意如下几点:
context.Background 用在请求进来的时候,所有其他context 来源于它。
在传入的conttext 不确定使用的是那种类型的时候,传入TODO context (不应该传入一个nil 的context)
context.Value 不应该传入可选的参数,应该是每个请求都一定会自带的一些数据。(比如说traceId,授权token 之类的)。在Value 使用时,建议把Key 定义为全局const 变量,并且key 的类型不可导出,防止数据存在冲突。
context goroutines 安全。