文章目录
前言
- 本文基于
go1.18
- 本文使用
context
代表context
包,Context
代表context.Context
接口并泛指环境对象
为什么需要context
场景举例
context
是在go1.7
中引入的,为什么需要context
呢,首先考虑下面一个简单但常见的业务场景:服务端接收到并处理客户端的一个请求,处理过程又是通过多个下游服务组成的,并且为了加速处理过程提高响应速度,为每个下游服务单独开启一个协程并行处理
存在问题
上述过程听起来非常自然,但是难免会遇到某个下游服务在业务高峰期中响应变慢,而同事又有新请求需要使用这个下游服务,从而导致等待这个下游服务的协程越来越多,内存占用增高,最终导致服务不可用
解决思路
为了解决这样的情况,自然而然就想要对下游服务设置timeout
(这里先不考虑更复杂的限流处理),当超过这个timeout
后下游服务就超时返回,即关闭处理这个下游服务的协程。而在go
中无法在协程外部直接关闭协程,而是用chan+select
的方式,通过协程通信来通知协程关闭。但是上面所说的timeout
超时返回或上游由于其他原因主动要求下游结束的需求,都需要除了chan+select
额外的代码来实现,当服务的调用更加复杂的时候,单纯基于chan+select
的方式就会非常麻烦。于是聪明的Gopher
想到了用context
包来封装这些常用的需求,是的你没有听错,只是封装,context
的实现依然是基于chan+select
,但其设计又十分巧妙,以至于可以被加入标准库中,并应对各种各样的业务场景。
context解决方案
context
这个名字也取得很合理,“上下文”/“环境”,其基本思想与步骤如下(用字母代表不同的协程/服务):
- A需要调用B,并且需要控制B的生命周期(比如需要中途杀死B)
- 那么A只需要创建一个环境,让B置身运行在这个环境当中
- 当A想要中途结束B的运行,只需要结束上述环境的运行即可
上面是基本的使用思路,手动用chan+select
肯定也能轻松做到,但context
还提供了以下额外功能:
- 给环境设置
timeout
,使得超时自动结束环境的运行 - 环境的继承,即父环境结束后,子环境自动结束
- 环境可以携带额外的数据
第一个功能很好理解
第二个功能首先需要知道,Context
一定是通过父Context
来创建的,不存在没有父Context
的Context
(除了context包自带的全局环境,但该环境是为了方便我们创建新环境而存在的)。然后考虑下面一个业务场景:下游服务调用顺序为A->B->C,其中A需要控制B的生命周期,B需要控制C的生命周期,因此除了A需要创建Context
传入B中,B也需要通过传入的环境作为父Context
创建一个新环境并传入C中,作为C的环境。此时若A结束了传入B的环境,此时传入C的环境也会被结束。设想一下如果单纯用chan
来实现这个需求,传入B和传入C的两个chan
并没有任何联系,B收到结束的请求后,需要手动通知C也结束,而使用Context
的继承性就能自动结束子环境的运行!
第三个功能比较少用到,而且一般也不建议使用。所谓携带额外的数据指的是,创建Context
时传入一个键值对,类型为any
,以后可以通过Context.Value(key)
来取出该值。
context包的使用
接口和方法定义一览
理解了context
的思想后,context
包就很容易上手。首先来看下Context
接口的定义
type Context interface {
// 返回超时时间点,如果没有设置超时时间点,ok=false
Deadline() (deadline time.Time, ok bool)
// 返回一个chan,若Context已经被取消则<-chan语句不阻塞。Done()返回nil表示该Context不会主动被取消
// 但可能会由于继承关系,随着父Context取消而被动取消
Done() <-chan struct{}
// 只有两种返回值:Canceled表示被取消,DeadlineExceeded表示超时
Err() error
// 根据键返回Context携带的数据
Value(key any) any
}
如何创建Context
呢,有四个构造函数可以用,另外由于Context
必须从父Context
创建而来,因此可以看到所有的构造函数第一个参数都需要传入父Context
// 创建一个可以被显式取消的Context
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
// 创建一个可以被显式取消的,并且有超时取消的Context,其传入一个超时时间点
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
// 与WithDeadline一样,只不过后者传入时间点,后者传入timeout
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
// 创建一个不可以被显式取消的,并且携带数据的Context
func WithValue(parent Context, key, val any) Context
-
可以显式取消的意思是返回一个
CancelFunc
类型的变量,其实就是一个没有参数没有返回值的函数,定义如下type CancelFunc func()
需要取消的时候调用
cancel()
就可以了 -
超时取消也不难理解,到达指定时间点时,相当于
Context
内部自动调用cancel()
,不同的在于Err()
返回DeadlineExceeded
关于父Context
:假设我们创建Context
的时候没有可以依赖的父Context
,那么创建的时候应该传入哪个Context
呢?答案是context.Background()
,如果把程序所有创建的Context
想象成一个个节点,所有节点就可以组成一颗树,那么这个context.Background()
返回的就是树的根节点:
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 any) any {
return nil
}
var (
background = new(emptyCtx)
todo = new(emptyCtx)
)
func Background() Context {
return background
}
func TODO() Context {
return todo
}
可以看到emptyCtx
类型实际上是一个实现了Context
接口的int
,并且实现的方法里面什么都没有做,即不可取消、没有携带值、没有超时,其有两个实例background
和todo
,前者只是用来创建新Context
的,作为所有Context
的根节点,后者是当你还不能明确用哪个Context
作为父Context
,那么就使用context.TODO()
,先把程序运行起来,以后再修改。
使用例子
本小节最后来看下Context
的简单使用吧~
func main() {
ctx, cancel := context.WithCancel(context.Background())
go watch(ctx,"【监控1】")
go watch(ctx,"【监控2】")
go watch(ctx,"【监控3】")
time.Sleep(10 * time.Second)
fmt.Println("可以了,通知监控停止")
cancel()
//为了检测监控过是否停止,如果没有监控输出,就表示停止了
time.Sleep(5 * time.Second)
}
func watch(ctx context.Context, name string) {
for {
select {
case <-ctx.Done():
fmt.Println(name,"监控退出,停止了...")
return
default:
fmt.Println(name,"goroutine监控中...")
time.Sleep(2 * time.Second)
}
}
}
context包解析
在context
包的使用中简单介绍了Context
接口、构造函数、background/todo
。下面就来掀开Context
接口几个标准实现的真面目
emptyCtx
就是如上所说的,emptyCtx
类型实际上是一个实现了Context
接口的int
,其只有两个有专门用途的实例:background
和todo
cancelCtx
这个类型表示可主动取消的Context
,定义如下:
type cancelCtx struct {
Context // 父Context
mu sync.Mutex // 用来保护以下字段的访问
done atomic.Value // 作为Done()的返回值
children map[canceler]struct{} // 子Context
err error // 作为Err()的返回值
}
创建cancelCtx
的方法是:
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
if parent == nil {
panic("cannot create context from nil parent")
}
c := newCancelCtx(parent)
propagateCancel(parent, &c)
return &c, func() { c.cancel(true, Canceled) }
}
创建流程如下为
- 创建
cancelCtx
对象并设置父节点 - 调用
propagateCancel
继承父节点的取消机制,换个说法就是把取消传递到child
节点,也就是如果父节点已经取消或者将来要取消,该节点也会被取消,这个函数稍后讲解 - 返回创建好的对象与用于取消的
CancelFunc
函数,函数体调用的c.cancel
稍后讲解
cancelCtx
实现了Context
接口和canceler
接口,下面先来看看实现这两个接口里的几个方法
func (c *cancelCtx) Value(key any) any {
if key == &cancelCtxKey {
return c
}
return value(c.Context, key)
}
Value
比较直观,cancelCtxKey
是内部用于方便获取节点直到祖先节点路径上最近的那个可取消节点的,和之前所说的携带数据的特性实际上没什么关系,不是重点。如果key
不是cancelCtxKey
,那么将返回父节点的value
(因为cancelCtx
不携带数据,所以数据从父节点获取),其中value
函数可以简单看下:
func value(c Context, key any) any {
for {
switch ctx := c.(type) {
case *valueCtx:
if key == ctx.key {
return ctx.val
}
c = ctx.Context // 找到父节点
case *cancelCtx:
if key == &cancelCtxKey {
return c
}
c = ctx.Context // 找到父节点
case *timerCtx:
if key == &cancelCtxKey {
return &ctx.cancelCtx
}
c = ctx.Context // 找到父节点
case *emptyCtx:
return nil // background/todo
default:
return c.Value(key) // 自定义的Context结构
}
}
}
这里还是比较好理解的,c
记录当前遍历到的节点,通过更新c
往c
的父节点层层遍历,找到携带数据的那个祖先节点并返回数据,如果一路上都没有携带数据的节点,若根节点是background
,函数最终返回nil
然后来看看Done()
方法:
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{})
}
cancelCtx.done
的类型是atomic.Value
,了解它的Load
和Store
都是并发安全的即可。Done()
方法也很明了,正如之前所说的done
使用懒汉式创建,先获取done
的值,若不是nil
则直接返回,否则加锁创建chan struct{}
并赋值给done
。在旧版本go的代码中done
的类型是chan struct{}
,这样在每次调用Done()
时都需要加锁检查done
是否已经赋值:
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
}
但考虑到done
是一次Store
多次Load
,因此新版本中,对done
取值使用了相比加锁效率更高的atomic.Value.Load
,了解一下即可
另外还有Err()
方法比较简单,可以自己看源码
最后是canceler
接口里的cancel
方法实现,其用于取消自己本身和取消所有子节点:
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)
}
}
cancel
传入的两个参数,第一个removeFromParent
表示被取消节点是否与其父亲断开,第二个参数是被取消的原因(通过error
表示)
执行流程如下:
- 用
if c.err!=nil
判断节点是否已经被取消,如果被取消了,就直接返回 c.err=err
记录被取消的原因- 关闭
done
,因为done
是在Done()
方法中懒汉式创建,所以done
可能为空,那么这里无论如何done channel
都是被关闭的状态,于是为了执行效率就不再创建新chan
,而是用一个已经预先创建好的并关闭的chan
给done
赋值,即closedchan
- 递归取消所有挂载的子节点,并将
children
置空与子节点断开 - 根据
removeFromParent
决定是否要把节点与父节点断开
有一个细节问题需要注意,传入的参数removeFromParent
什么时候传false/true
,首先看一下removeChild
函数:
func removeChild(parent Context, child canceler) {
p, ok := parentCancelCtx(parent)
if !ok {
return
}
p.mu.Lock()
if p.children != nil {
delete(p.children, child) // 注意看这行
}
p.mu.Unlock()
}
上面delete(p.children, child)
意思是把本节点从父节点的子节点列表中删除,这个操作不会影响其他的子节点。然后我们回到cancel
方法递归取消的这一行:child.cancel(false, err)
传入的是false
,原因是节点已经通过c.children=nil
与子节点断开(就如同执行流程第4点中的描述),所以子节点取消的时候不用再多此一举再把自身与父节点断开
但是再回到WithCancel
构造函数中,CancelFunc
中执行的是c.cancel(true, Canceled)
,这个时候为什么又要传true
使得本节点主动与父节点断开呢?因为这是本节点要求主动取消的,并不会影响父节点的状态,更不会影响兄弟节点的状态,说明本节点要主动将其从父节点的引用列表中移除。因此可以总结出只有节点要求主动与父节点断开的情况,比如外界调用CancelFunc
、超时取消,才传入true
。context
包内还有其他几个地方也调用了c.cancel
函数,想必就能举一反三地想清楚为什么有的地方传true
有的地方是false
了
最后我们再回过头看WithCancel
构造函数流程中的propagateCancel
函数,其实现本节点继承父节点的取消机制
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:
}
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 {
atomic.AddInt32(&goroutines, +1)
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err())
case <-child.Done():
}
}()
}
}
函数执行流程如下:
- 若
parent.Done()=nil
说明父节点不可以被取消,则返回 - 通过检查
parent.Done()
返回的chan
是否被关闭来检查父节点是否已经取消,如果已经取消,将本节点也取消并返回,此时子节点还没挂载到父节点上,因此child.cancel
传入false
。否则继续执行下面的代码 - 通过
parentCacnelCtx
函数(稍后讲解)尝试将父节点转换为可取消的Context
类型即cancelCtx
,如果转换成功,则加锁并通过父节点的err
成员检查是否已经取消(这里再次检查是否已经取消是因为上一次检查到这次检查的这段时间内可能会因为没有加锁而被取消),如果已经取消,则把本节点也取消即可,否则将本节点挂载到父节点的引用列表,以便父节点在将来取消的时候能够发现该子节点并将其取消 - 如果上一步没有转换成功,但也不能说明父节点在不会被取消,因此需要开启一个监听父节点取消的协程,从而将子节点取消。而第二个
case
是因为本节点可能比父节点先取消,所以也需要监听本节点是否被取消,防止上一个case
持有child
的引用而泄漏,就算不泄漏,再次把child
取消也是多此一举
propagateCancel
函数实现了“传递取消”这个十分重要的机制,上面已经把实现流程梳理了一遍,还有个细节问题:parentCancelCtx
函数的实现
func parentCancelCtx(parent Context) (*cancelCtx, bool) {
done := parent.Done()
if done == closedchan || done == nil {
return nil, false
}
p, ok := parent.Value(&cancelCtxKey).(*cancelCtx)
if !ok {
return nil, false
}
pdone, _ := p.done.Load().(chan struct{})
if pdone != done {
return nil, false
}
return p, true
}
函数执行流程如下:
-
如果节点不可取消或者已关闭则返回失败,否则继续执行
-
通过
Value(&cancelCtxKey).(*cancelCtx)
将节点转换成cancelCtx
派生类型,若转换失败则返回失败 -
最后一步可能是最困惑的地方:为什么要将
parent.Done()
返回的chan
与p.done.Load()
返回的chan
进行比较,并且不相等的话就返回失败呢?是因为context
包的设计者考虑到的情况如下:cancelCtx
被嵌入到用户Context
里,并且重载了Done()
方法!type MyContext struct { Context } func (c *MyContext) Done() <-chan struct{} { return otherDone // 返回了别的channel,而不是cancelCtx里的done channel }
在这种情况下,
Done()
就不会返回cancelCtx
中的done
了,也就是这个用户定制的Context
的是否取消不再依赖于done
,而cancelCtx
内部的cancel
函数、children
都和done
有关,因此如果Done()!=done
,我见识不多,于是先不考虑这个脑回路清奇的“用户”为什么这么做,总之children
、cancel
等私有成员都已经没有用了,因为他们都依赖于done
。再看一下parentCancelCtx
函数在哪几个地方被调用:removeChild
函数和propagateCancel
函数这两个地方,我这里直接再复制出关键代码来看看:func propagateCancel(parent Context, child canceler) { ... if p, ok := parentCancelCtx(parent); ok { // 这行 p.mu.Lock() if p.err != nil { child.cancel(false, p.err) } else { if p.children == nil { p.children = make(map[canceler]struct{}) } p.children[child] = struct{}{} } p.mu.Unlock() } else { ... } } func removeChild(parent Context, child canceler) { p, ok := parentCancelCtx(parent) // 还有这行 if !ok { return } p.mu.Lock() if p.children != nil { delete(p.children, child) } p.mu.Unlock() }
可以看到通过
parentCancelCtx
函数获取到ctxCancel
后,要使用他的私有成员比如children
,这样就和上面说的对应起来了!done!=Done()
时children
已经没有意义了,因此不需要挂载节点到MyContext
的children
上,这样就很清晰为什么当done!=Done()
时parentCancelCtx
返回转换失败的结果了!源码对于这个函数的注释也只是简单的说我们不能忽略这种情况而没有说明为什么:“If not, the *cancelCtx has been wrapped in a custom implementation providing a different done channel, in which case we should not bypass it.”
经过上面分析,可以知道context
包对于协程的取消机制还是挺完善的。除非收益巨大,不然最好不要自定义Context
,因为可以看到propagateCancel
中对于非标准的Context
实现类采取的是开一个goroutine
来监听取消,本来业务代码己经 goroutine
满天飞了,不加节制的使用只会增加系统负担
那么Context
最重要的一个实现:cancelCtx
,到这里已经讲解完毕了,下面两个Context
实现类都非常简单
timerCtx
先看下timerCtx
的定义:
type timerCtx struct {
cancelCtx
timer *time.Timer // Under cancelCtx.mu.
deadline time.Time
}
可以看到timerCtx
是基于cancelCtx
的,另外带了一个timer
和deadline
帮助实现超时机制
构造函数有两个:WithDeadline
和WithTimeout
,其中WithTimeout
基于前者,就不放代码了,自己看看也很好理解,主要看一下WithDeadline
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)
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() {
c.cancel(true, DeadlineExceeded)
})
}
return c, func() { c.cancel(true, Canceled) }
}
创建执行流程如下:
- 检查
parent
的deadline
如果在节点的deadline
之前,那么parent
将会比节点更早超时取消,这时节点的deadline
就没有意义了,因此相当于创建cancelCtx
- 调用
propagateCancel
传递父节点的取消机制 - 检查
deadline
是否在now
之前,如果是的话直接对节点执行超时取消 - 检查节点如果没被取消的话,就调用
time.AfterFunc
设置定时任务,即时间到达deadline
后取消该节点 - 返回结果
由于之前cancelCtx
的铺垫,创建过程理解起来非常简单。看下它实现的几个方法,String()
和Deadline()
方法几乎就是返回成员对象,就不说了。简单分析剩下的cancel()
方法
func (c *timerCtx) cancel(removeFromParent bool, err error) {
c.cancelCtx.cancel(false, err)
if removeFromParent {
// Remove this timerCtx from its parent cancelCtx's children.
removeChild(c.cancelCtx.Context, c)
}
c.mu.Lock()
if c.timer != nil {
c.timer.Stop()
c.timer = nil
}
c.mu.Unlock()
}
首先调用了内嵌cancelCtx
的取消方法,注意到把removeChild
的操作单独拎了出来,是因为cancelCtx
只是内嵌对象,真正被父节点引用的是timerCtx
本身。最后通过timerCtx.timer
将定时任务停止,因为节点已经取消了,所以同样用来的取消的定时任务就没有用了,需要停掉
回过头看一下timerCtx.timer
是通过time.AfterFunc
创建的time.Timer
类型对象,用来控制定时任务的取消和重置
valueCtx
分析代码之前首先说明一点,除了框架层不要使用 WithValue
来携带业务数据,因为键值对的类型是 any
, 编译期无法确定,运行时assert
有开销。如果真要携带也要用 thread-safe 的数据
type valueCtx struct {
Context
key, val any
}
看一下构造方法:
func WithValue(parent Context, key, val any) Context {
if parent == nil {
panic("cannot create context from nil parent")
}
if key == nil {
panic("nil key")
}
if !reflectlite.TypeOf(key).Comparable() {
panic("key is not comparable")
}
return &valueCtx{parent, key, val}
}
这里只需要注意一下传入的key
必须是可比较的,并且不要用string
、int
等built-in
类型作为key
以防止不同的包使用同一个Context
时发生冲突
valueCtx
实现的Value
方法也非常简单,可以自行查阅源码
小结
这个小节分析了标准context
包里的几个实现类:emptyCtx
、cancelCtx
、timerCtx
和不常用的valueCtx
。写这篇文章越写到后面思路越发清晰:
emptyCtx
用于background/todo
用作全局和临时节点cancelCtx
主要实现了cancel
、Done
这两个方法,实现的是取消机制timerCtx
主要实现了Deadline
方法和内嵌cancelCtx
,实现的是超时机制valueCtx
主要实现了Value
方法,实现了携带数据的功能
而各个实现类没实现的方法,都通过委托或者说继承的方式,委托给了内嵌的Context
对象,并且内嵌Context
对象又扮演了父节点的角色,比如从timerCtx
创建而来的cancelCtx
,其Deadline
方法返回的其实是其父亲的deadline
。在这点上context
的设计者完美结合了context
的设计理念和golang
的语法机制