Go 并发控制:context 源码解读

教育   2024-12-15 21:56   陕西  

欢迎点击下方👇关注我,记得星标哟~

文末会有重磅福利赠送


context 是 Go 语言的特色设计之一,主要作用有两个:控制链路安全传值,并且 context 是并发安全的。context 在 Go 1.17 版本被引入,经过数年的迭代,在设计和用法上已经趋于稳定,本文以最新的 Go 1.23.0 版本源码为基础,带你深入理解 context 的设计和实现。

context 设计

context 被设计为一个接口,名为Context。为了支持不同特性,这个接口有多种结构体实现。而每个结构体又提供了一个或多个 exported 函数(大写字母开头的公开函数)作为构造函数来实例化 context 对象。

我画了一张 context 的设计架构图如下:

context 设计架构


这张图包含了 context 中最核心的对象和它们之间的关系,我们来简单梳理下这张图,为稍后的源码阅读打下基础。

  • Context 接口:这是 context 最基本的抽象,定义了一个 context 对象应该支持哪些行为。它被设计为 exported,所以我们也可以实现自定义的 context 对象。
  • 实现Context 接口的结构体:为了实现 context 的控制链路和安全传值两大特性,context 包提供了多种Context 接口的实现。
    • emptyCtx 表示一个空的 context 实现,没有控制链路的能力,也没有安全传值的功能。不过它作为最基础的 context 实现,可以算是其他 context 实现的“基类”了。backgroundCtxtodoCtx 包装了emptyCtx,不过二者并没有扩展什么功能,只是表明了语义,它们通常作为整个 context 链路的起点。
    • cancelCtx 是一个带有取消功能的 context 实现,所以它拥有控制链路的能力。timerCtxafterFuncCtx 都是在cancelCtx 的基础上来实现的。
    • withoutCancelCtx 从命名上也能看出,它和cancelCtx 正相反,没有取消功能,在实现上与emptyCtx 差不多。
    • valueCtx 见名之意,是用来进行安全传值的。
    • 最后还有一个stopCtx 实现,它比较特殊,没有提供构造函数,目前来看并不是 context 的核心对象。
  • exported 函数:因为所有的 context 实现都是unexported 类型,所以就需要exported 类型的函数来创建 context 对象供我们使用。
    • Background() 是使用的最多的函数了,它构造一个backgroundCtx 对象并返回,通常作为 context 树的根节点。
    • TODO() 函数当然就是用来构造todoCtx 对象的构造函数了,同样会作为 context 树的根节点。当我们不知道该用哪个 context 对象时,就用它。
    • WithCancel()WithCancelCause() 都用来构造并返回cancelCtx 对象,二者唯一的区别就是构造对象时是否传入根因
    • WithDeadline()WithDeadlineCause() 用于构造一个cancelCtxtimerCtx 对象。它们可以接收一个time.Time 用来指定 context 对象被取消的时间,到期时会被自动取消
    • WithTimeout()WithTimeoutCause() 都接收一个time.Duration 来指定多长时间之后 context 对象被取消。WithTimeout() 内部调用了WithDeadline(),而WithTimeoutCause() 内部则调用了WithDeadlineCause()
    • WithoutCancel() 用于构造并返回withoutCancelCtx 对象。
    • WithValue() 用于构造并返回valueCtx 对象。
    • AfterFunc() 用于在 context 过期时异步的执行一个任务,它会构造一个afterFuncCtx 对象,但不返回它,而是返回一个停止函数,可以阻止异步任务执行。

以上,就简单梳理了 context 包最核心的设计框架。如果你不够熟悉 context,切记不要死记硬背,只需要多使用它就好了。你可以先收藏此文,用过 context 一段时间,再回来看本文的源码解析。

context 接口

Context 作为 context 包最核心的接口,其定义如下:

type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}

可以看到Context 只有 4 个方法,可谓大道至简。

  • Deadline() 方法返回该 context 应该被取消的截止时间,如果此 context 没有设置截止时间,则返回的ok 值为false
  • Done() 返回一个只读的 channel 作为取消信号,当 context 被取消时,此 channel 会被 close 掉。
  • Err() 方法返回 context被取消的原因,如果 context 还未取消,返回nil;如果调用cancel() 主动取消了 context,返回Canceled 错误;如果是截止时间到了自动取消了 context,返回DeadlineExceeded 错误。
  • Value() 方法返回与给定键(key关联的值value),如果没有与该key 关联的value,则返回nil

其中CanceledDeadlineExceeded 两个错误定义如下:

var Canceled = errors.New("context canceled")

var DeadlineExceeded error = deadlineExceededError{}

type deadlineExceededError struct{}

func (deadlineExceededError) Error() string   { return "context deadline exceeded" }
func (deadlineExceededError) Timeout() bool   { return true }
func (deadlineExceededError) Temporary() bool { return true }

这里采用了典型的 Sentinel error 用法。并且从deadlineExceededError 实现的方法来看,其鼓励行为断言而非类型断言

如果你对 Go 错误设计和处理不够熟悉,可以查看我的另一篇文章《Go 错误处理指北:如何优雅的处理错误?》

context 实现

接下来我们对Context 接口的具体实现进行逐一讲解。

emptyCtx

emptyCtx 是最基础的 context 实现,定义如下:

type emptyCtx struct{}

func (emptyCtx) Deadline() (deadline time.Time, ok bool) {
return
}

func (emptyCtx) Done() <-chan struct{} {
returnnil
}

func (emptyCtx) Err() error {
returnnil
}

func (emptyCtx) Value(key any) any {
returnnil
}

它确实“基础”,也确实 “empty”,所有实现都为空,没有代码逻辑,仅是一个 context 架子。

backgroundCtx 和 todoCtx

backgroundCtx 定义如下:

type backgroundCtx struct{ emptyCtx }

func (backgroundCtx) String() string {
 return "context.Background"
}

它内嵌了emptyCtx,也仅比emptyCtx 多实现了一个String() 方法。

todoCtx 实现同理:

type todoCtx struct{ emptyCtx }

func (todoCtx) String() string {
 return "context.TODO"
}
Background() 和 TODO()

我们在使用 context 时,往往会用context.Background()context.TODO() 来定义了最顶层 context,这两个方法实现如下:

func Background() Context {
 return backgroundCtx{}
}

func TODO() Context {
 return todoCtx{}
}

没错,最常用的 context 代码实现就是这么简单,它们是整个 context 链路的基础。

cancelCtx

cancelCtx 结构体定义如下:

type cancelCtx struct {
Context // “继承”的父 Context

mu       sync.Mutex            // 持有锁保护下面这些字段
done     atomic.Value          // 值为 chan struct{} 类型,会被懒惰创建,在第一次调用取消函数 cancel() 时被关闭,表示 Context 已被取消
children map[canceler]struct{} // 所有可以被取消的子 Context 集合,它们在第一次调用取消函数 cancel() 时被级联取消,然后置为 nil
err      error                 // 取消原因,在第一次调用取消函数 cancel() 时被设置值
cause    error                 // 取消根因,在第一次调用取消函数 cancel() 时被设置值
}

cancelCtx 直接内嵌了Context 接口,也就是说,它支持任意其他类型的 context 实现作为父上下文(parent context)。

前文说过,context 是并发安全的,所以cancelCtx 内部持有一把互斥锁,保证安全的操作结构体属性。

done 属性为atomic.Value 类型,是为了支持原子操作,使用它可以减少互斥锁的使用频率,稍后你将在Done() 方法中看到。它的值是chan struct{} 类型。

children 属性是一个集合,记录了当前 context 的所有子上下文(child context)。这样,父子 context 就产生了链路关系,以此为基础实现父 context 取消时,级联的取消所有子 context。

errcause 分别记录了 context 被取消的原因根因err 是 context 包内部产生的,cause 则是我们在使用WithXxxCause() 方法构造 context 对象时传入的。

这里涉及的canceler 定义如下:

type canceler interface {
cancel(removeFromParent bool, err, cause error) // 取消函数
Done() <-chan struct{}                          // 通过返回的 channel 能够知道是否被取消
}

它是一个接口,表示一个可以被取消的对象。也就是说,在 context 包中设计的支持取消的 context 类型都需要提供这两个方法。父 context 取消时会调用子 context 的cancel() 方法进行级联取消;并且有取消功能的 context 必须要实现Done() 方法,这样使用者才能通过监听 done channel 知道这个 context 是否被取消。

cancelCtxDone() 方法实现如下:

func (c *cancelCtx) Done() <-chan struct{} {
// 使用 double-check 来提升性能
d := c.done.Load() // 原子操作,比互斥锁更加轻量
if d != nil {      // 如果存在 channel 直接返回
 return d.(chanstruct{})
}
c.mu.Lock() // 如果不存在 channel,则要先加锁,然后创建 channel 并返回
defer c.mu.Unlock()
d = c.done.Load()
if d == nil { // 为保证并发安全,再做一次检查
 d = make(chanstruct{})
 c.done.Store(d)
}
return d.(chanstruct{})
}

这里使用了double-check 来提升程序的性能,这也是done 属性为什么被设计成atomic.Value 类型的原因。首先使用c.done.Load() 来判断标识 context 是否取消的chan struct{} 是否存在,存在则直接返回,不存在才会加锁创建。

cancelCtxcancel() 方法实现如下:

func (c *cancelCtx) cancel(removeFromParent bool, err, cause error) {
if err == nil {
 panic("context: internal error: missing cancel error")
}
if cause == nil { // 如果没有设置根因,取 err
 cause = err
}
c.mu.Lock()
if c.err != nil { // 如果 err 不为空,说明已经被取消,直接返回
 c.mu.Unlock()
 return
}

// NOTE: 只有第一次调用 cancel 才会执行之后的代码

// 记录错误和根因
c.err = err
c.cause = cause
d, _ := c.done.Load().(chanstruct{})
if d == nil { // 如果 done 为空,直接设置一个已关闭的 channel
 c.done.Store(closedchan)
} else { // 如果 done 有值,将其关闭
 close(d)
}
// 级联取消所有子 Context
for child := range c.children {
 // NOTE: 获取子 Context 的锁,同时持有父 Context 的锁
 child.cancel(false, err, cause)
}
c.children = nil// 清空子 Context 集合,因为已经完成了 Context 树整个链路的取消操作
c.mu.Unlock()

if removeFromParent { // 从父 Context 的 children 集合中移除当前 Context
 removeChild(c.Context, c)
}
}

这个方法用来取消cancelCtx,它接收 3 个参数,removeFromParent 表示是否要从父 context 的children 属性集合中移除当前的cancelCtxerrcause 则分别表示取消的错误原因和根因。

在第 9 行,因为使用了c.err != nil 来判断err 是否为空,如果不为空,说明 context 已经被取消,直接返回。所以,多次调用cancel() 方法效果相同。

当第一次调用cancel() 方法时会记录errcause。接着判断 done channel 是否存在,不存在就直接设置为一个已经关闭的 channel 对象closedchan;如果存在则调用close(d) 将其关闭。

接着,会遍历c.children 属性对当前cancelCtx 的所有子 context 进行级联取消,即依次调用它们的cancel() 方法。然后清空children 集合。

最终根据参数removeFromParent 的值决定是否要从父 context 的children 属性集合中移除cancelCtx

这里涉及的closedchan 定义如下:

// closedchan 表示一个已关闭的 channel
var closedchan = make(chan struct{})

// 导入 context 包时直接关闭 closedchan
func init() {
 close(closedchan)
}

在 context 包被导入时就直接关闭了。

removeChild() 函数的具体实现如下:

func removeChild(parent Context, child canceler) {
if s, ok := parent.(stopCtx); ok {
 s.stop()
 return
}
p, ok := parentCancelCtx(parent)
if !ok {
 return
}
p.mu.Lock()
if p.children != nil {
 delete(p.children, child)
}
p.mu.Unlock()
}

首先判断父 context 是否为stopCtx 类型,如果是,则调用其s.stop() 方法。关于stopCtx 类型暂时不必深究,后文中讲解*cancelCtx.propagateCancel() 方法时我会更详细的解释。

接着调用parentCancelCtx() 函数向上查找父 context 或其链路中是否存在*cancelCtx 对象,如果不存在,直接返回;如果存在,从其children 属性集合中移除当前 context。

parentCancelCtx() 函数实现如下:

func parentCancelCtx(parent Context) (*cancelCtx, bool) {
done := parent.Done()
if done == closedchan || done == nil {
 returnnil, false
}
p, ok := parent.Value(&cancelCtxKey).(*cancelCtx)
if !ok {
 returnnil, false
}
pdone, _ := p.done.Load().(chanstruct{})
if pdone != done {
 returnnil, false
}
return p, true
}

如果父 context 的Done() 方法返回closedchan,说明已经被取消了;如果返回nil,则说明父 context 永远不会被取消。这两种情况,都不必继续向上查找*cancelCtx 对象了,直接返回false 表示未找到。

接下来使用&cancelCtxKey 作为key 从父 context 中查找value,并且断言查找到的对象是否为*cancelCtx 类型,如果!ok 说明未找到,返回false;否则,说明找到的*cancelCtx

然后对*cancelCtx 进行进一步的检查,确保返回的*cancelCtx 的 done channel 与父 context 的 done channel 是匹配的,如果不匹配,说明*cancelCtx 已经被包装在一个自定义实现中,为了避免影响自定义 context 实现,这种情况下返回false 表示未找到;如果匹配,才返回*cancelCtx 对象和true 表示找到了。

cancelCtx 还实现了Context 接口的Value()Err() 方法:

func (c *cancelCtx) Value(key any) any {
// 使用 &cancelCtxKey 标记需要返回自身
// 这是一个未导出的(unexported)类型,所以仅作为 context 包内部实现的一个“协议”,对用户不可见
if key == &cancelCtxKey {
 return c
}
// 接着向上遍历父 Context 链路,查询 key
return value(c.Context, key)
}

func (c *cancelCtx) Err() error {
c.mu.Lock()
err := c.err
c.mu.Unlock()
return err
}

Err() 方法的实现非常简单,没什么好说的。

cancelCtx 实现了Value() 方法,这是为了实现一个特殊的“内部协议”。这个方法里有一个特殊的判断if key == &cancelCtxKey,如果成立,则不去查找给定key 所对应的value;如果不成立才调用value() 函数继续进行查找。

cancelCtxKey 就是一个普通的变量:

var cancelCtxKey int

上面介绍的parentCancelCtx() 函数中,之所以能够使用parent.Value(&cancelCtxKey).(*cancelCtx) 获取到*cancelCtx 对象,就是通过在Value() 方法中这个特殊的“协议”来实现的。

Value() 方法的实现来看,只要调用*cancelCtx.Value() 方法时传入&cancelCtxKey 作为查找的key,就返回*cancelCtx 对象本身。

注意:&cancelCtxKey 是一个unexported类型的指针变量,所以外部无法使用,只作为“内部协议”。

这个设计有点奇技淫巧的意思,不过却很有用。

这里涉及的value() 函数我们暂且不继续深究,后文再来讲解。

此外,cancelCtx 也实现了自己的String() 方法:

type stringer interface {
String() string
}

func contextName(c Context) string {
if s, ok := c.(stringer); ok {
 return s.String()
}
return reflectlite.TypeOf(c).String()
}

func (c *cancelCtx) String() string {
return contextName(c.Context) + ".WithCancel"
}
WithCancel() 和 WithCancelCause()

看完了cancelCtx 的实现,接下来看下我们如何构造一个cancelCtx

context 包提供了两种构造cancelCtx 的方法,分别是WithCancel()WithCancelCause()

WithCancel() 实现如下:

type CancelFunc func()

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := withCancel(parent)
 return c, func() { c.cancel(true, Canceled, nil) }
}

WithCancel() 根据给定的父 context 构造一个新的具有取消功能的cancelCtx 并返回,其核心逻辑是代理给withCancel() 函数去实现的。

WithCancelCause() 实现如下:

type CancelCauseFunc func(cause error)

func WithCancelCause(parent Context) (ctx Context, cancel CancelCauseFunc) {
c := withCancel(parent)
 return c, func(cause error) { c.cancel(true, Canceled, cause) }
}

WithCancelCause()WithCancel() 类似,但返回CancelCauseFunc 而不是CancelFunc。可以发现二者的唯一区别就是返回的函数是否支持设置 context 被取消的根因cause

那么接下来就看看withCancel() 函数是如何实现的:

func withCancel(parent Context) *cancelCtx {
 if parent == nil {
 panic("cannot create context from nil parent")
}
c := &cancelCtx{}            // 带取消功能的 Context
c.propagateCancel(parent, c) // 将新构造的 Context 向上传播挂载到父 Context 的 children 属性中,这样当父 Context 取消时子 Context 对象 c 也会级联取消
 return c
}

这个函数逻辑并不多,这里构造了一个cancelCtx 并返回,核心逻辑都交给了propagateCancel() 方法。

propagateCancel() 方法实现如下:

func (c *cancelCtx) propagateCancel(parent Context, child canceler) {
c.Context = parent // “继承”父 Context,这里可以是任何实现了 Context 接口的类型

// NOTE: 父 Context 没有实现取消功能
done := parent.Done()
if done == nil { // 如果父 Context 的 Done() 方法返回 nil,说明父 Context 没有取消的功能,那么无需传播子 Context 的 cancel 功能到父 Context
 return
}

// NOTE: 父 Context 已经被取消
select {
case <-done: // 直接取消子 Context,且取消原因设置为父 Context 的取消原因
 child.cancel(false, parent.Err(), Cause(parent))
 return
default:
}

// NOTE: 父 Context 还未取消
if p, ok := parentCancelCtx(parent); ok { // 如果父 Context 是 *cancelCtx 或者从 *cancelCtx 派生而来
 p.mu.Lock()
 if p.err != nil {
  // 如果父 Context 的 err 属性有值,说明已经被取消,直接取消子 Context
  child.cancel(false, p.err, p.cause)
 } else {
  if p.children == nil { // 延迟创建父 Context 的 children 属性
   p.children = make(map[canceler]struct{})
  }
  p.children[child] = struct{}{} // 将 child 加入到这个 *cancelCtx 的 children 集合中
 }
 p.mu.Unlock()
 return
}

// NOTE: 父 Context 实现了 afterFuncer 接口
if a, ok := parent.(afterFuncer); ok { // 测试文件 afterfunc_test.go 中 *afterFuncCtx 实现了 afterFuncer 接口
 c.mu.Lock()
 stop := a.AfterFunc(func() { // 注册子 Context 取消功能到父 Context,当父 Context 取消时,能级联取消子 Context
  child.cancel(false, parent.Err(), Cause(parent))
 })
 c.Context = stopCtx{ // 将当前 *cancelCtx 的直接父 Context 设置为 stopCtx
  Context: parent, // stopCtx 的父 Context 设置为 parent
  stop:    stop,
 }
 c.mu.Unlock()
 return
}

// NOTE: 父 Context 不是已知类型,但实现了取消功能
goroutines.Add(1) // 记录下开启了几个 goroutine,用于测试代码
gofunc() {       // 开起一个 goroutine,监听父 Context 是否被取消,如果取消则级联取消子 Context
 select {
 case <-parent.Done(): // 父 Context 被取消
  child.cancel(false, parent.Err(), Cause(parent))
 case <-child.Done(): // 自己被取消
 }
}()
}

propagateCancel() 方法将cancelCtx 对象向上传播挂载到父 context 的children 属性集合中,这样当父 context 被取消时,子 context 也会被级联取消。这个方法逻辑稍微有点多,也是 context 包中最复杂的方法了,拿下它,后面的代码就都很简单了。

首先将parent 参数记录到cancelCtx.Context 属性中,作为父 context。接下来会对父 context 做各种判断,以此来决定如何处理子 context。

第 5 行通过parent.Done() 拿到父 context 的 done channel,如果值为nil,则说明父 context 没有取消功能,所以不必传播子 context 的取消功能到父 context。

第 11 行使用select...case... 来监听<-done 是否被关闭,如果已关闭,则说明父 context 已经被取消,那么直接调用child.cancel() 取消子 context。因为 context 的取消功能是从上到下级联取消,所以父 context 被取消,那么子 context 也一定要取消。

如果父 context 尚未取消,则在第 19 行判断父 context 是否为*cancelCtx 或者从*cancelCtx 派生而来。如果是,则判断父 context 的err 属性是否有值,有值则说明父 context 已经被取消,那么直接取消子 context;否则将子 context 加入到这个*cancelCtx 类型的父 context 的children 属性集合中。

如果父 context 不是*cancelCtx 类型,在第 35 行判断父 context 是否实现了afterFuncer 接口。如果实现了,则新建一个stopCtx 作为当前*cancelCtx 的父 context。

最终,如果之前对父 context 的判断都不成立,则开启一个新的 goroutine 来监听父 context 和子 context 的取消信号。如果父 context 被取消,则级联取消子 context;如果子 context 被取消,则直接退出 goroutine。

至此propagateCancel() 方法的主要逻辑就梳理完了。

不过,在当前的 context 包实现中,其实在第 35 行判断父 context 是否实现了afterFuncer 接口的 case 永远不会发生。afterFuncer 接口定义如下:

type afterFuncer interface {
AfterFunc(func()) func() bool
}

在 Go 1.23.0 版本 context 包的源码中,并没有一个 context 实现了afterFuncer 接口。所以stopCtx 也并没有被真正使用。所以我才在前文讲解removeChild() 函数时说stopCtx 类型不必深究。

不过我们还是简单看一下stopCtx 的定义:

type stopCtx struct {
Context
stop func() bool
}

它同样嵌入了Context 接口,stop 方法用于注销AfterFunc

NOTE:

其实 afterFuncer 接口在 context/afterfunc_test.go 文件中有一个 afterFuncContext 类型是实现了的,只不过是测试代码,所以我们还是无法使用。

我在 issues/61672 中找到了一些关于 afterFuncer 的讨论,在我看来这是一个为了填早期设计的坑而定义的,如果能重来,大概率 Context 不会被设计成接口,而是结构体。

此外,这里还用到了Cause() 函数从parent 中提取根因,Cause() 函数实现如下:

func Cause(c Context) error {
 if cc, ok := c.Value(&cancelCtxKey).(*cancelCtx); ok {
 cc.mu.Lock()
 defer cc.mu.Unlock()
 return cc.cause
}
 return c.Err()
}

这里同样使用特殊的key&cancelCtxKey 来查找 context 链路中的*cancelCtx,如果找到,则返回*cancelCtx.cause,否则将 context 的错误原因作为根因。

针对cancelCtx 类型的源码讲解就到这里,可以说cancelCtx 是最复杂的 context 实现了,后文中要讲解的timerCtxafterFuncCtx 都是基于它实现的。

timerCtx

timerCtx 结构体定义如下:

type timerCtx struct {
cancelCtx             // “继承”了 cancelCtx
timer     *time.Timer // Under cancelCtx.mu.

deadline time.Time
}

timerCtx 内部嵌入了cancelCtx 以“继承”Done()Err() 方法。并且它还关联了一个定时器timer 和截止时间deadline,以此来实现在截止时间到期时,自动取消 context。

timerCtx 实现的方法如下:

func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
return c.deadline, true
}

func (c *timerCtx) String() string {
return contextName(c.cancelCtx.Context) + ".WithDeadline(" +
 c.deadline.String() + " [" +
 time.Until(c.deadline).String() + "])"
}

func (c *timerCtx) cancel(removeFromParent bool, err, cause error) {
c.cancelCtx.cancel(false, err, cause)
if removeFromParent {
 // 将此 *timerCtx 从其父 *cancelCtx 的 children 集合中删除
 removeChild(c.cancelCtx.Context, c)
}
c.mu.Lock()
if c.timer != nil {
 c.timer.Stop()
 c.timer = nil
}
c.mu.Unlock()
}

你是否还记得我们在讲解Context 接口时提到,Deadline() 方法返回的ok 值为false 时说明 context 没有设置截止时间。这里返回true 则说明timerCtx 支持设置截止时间。

timerCtx 也实现了自己的String() 方法。其实所有 context 实现都有自己的String() 方法。

timerCtx 的并没有直接使用cancelCtx 的取消方法,而是自己也实现了cancel() 方法。内部调用的removeChild() 函数我们在前文讲解cancelCtx 时已经见过了。这里唯一需要注意的一点是,如果timer 属性不为nil 则调用timer.Stop() 将其停止,并将属性值置为nil,以此让timer 对象尽早被 GC 回收。

WithDeadline() 和 WithTimeoutCause()

我们先来看timerCtx 的第一个构造函数WithDeadline()

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
 return WithDeadlineCause(parent, d, nil)
}

WithDeadline() 直接将逻辑代理给了WithDeadlineCause() 来处理,WithDeadlineCause() 实现如下:

func WithDeadlineCause(parent Context, d time.Time, cause error) (Context, CancelFunc) {
if parent == nil {
 panic("cannot create context from nil parent")
}
// 如果父 Context 的截止时间已经比传入的 d 更早,直接返回一个 *cancelCtx(无需构造 *timerCtx 等待定时器判断截止时间到了才取消 Context)
if cur, ok := parent.Deadline(); ok && cur.Before(d) {
 return WithCancel(parent)
}
c := &timerCtx{ // 构造一个带有定时器和截止时间功能的 Context
 deadline: d,
}
// 这里使用 cancelCtx 结构体默认值,初始化 timerCtx 时没有显式初始化 cancelCtx 字段
c.cancelCtx.propagateCancel(parent, c) // 向父 Context 传播 cancel 功能,这样当父 Context 取消时当前 Context 也会被级联取消
dur := time.Until(d)
if dur <= 0 { // 截止日期已过,直接取消
 c.cancel(true, DeadlineExceeded, cause)
 return c, func() { c.cancel(false, Canceled, nil) }
}
c.mu.Lock()
defer c.mu.Unlock()
if c.err == nil {
 c.timer = time.AfterFunc(dur, func() { // 等待截止时间到期,自动调用 cancel 取消 Context
  c.cancel(true, DeadlineExceeded, cause)
 })
}
return c, func() { c.cancel(true, Canceled, nil) }
}

可以发现WithDeadline(parent, d) 等价于WithDeadlineCause(parent, d, nil)

WithDeadlineCause() 实现代码不多,首先对parent 是否为nil 做了检查。接着检查父 context 的截止时间是否比传入的d 更早,如果是,则直接创建一个*cancelCtx 并返回,无需创建*timerCtx。这是因为 context 具有级联取消的能力,既然父 context 的截止时间更早,则父 context 一定先于子 context 取消,所以子 context 会被级联取消,这就没必要再大费周章的构造*timerCtx 来定时取消子 context 了。

如果上述条件不成立,则构造一个带有定时器和截止时间功能的*timerCtx。并且,同样需要调用cancelCtx.propagateCancel() 向上传播取消功能。

接着判断是否已到截止时间,如果到了,则直接取消 context。否则使用time.AfterFunc() 来实现延迟取消 context。

WithTimeout() 和 WithTimeoutCause()

WithTimeout()WithTimeoutCause() 两个方法同样用于构造timerCtx,其实现如下:

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
 return WithDeadline(parent, time.Now().Add(timeout))
}

func WithTimeoutCause(parent Context, timeout time.Duration, cause error) (Context, CancelFunc) {
 return WithDeadlineCause(parent, time.Now().Add(timeout), cause)
}

WithTimeout() 内部调用了WithDeadline(),而WithTimeoutCause() 内部则调用了WithDeadlineCause()

WithDeadline()WithDeadlineCause() 接收一个绝对时间d time.Time

WithTimeout()WithTimeoutCause() 接收一个相对时间timeout time.Duration,并在内部将其转换为绝对时间。

所以timerCtx 类型的构造函数有 4 个。

withoutCancelCtx

withoutCancelCtx 故名思义,是没有取消功能的 context,它可以打断 context 控制链路中级联取消的能力。

withoutCancelCtx 结构体定义非常简单,只有一个属性c 用来保存父 context:

type withoutCancelCtx struct {
c Context
}

withoutCancelCtx 实现方法如下:

func (withoutCancelCtx) Deadline() (deadline time.Time, ok bool) {
return
}

func (withoutCancelCtx) Done() <-chan struct{} {
returnnil
}

func (withoutCancelCtx) Err() error {
returnnil
}

func (c withoutCancelCtx) Value(key any) any {
return value(c, key)
}

func (c withoutCancelCtx) String() string {
return contextName(c.c) + ".WithoutCancel"
}

withoutCancelCtx 虽然没有取消功能,但实现了Value 方法,可以根据key 查询value。这样才能保证整个 context 链路中传值的能力不被中断。

WithoutCancel()

不仅withoutCancelCtx 结构体设计简单,它的构造函数WithoutCancel() 同样非常简单:

func WithoutCancel(parent Context) Context {
 if parent == nil {
 panic("cannot create context from nil parent")
}
 return withoutCancelCtx{parent}
}

这里只对父 context 是否为nil 做了检查,然后就直接返回实例化的withoutCancelCtx 对象了。

valueCtx

我们前面介绍的 context 从设计上来说都是为了实现控制链路的,与其他 context 不同,valueCtx 用于实现在 context 链路中进行安全传值。

valueCtx 实现如下:

type valueCtx struct {
Context
key, val any // 存储的键值对,注意一个 Context 仅能保存一对 key/value,这样就能实现并发读的安全,copy-on-write
}

func (c *valueCtx) Value(key any) any {
 if c.key == key { // 在自己的键值对中查找
 return c.val
}
 return value(c.Context, key) // 沿着父 Context 向上查找
}

valueCtx 结构体内部嵌入了Context 接口,这样可以直接复用父 context 实现的方法。keyvalue 字段则用于存储键值对。可以发现,一个valueCtx 对象只能存储一对key/value

在用户调用Value() 方法查找给定key 关联的value 时,首先判断是否在当前 context 中,如果不在,则交给value() 函数来处理。

在介绍*cancelCtx.Value() 方法时,我们并没有深入讲解value() 函数,那么现在是时候看下value() 函数是如何实现的了:

func value(c Context, key any) any {
for {
 switch ctx := c.(type) { // 断言 Context 类型
 case *valueCtx: // 表示一个用于安全传递数据的 Context
  if key == ctx.key { // 与当前 Context 的 key 匹配,直接返回对应的值 val
   return ctx.val
  }
  c = ctx.Context // key 不匹配,继续向上遍历父 Context
 case *cancelCtx: // 表示一个带有取消功能的 Context
  if key == &cancelCtxKey { // 检查 key 是否等于 &cancelCtxKey(这是一个指向 *cancelCtx 的特殊键),如果匹配,就返回自身(即 c 对象)
   return c
  }
  c = ctx.Context // key 不匹配,继续向上遍历父 Context
 case withoutCancelCtx: // 表示一个不带取消功能的 Context(使用 WithoutCancel() 创建出来的 Context 类型)
  if key == &cancelCtxKey { // 检查 key 是否等于 &cancelCtxKey,如果匹配,说明要查找的是取消信号的特殊键,就返回 nil,因为这种 Context 没有取消信号
   returnnil
  }
  c = ctx.c // 如果 key 不匹配,则继续向上遍历父 Context
 case *timerCtx: // 表示一个带有定时器的 Context
  if key == &cancelCtxKey { // 检查 key 是否等于 &cancelCtxKey,如果匹配,返回其包装的 *cancelCtx
   return &ctx.cancelCtx
  }
  c = ctx.Context // key 不匹配,继续向上遍历父 Context
 case backgroundCtx, todoCtx: // 这两个类型是无值的 Context(通常这是 Context 树的根),所以直接返回 nil
  returnnil
 default: // 如果没有匹配任何已知的 Context 类型,则调用 Context 的 Value 方法去查找 key 对应的值
  return c.Value(key)
 }
}
}

这里代码看似复杂,实际上逻辑非常简单。启用一个for 无限循环,沿着传进来的 context 对象c 的父路径,循环查找匹配的key,直到找到目标value 或走到链路根节点返回nil

for 循环中,首先会断言当前 context 对象c 的类型,如果是*valueCtx,判断key 是否匹配,匹配则直接返回ctx.val,不匹配则将父 context 取出赋值给c,进行下一轮循环;如果是*cancelCtx*timerCtx,判断key 是否匹配&cancelCtxKey 这个特殊值,匹配则根据我们前文讲过的“内部协议”返回当前*cancelCtx,否则将父 context 取出赋值给c,进行下一轮循环;如果是withoutCancelCtx,当key 匹配&cancelCtxKey 时返回nil,因为这个 context 的实现不支持取消功能,key 不匹配同样将父 context 取出赋值给c,进行下一轮循环;如果是backgroundCtxtodoCtx,则说明已经遍历到 context 链路的顶点,所以直接返回nil,表示未查找到;如果所有已知类型都没匹配,则调用其Value() 方法继续查找。

所以,从源码中我们能够看出,context 根据给定的key 查找value 时,是自下而上查找的。

此外,valueCtx 同样实现了自己的String() 方法:

func stringify(v any) string {
switch s := v.(type) {
case stringer: // 实现了 String() 方法,就返回 String() 内容
 return s.String()
casestring: // 字符串类型就返回字符串内容
 return s
casenil: // nil 返回字符串格式
 return"<nil>"
}
// 其他类型会返回对象类型名的字符串格式,而不是对象值的字符串形式
return reflectlite.TypeOf(v).String()
}

// 代码示例:context.WithValue(context.Background(), "a", 1)
// 输出示例:context.Background.WithValue(a, int)
func (c *valueCtx) String() string {
// 取父 Context 的 string 形式 + .WithValue(k, v)
return contextName(c.Context) + ".WithValue(" +
 stringify(c.key) + ", " +
 stringify(c.val) + ")"
}
WithValue()

valueCtx 构造函数WithValue() 实现如下:

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}
}

这里对parentkey 都做了检查,注意key 一定是可比较类型。

可以发现,valueCtx 并没有使用互斥锁,这是因为每次新增key/value 时,都会新建一个新的valueCtx,并将parent 赋值给valueCtx。这种 copy-on-write 的思想,保证绝不修改现有的 context 对象,那么程序中并发读取值时就不会产生 data race,同时也能保证并发安全。

afterFuncCtx

我们最后还未介绍的 context 就仅剩一个afterFuncCtx 类型了,其实现如下:

type afterFuncCtx struct {
cancelCtx           // “继承”了 cancelCtx
once      sync.Once // 要么用来开始执行 f,要么用来阻止 f 被执行
f         func()
}

timerCtx 一样,afterFuncCtx 内部也嵌入了cancelCtx。此外它还有两个属性oncefonce 保证一个操作仅执行一次,要么用来开始执行f,要么用来阻止f 被执行,f 是一个延迟函数,在构造函数AfterFunc() 中被传入赋值。

afterFuncCtx 实现了自己的cancel() 方法:

func (a *afterFuncCtx) cancel(removeFromParent bool, err, cause error) {
a.cancelCtx.cancel(false, err, cause) // 取消 cancelCtx
 if removeFromParent {
 removeChild(a.Context, a) // 将当前 *afterFuncCtx 从 cancelCtx 的父 Context 的 children 属性中移除
}
a.once.Do(func() { // 确保仅执行一次
 go a.f() // 开启新的 goroutine 执行 f,如果在调用 a.cancel() 之前 stop 函数被调用,stop 函数中的 a.once.Do 优先被执行,则此处就不会执行
})
}

afterFuncCtx 在取消时,首先会取消父cancelCtx。然后根据参数removeFromParent 决定是否从父 context 的children 属性中移除。最后使用once.Do() 确保f 函数仅执行一次。

AfterFunc()

afterFuncCtx 构造函数AfterFunc() 实现如下:

func AfterFunc(ctx Context, f func()) (stop func() bool) {
a := &afterFuncCtx{
 f: f,
}
// 调用 cancelCtx 的向上传播方法,将 a 的取消功能挂载到父 ctx 的 children 属性中,实现级联取消
a.cancelCtx.propagateCancel(ctx, a)
returnfunc() bool { // 返回一个停止函数,用于阻止 f 被执行
 stopped := false
 a.once.Do(func() { // 确保仅执行一次
  stopped = true// 如果此处被执行,则 a.cancel 方法内部的 a.once.Do 就不会重复执行,即阻止 f 被执行
 })
 if stopped { // 第一次调用,取消 Context
  a.cancel(true, Canceled, nil)
 }
 return stopped
}
}

与其他 context 构造函数不同,AfterFunc() 并不会返回构造的afterFuncCtx 对象,而是返回一个stop() 函数。其实AfterFunc() 的功能是为 context 注册一个延迟函数,当 context 被取消时,开启新的 goroutine 异步执行f()。而stop() 函数的作用则是用来阻止f() 被执行。

因为stop() 函数和cancel() 方法内部使用的a.once.Do() 是同一个,所以二者只能有一个会被执行。可以总结stop() 函数和cancel() 方法执行逻辑如下:

  • 如果先执行cancel(),则f() 必然执行。无论之后是否调用了stop()
  • 如果先执行stop(),则f() 必然不会被执行。无论之后是否调用了cancel()

至此,context 包的源码就全部解读完成了。

总结

context 包在 Go 1.7 版本被引入,核心功能是控制链路安全传值,且并发安全。

context 被设计为一个Context 接口和多个实现了此接口的结构体。一切 context 链路都会从一个空的emptyCtx 开始,由context.Background()context.TODO() 来定义了最顶层 context,接着使用WithXxx() 方法在原有的 context 基础上附加新的功能,形成 context 链路。

context 链路最终可能发展成一个树形结构,不过你要清楚,控制链路是从上到下的,父 context 取消,则会及联的取消所有带有取消功能的子孙 context;但通过给定key 查找value 则是自下而上的,而这就会导致从不同的起点出发,查找 context 中相同key 对应的value 可能不同。

我画了一张 context 树形结构图:

context 树

在这幅图中,从控制链路的角度出发,如果我们取消 context 3️⃣,则 context 7️⃣ 会被级联取消,因为 6️⃣ 不支持取消,控制链路会被打断,所以 9️⃣ 不会被取消;如果取消 context 7️⃣,则 context 3️⃣ 不会被取消,因为控制链路是从上到下的。

从安全传值的角度出发,根据给定key 查找value,假如 context 2️⃣ 中存储的是key: value2,context 8️⃣ 中存储的是key: value8,那么从 context 2️⃣ 4️⃣ 5️⃣ 中看到的就是key: value2;从 context 8️⃣ 🔟 中看到的则是key: value8

我用代码构造了这幅图中的 context 树,放在了这里 https://github.com/jianghushinian/blog-go-example/blob/main/context/main.go,你可以点击进去跟着代码来实验一下。也可以将代码 clone 到本地,进行修改,尝试执行和分析结果,以此来加深你对 context 的理解。

本文示例源码我都放在了GitHub 中 https://github.com/jianghushinian/blog-go-example/tree/main/context,欢迎点击查看。

希望此文能对你有所启发。

延伸阅读

  • Go 1.7 Release Notes:https://go.dev/doc/go1.7
  • Go 1.21 Release Notes:https://go.dev/doc/go1.21#contextpkgcontext
  • context Documentation:https://pkg.go.dev/context@go1.23.0
  • go1.7/src/context:https://github.com/golang/go/tree/go1.7/src/context
  • go1.23.0/src/context:https://github.com/golang/go/tree/go1.23.0/src/context
  • context: AfterFunc spawns a goroutine #61672:https://github.com/golang/go/issues/61672
  • Go 错误处理指北:如何优雅的处理错误?:https://jianghushinian.cn/2024/10/01/go-error-guidelines-error-handling/
  • 本文 GitHub 示例代码:https://github.com/jianghushinian/blog-go-example/tree/main/context

早日上岸!

我们搞了一个免费的面试真题共享群,互通有无,一起刷题进步。

没准能让你能刷到自己意向公司的最新面试题呢。

感兴趣的朋友们可以加我微信:wangzhongyang1993,备注:面试群。



点击下方文章,看看他们是怎么找到好工作的!

Go就业陪跑训练营,辅导到就业为止!

Java就业陪跑训练营,辅导到就业为止!

我们又出成绩啦!大厂Offer集锦!遥遥领先!


阅读原文


王中阳
公司技术总监,创办就业陪跑服务,辅导学员拿到600多个offer。专注程序员的就业辅导、简历优化、模拟面试等。
 最新文章