在Go服务器中,每个传入的请求都在自己的goroutine中处理。请求的处理程序经常启动额外的goroutine来访问后端服务,如数据库和RPC服务。处理一个请求的一组goroutine通常需要访问该请求相关的特定的值,比如最终用户的身份、授权令牌和请求的deadline等。当一个请求被取消或处理超时时,所有在该请求上工作的goroutines应该迅速退出,以便系统可以回收他们正在使用的任何资源。
在 Google,开发了一个context包,可以轻松地将请求范围内的传值、取消信号和截止日期传递给所有参与处理该请求的 goroutine。
Context 设计目的是跟踪 goroutine 调用树,并在这些 goroutine 调用树中传递通知与元数据。 Context 提供的核心功能是多个 goroutine 之间的退出通知机制,传递数据只是一个辅助功能,应谨慎使用 context 传递数据。
Context们是一棵树
context 整体是一个树形结构,不同的 ctx 间可能是兄弟节点或者是父子节点的关系。
同时由于 Context 接口有多种不同的实现,所以树的节点可能也是多种不同的 ctx 实现。总的来说我觉得 Context 的特点是:
- 树形结构:每次调用WithCancel, WithValue, WithTimeout, WithDeadline实际是为当前节点追加子节点。
- 继承性:某个节点被取消,其对应的子树也会全部被取消。
- 多样性:节点存在不同的实现,故每个节点会附带不同的功能。
基础用法
接下来介绍 Context 的基础用法,最为重要的就是 3 个基础能力,取消、超时、附加值。
(一)新建一个Context:
1
2
ctx := context.TODO()
ctx := context.Background()
这两个方法返回的内容是一样的,都是返回一个空的 context,这个 context 一般用来做父 context。
(二)WithCancel:
1
2
3
4
// 函数声明
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
// 用法:返回一个子Context和主动取消函数
ctx, cancel := context.WithCancel(parentCtx)
这个函数相当重要,会根据传入的context生成一个子context和一个取消函数。当父context有相关取消操作,或者直接调用cancel函数的话,子context就会被取消。
举个日常业务中常用的例子:
1
2
3
4
5
6
7
8
9
10
11
// 一般操作比较耗时或者涉及远程调用等,都会在输入参数里带上一个ctx,这也是公司代码规范里提倡的
func Do(ctx context.Context, ...) {
ctx, cancel := context.WithCancel(parentCtx)
// 实现某些业务逻辑
// 当遇到某种条件,比如程序出错,就取消掉子Context,这样子Context绑定的协程也可以跟着退出
if err != nil {
cancel()
}
}
(三)WithTimeout:
1
2
3
4
// 函数声明
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
// 用法:返回一个子Context(会在一段时间后自动取消),主动取消函数
ctx := context.WithTimeout(parentCtx, 5*time.Second)
这个函数在日常工作中使用得非常多,简单来说就是给 Context 附加一个超时控制,当超时 ctx.Done()
返回的 channel 就能读取到值,协程可以通过这个方式来判断执行时间是否满足要求。
举个日常业务中常用的例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 一般操作比较耗时或者涉及远程调用等,都会在输入参数里带上一个ctx,这也是公司代码规范里提倡的
func Do(ctx context.Context, ...) {
ctx := context.WithTimeout(parentCtx, 5*time.Second)
// 实现某些业务逻辑
for {
select {
// 轮询检测是否已经超时
case <-ctx.Done():
return
// 有时也会附加一些错误判断
case <-errCh:
cancel()
default:
}
}
}
现在大部分 go 库都实现了超时判断逻辑,只需要传入 ctx 就好。
(四)WithDeadline:
1
2
3
4
// 函数声明
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
// 用法:返回一个子Context(会在指定的时间自动取消),主动取消函数
ctx, cancel := context.WithDeadline(parentCtx, time.Now().Add(5*time.Second))
这个函数感觉用得比较少,和WithTimeout相比的话就是使用的是截止时间。
(五)WithValue:
1
2
3
4
5
6
// 函数声明
func WithValue(parent Context, key, val interface{}) Context
// 用法: 传入父Context和(key, value),相当于存一个kv
ctx := context.WithValue(parentCtx, "name", 123)
// 用法:将key对应的值取出
v := ctx.Value("name")
这个函数常用来保存一些链路追踪信息,比如 API 服务里会有来保存一些来源 ip、请求参数等。
因为这个方法实在是太常用了,比如grpc-go
里的 metadata 就使用这个方法将结构体存储在 ctx 里。
1
2
3
func NewOutgoingContext(ctx context.Context, md MD) context.Context {
return context.WithValue(ctx, mdOutgoingKey{}, rawMD{md: md})
}
源码实现
context.Context是一个接口,源码里是有多种不同的实现的,借此实现不同的功能。
1
2
3
4
5
6
7
8
// context携带截止日期、取消信号和请求维度的传值
// 其方法对于多个goroutine同时使用是安全的
type Context interface {
Deadline() (deadline time.Time, ok bool) // Done返回一个信道(chan),当此Context被取消或超时时,该信道将关闭
Done() <-chan struct{} // 在Done信道关闭后,Err可返回context被取消的原因
Err() error // Deadline返回此Context什么时间会被取消(如果有的话)
Value(key interface{}) interface{} // Value返回key对应的值,如果没有则返回nil
}
Done() 返回一个空只读 channel,可以表示 context 被取消的信号:当这个 channel 被关闭时,说明 context 被取消了。这是一个只读的channel, 读一个关闭的 channel 会读出相应类型的零值。并且源码里没有地方会向这个 channel 里面塞入值(只会close)。因此在子协程里读这个 channel,除非被关闭,否则读不出来任何东西。也正是利用了这一点,子协程从 channel 里读出了值后,就可以做一些收尾工作,尽快退出。同样在父协程中也可以读这个channel,监听子协程的cancel。
Canceler 接口:
1
2
3
4
type canceler interface {
cancel(removeFromParent bool, err error)
Done() <-chan struct{}
}
实现了上面定义的两个方法的 Context,就表明该 Context 是可取消的。源码中有两个类型实现了 canceler 接口:cancelCtx 和timerCtx。
emptyCtx:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
}
background 和 todo 是一个空的 context,永远不会被 cancel,没有存储值,也没有 deadline。 background 通常用在 main 函数中,作为所有 context 的根节点。 todo 通常用在并不知道传递什么 context 的情形。
cancelContext:
1
2
3
4
5
6
7
8
9
type cancelCtx struct {
Context
// 保护之后的字段
mu sync.Mutex
done chan struct{}
children map[canceler]struct{}
err error
}
这是一个可以取消的 Context,实现了 canceler 接口。它直接将接口 Context 作为它的一个匿名字段,这样,它就可以被看成一个 Context。
1
2
3
4
5
6
7
8
9
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
}
c.done 是“懒汉式”创建,只有调用了 Done() 方法的时候才会被创建。直接调用读这个 channel,协程会被 block 住。一般通过搭配 select 来使用。一旦关闭,就会立即读出零值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
c.mu.Lock()
if c.err != nil {
c.mu.Unlock()
return // already canceled
}
c.err = err
if c.done == nil {
c.done = closedchan
} else {
close(c.done)
}
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)
}
}
总体来看,cancel() 方法的功能就是关闭 channel;递归地取消它的所有子节点;从父节点从删除自己。达到的效果是通过关闭 channel,将取消信号传递给了它的所有子节点。goroutine 接收到取消信号的方式就是 select 语句中的读 c.done 被选中。
timerCtx:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type timerCtx struct {
cancelCtx
timer *time.Timer // Under cancelCtx.mu.
deadline time.Time
}
timerCtx 基于 cancelCtx,只是多了一个 time.Timer 和一个 deadline。Timer 会在 deadline 到来时,自动取消 context。
valueCtx
type valueCtx struct {
Context
key, val interface{}
}
func WithValue(parent Context, key, val interface{}) Context {
return &valueCtx{parent, key, val}
}
WithValue 能从父上下文中创建一个子上下文,传值的子上下文使用 context.valueCtx 类型
1
2
3
4
5
6
func (c *valueCtx) Value(key interface{}) interface{} {
if c.key == key {
return c.val
}
return c.Context.Value(key)
}
总结
Context 的作用:
在 goroutine 之间传递上下文信息,包括:取消信号、超时时间、截止时间、k-v 等。
Context 的原理:
- 取消信号:通过关闭 done channel 通知监听者
- 超时时间:通过Timer自动触发 cancel 关闭 channel
- 存值:通过链表生成一个新的节点存储 k-v,查询时递归查找
Context 实现超时控制:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func doLongJob(ctx context.Context) {
time.Sleep(10*time.Second)
print("done long job")
}
func TestJob(t *testing.T) {
ctx := context.Background()
ctx, cancel := context.WithTimeout(ctx, time.Second * 5)
defer cancel()
start := time.Now()
go doLongJob(ctx)
select {
case <-ctx.Done():
t.Log(ctx.Err())
}
elapsed := time.Since(start)
}
Context 的使用:
- 应该使用 RPCContext 供其它组件使用 Ginex 传递 context 给 kitc/kitex/log等
- 不要异步使用 ctx 中的某些值 勿异步使用 RPCInfo
- Log 尽量带上 ctx 可以根据 logid 追踪日志