理解 Golang Context 机制
在使用 Golang 的一些框架的时候,比如 Gin,每一个请求的 Handler 方法总是需要传递进去一个 context 对象,然后很多请求数据,比如请求参数,路径变量等都可以从中读出来,其实在这个使用过程中已经大体理解了这个 context 是个什么东西,但是对于其中的一些细节包括具体的使用方式还是缺乏了解,因此本文就针对 golang 里面的 context 概念进行简单的探讨。
Goroutine 并发控制的几种方式
我们知道 Golang 是一门擅长高并发的编程语言,可以通过 Goroutine 快速地创建并发任务,但是如何有效地管理这些执行的 Goroutine 是一个值得思考的问题。通常我们有下面几种方式实现 Goroutine 的控制:
- 使用 WaitGroup,根 goroutine 通过
add()
来记录已经开启的 Goroutine 数量,通过wait()
来等待执行任务的 goroutine 的done()
,实现同步的工作; - 使用 for/select + stop channel,通过向 stop channel 中传递 stop signal 实现 goroutine 的结束;
- 使用 Context, 可以控制具有复杂层级关系的 goroutine 任务,此时使用前两种方式实现会比较复杂,使用 context 会更优雅;
Context 原理概述
Goroutine 的创建和调用关系往往是有着层级关系的,顶部的 Goroutine 应有办法主动关闭其下属的 Goroutine 的执行。为了实现这种关系,Context 结构也应该像一棵树,叶子节点须总是由根节点衍生出来的。
第一个创建 Context 的 goroutine 被称为 root 节点:root 节点负责创建一个实现 Context 接口的具体对象,并将该对象作为参数传递至新拉起的 goroutine 作为其上下文。下游 goroutine 继续封装该对象并以此类推向下传递。
Context 可以安全的被多个 goroutine 使用。开发者可以把一个 Context 传递给任意多个 goroutine 然后 cancel 这个 context 的时候就能够通知到所有的 goroutine。
Context 接口源码
// A Context carries a deadline, cancelation signal, and request-scoped values
// across API boundaries. Its methods are safe for simultaneous use by multiple
// goroutines.
type Context interface {
// Done returns a channel that is closed when this Context is canceled
// or times out.
Done() <-chan struct{}
// Err indicates why this context was canceled, after the Done channel
// is closed.
Err() error
// Deadline returns the time when this Context will be canceled, if any.
Deadline() (deadline time.Time, ok bool)
// Value returns the value associated with key or nil if none.
Value(key interface{}) interface{}
}
-
Done 方法在 Context 被取消或超时时返回一个 close 的 channel,close 的 channel 可以作为广播通知,告诉给 context 相关的函数要停止当前工作然后返回。
-
Err 方法返回 context 为什么被取消。
-
Deadline 返回 context 何时会超时。
-
Value 返回 context 相关的数据
根 Context 创建方法
顶部的Goroutine应有办法主动关闭其下属的Goroutine的执行(不然程序可能就失控了)。为了实现这种关系,Context结构也应该像一棵树,叶子节点须总是由根节点衍生出来的。
有两种方式创建根 Context:
context.Background()
context.TODO()
context.Background()
创建根 context 的第一种方式。
在顶层 goroutine 中通过调用 context.Background()
可以返回一个空 Context,这个 Context 是 所有 Context 的 root,不能够被cancel。
ctx, cancel := context.WithCancel(context.Background())
context.TODO()
创建根 context 的第二种方式。
一般情况使用 Background()
方法创建根 context。TODO()
用于当前不确定使用何种 context,留待以后调整。
注意:不要传递 nil 的 context,在不确定使用何种 context 的时候应该使用 context.TODO()
子 Context 派生方法
父 context 被 cancel,那么它的派生 context 都会收到 cancel 信号,即 Done() 返回的 channel 读到数据。
有四种方法派生 context :
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithValue(parent Context, key, val interface{}) Context
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
context.WithCancel()
最常用的一种 context 派生方式,接收一个父 context(可以是 background context,或其它 Context),返回一个派生的 context 以及一个用于控制的 cancel 函数对象。
WithCancel 返回一个继承的 context,这个 context 在父 context 的 Done 被关闭时关闭自己的 Done 通道,或者在自己被 Cancel 的时候关闭自己的 Done。(注意:读关闭的 channel 返回类型零值)
WithCancel 同时还返回一个取消函数 cancel,这个 cancel 用于取消当前的 Context,在父任务执行 cancel()
时,接收 context 的所有 goroutine 会从 Done() 返回的通道中读取到值从而退出。
func job() {
ctx, cancel := context.WithCancel(context.Background())
go doSomething(ctx)
time.Sleep(5 * time.Second)
cancel()
}
func doSomething(ctx context.Context) {
for {
time.Sleep(1 * time.Second)
select {
case <-ctx.Done():
fmt.Println("done")
return
default:
fmt.Println("working")
}
}
}
context.WithValue()
派生一个携带信息的 context 用于传递。
比如在 Request 中携带认证信息,携带用户数据等。
WithValue(parent Context, key, val interface{})
方法的参数包含三个部分:
- parent,用于派生子 context 的父 context;
- key,携带信息的 key,interface{} 类型;
- value,携带信息的 value,interface{} 类型,通常在接收到信息后通过断言(
.(T)
)将 value 转换成正确的类型使用;
接收 context 携带的信息可以使用 ctx.Value(K)
接收到 value(interface{}类型)
context.WithTimeout()
派生一个带有超时机制的 context。
达到 Timeout 时长后,该 context 以及该 context 的子 context 会收到 cancel 信号退出。
当然,如果在 Timeout 时长内调用 cancel,则会提前发送 cancel 信号退出。
ctx, cancel := context.WithTimeout(parentCtx, 5*time.Second)
context.WithDeadline()
派生一个带有绝对时限的 context,与 WithTimeout()
作用基本相同,仅仅是时间设定方式上不同。
达到 deadline 设定的时间后,该 context 以及该 context 的子 context 会收到 cancel 信号退出。
当然,如果在 deadline 之前调用 cancel,则会提前发送 cancel 信号退出。
ctx, cancel := context.WithDeadline(parentCtx, time.Now().Add(5*time.Second))
层级 Context 间的传递与控制
- 生命周期:Context 对象的生命周期一般仅为一个请求的处理周期。即针对一个请求创建一个 Context 变量(它为 Context 树的根);在请求处理结束后,撤销此 ctx 变量,释放资源。
- 传递方式:每次创建一个 Goroutine,要么将原有的 Context 传递给 Goroutine,要么创建一个子 Context 并传递给 Goroutine。
- 安全读写:Context能灵活地存储不同类型、不同数目的值,并且使多个 Goroutine 安全地读写其中的值。
- 控制权:当通过父 Context 对象创建子 Context 对象时,可同时获得子 Context 的一个 Cancel 函数对象,这样就获得了对子任务的控制权。
使用原则
- 传递 Context 时,不应把 Context 放入 struct,而应该显式地传入函数,并且放在参数列表第一个位置,通常命名为 ctx;
- 不要传递 nil 的 Context,在不确定的时候应该传递
context.TODO()
; - 使用 context 的 Value 相关方法时只应该用于传递和请求相关的元数据,不要用它传递一些可选参数;
- 同一个 context 可以传递到不同的 goroutine 中,且在多个 goroutine 可以安全访问;