Go-上下文Context
上下文context
上下文context是Go中较独特的设计,其他编程语言中较为少见,主要用于在Goroutine之间传递请求的截止时间、取消信号和其他跨API边界的值。
context.Context
是Go在1.7版本中引入的标准库接口,定义了4个待实现的方法:
Deadline
:返回context.Context
被取消的时间,也就是完成工作的截止日期(如果Context
设置了截止时间,则返回ok=true
,deadline
返回该时间;如果没有设置截止时间,则返回ok=false
,deadline
为空)Done
:返回一个Channel
(在当前工作完成或者上下文被取消后关闭),作为对Context
关联函数的取消信号。当Channel
关闭时,关联函数终止工作并返回。多次调用
Done
方法会返回同一个 Channel;(可以通过select
监听该通道,在取消信号到来时,实现优雅退出)为什么
Context
不设置Cancel
函数呢?与
Done
通道设置为只读的原因一致:收到取消信号的函数callee,通常不是发送取消信号的函数caller。特别地,当一个父操作启动goroutines以执行子操作时,子操作不应该具备取消父操作的权力。caller 不应该去关心、干涉 callee 的情况,决定如何以及何时 return 是 callee 的责任。caller 只需发送取消信号,callee 根据收到的信息来做进一步的决策,因此接口并没有定义 cancel 方法,而是在canceler中定义。
Err
:返回context.Context
结束的原因,只会在Done
方法对应的 Channel 关闭时返回非空的值:- 如果
context.Context
被取消,会返回Canceled
错误; - 如果
context.Context
超时,会返回DeadlineExceeded
错误;
- 如果
Value
:从context.Context
中获取键对应的值,对于同一个上下文来说,多次调用Value
并传入相同的Key
会返回相同的结果,该方法可以用来传递请求特定的数据;
1 | type Context interface { |
Context
是线程安全的,可以被多个goroutine同时使用。
再来看看另一个接口canceler
:
1 | type canceler interface { |
源码中*cancelCtx
和*timerCtx
实现了canceler
接口:对应的Context时可取消的。
设计原理
目的:在Goroutine构成的树形结构中,对信号进行同步处理,以减少计算资源的浪费。
Go Concurrency Patterns: Context
Go 服务的每一个请求都是通过单独的 Goroutine 处理的,HTTP/RPC 请求的处理器会启动新的 Goroutine 访问数据库和其他服务。当一个请求被取消或者超时,所有面向该请求工作的goroutine将快速退出,以便系统回收资源。
如下图所示,我们可能会创建多个 Goroutine 来处理一次请求,而 context.Context
的作用是在不同 Goroutine 之间同步请求特定数据、取消信号以及处理请求的截止日期。
每一个 context.Context
都会从最顶层的 Goroutine 一层一层传递到最下层。context.Context
可以在上层 Goroutine 执行出现错误时,将信号及时同步给下层,停掉无用的工作以减少额外资源的消耗:
核心接口与类
context.cancelCtx
context.cancelCtx
结构体实现了接口canceler
:
1 | type cancelCtx struct { |
有一个关键问题:调用cancel
方法时,如何设置参数removeFromParent
呢?也即:什么时候传true
?什么时候传false
?
看看removeChild
函数:
1 | // removeChild 将当前Context从它的父Context的children列表中删除 |
什么时候会传true
呢?调用WithCancel()
时。
1 | return &c, func() { c.cancel(true, Canceled) } |
当新创建一个可取消的Context
时,返回的cancelFunc
需要传入true
:当调用返回的cancelFunc
时,将调用removeChild
函数,将该Context
从其父Context
的children
列表中删除。
以下是一棵Context树:
当调用标红
Context
的cancel
方法后,该Context
将从它的父Context
的children
列表中删除,实线变成虚线;虚线框内的Context
也均被取消,父子关系消失。
派生的 Context
context
包提供了从现有的 Context
值派生新 Context
值的函数。这些派生的 Context
形成了一棵树:当一个 Context
被取消时,所有从它派生的 Context
也会被取消。
默认上下文:context.Background
Background
是任何Context
树的根,永远不会被取消,也没有Value
和Deadline
。(通常用于main, init和测试函数,或者作为传入请求的顶级Context
)
context
包中最常用的方法还是 context.Background
、context.TODO
,这两个方法都会返回预先初始化好的私有变量 background
和 todo
,它们会在同一个 Go 程序中被复用:
这两个私有变量都是通过
new(emptyCtx)
语句初始化的,它们是指向私有结构体context.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
}
context.emptyCtx
通过空方法实现了context.Context
接口中的所有方法,它没有任何功能。
从源代码看,context.Background
和 context.TODO
只是互为别名,没有太大的差别,只是在使用和语义上稍有不同:
context.Background
是上下文的默认值,所有其他的上下文都应该从它衍生出来;context.TODO
应该仅在不确定应该使用哪种上下文时使用;
取消信号:context.WithCancel()
context.WithCancel
函数用于从父Context
中衍生出一个新的子Context
,并返回父Context
的副本。一旦我们父Context
的Done
通道关闭/cancel被调用,父Context
以及它的子Context
都会被取消,所有的 Goroutine 都会同步收到这一取消信号。
看看context.WithCancel
函数的实现:
1 | func WithCancel(parent Context) (ctx Context, cancel CancelFunc) { |
context.newCancelCtx
将传入的Context
包装成私有结构体context.cancelCtx
;context.propagateCancel
会构建父子Context
之间的关联,当父Context
被取消时,子Context
也会被取消;共包含3种情况:- 当
parent.Done()==nil
时,也即parent
不会触发取消信号时,当前函数直接返回; - 当
child
的继承链包含可以取消的Context
时,判断parent
是否已经触发取消信号:- 如果已经被取消,
child
会立刻被取消; - 如果没有被取消,
child
会被加入parent
的children
列表中,等待parent
释放取消信号;
- 如果已经被取消,
- 在默认情况下:
- 运行一个新的 Goroutine 同时监听
parent.Done()
和child.Done()
两个 Channel:在parent.Done()
关闭时调用child.cancel
取消子上下文;
- 运行一个新的 Goroutine 同时监听
- 当
1 | func propagateCancel(parent Context, child canceler) { |
propagateCancel
方法的意义是什么呢?
不断向上寻找可以“挂靠”的“可取消”Context
,并“挂靠”上去。这样,调用上层cancel
方法的时候,就可以层层传递,将那些挂靠的子Context
同时取消。看看查找的关键函数:
1 | func parentCancelCtx(parent Context) (*cancelCtx, bool) { |
- 如果没有找到可“挂靠”的
Context
(第3种情况),那理论上case <-parent.Done()
永远不会发生,岂非有些多余?
进入Value
函数看看:从context链中查找key
对应的值:
1 | func (c *valueCtx) Value(key any) any { |
发现Value
只能识别一些原生的Context
类型,如果采用自定义的Context
类型作为父Context
,不能匹配。此时Go 会新启动一个协程来监控取消信号。
为啥第3种情况里
select
语句的两个case
都不能删?1
2
3
4
5select {
case <-parent.Done():
child.cancel(false, parent.Err())
case <-child.Done():
}1. 第一个`case`如果去掉,那么父`Context`的取消信号无法传递给子`Context`了; 2. 第二个`case`是说如果子`Context`自行取消,则退出该`select`,不用再等父`Context`的取消信号(否则父`Context`一直不取消,造成goroutine泄漏)
定时取消信号:context.WithTimeout()
context.WithDeadline
在创建 context.timerCtx
的过程中判断了父Context
的Deadline
当前Deadline
(新的 Context
的 Deadline
是当前时间加上超时时间,与父Context
的Deadline
做比较,取更早的一个);并通过 time.AfterFunc
创建定时器,当时间超过了截止日期后会调用 context.timerCtx.cancel
同步取消信号。
1 | func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) { |
context.timerCtx
内部不仅通过嵌入context.cancelCtx
结构体继承了相关的变量和方法,还通过持有的定时器timer
和截止时间deadline
实现了定时取消的功能:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23 type timerCtx struct {
cancelCtx
timer *time.Timer // Under cancelCtx.mu.
deadline time.Time
}
func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
return c.deadline, true
}
func (c *timerCtx) cancel(removeFromParent bool, err error) {
c.cancelCtx.cancel(false, err)
if removeFromParent {
removeChild(c.cancelCtx.Context, c)
}
c.mu.Lock()
if c.timer != nil {
c.timer.Stop()
c.timer = nil
}
c.mu.Unlock()
}
WithTimeout
非常适用于设置与后端服务器的请求超时。当希望在一定时间内没有响应就取消请求时,可以使用 WithTimeout
。
传值:context.WithValue()
context
包中的 context.WithValue
能从父Context
中创建一个子Context
,传值的子Context
使用 context.valueCtx
类型:
1 | // WithValue 返回父 Context 的副本,父 Context 的 Value 方法对于 key 返回 val |
context.valueCtx
结构体会将除了Value
之外的Err
、Deadline
等方法代理到父上下文中,它只会响应context.valueCtx.Value
方法:
1
2
3
4
5
6
7
8
9
10
11 type valueCtx struct {
Context // 父Context
key, val interface{}
}
func (c *valueCtx) Value(key interface{}) interface{} {
if c.key == key {
return c.val
}
return c.Context.Value(key) // 向父Context中不断回溯查找
}如果
context.valueCtx
中存储的键值对与context.valueCtx.Value
方法中传入的参数不匹配,就会从父Context
中查找该键对应的值,直到某个父Context
中返回nil
或者查找到对应的值。
WithValue
允许将值与 Context
关联,这些值在请求的生命周期内是有效的,并且线程安全地共享。通常在 web 服务中,WithValue
被用来在请求处理中传递用户信息或其他与请求相关的数据。
一个栗子
创建一个过期时间为1s的Context
,并传入handle
函数,该方法使用500ms处理传入的请求:
1 | func main() { |
由于超时时间1s>处理时间500ms,因此打印内容为:
1
2
3$ go run context.go
process request with 500ms
main context deadline exceededhandle
函数没有进入超时的select
分支,但是main
函数的select
却会等待context.Context
超时并打印出main context deadline exceeded
。如果设置处理时间=1500ms>超时时间1s,整个程序将因Contxt终止而终止,打印内容为:
1
2
3$ go run context.go
main context deadline exceeded
handle context deadline exceeded注:有几率不打印"handle context deadline exceeded",因为main 协程已经退出了,handle 被强制退出了。此时需要main中sleep一会儿。
一些官方文档的建议
- 不要将
Context
塞到结构体里。直接将Context
类型作为函数的第一参数,而且一般都命名为 ctx; - 不要向函数传入一个 nil 的 context,如果你实在不知道传什么,标准库准备好了一个 context:
todo
; - 不要把本应该作为函数参数的类型塞到 context 中,context 存储的应该是一些共同的数据。例如:登陆的 session、cookie 等;
- 同一个 context 可能会被传递到多个 goroutine,别担心,context 是并发安全的。