Go Context 详解之终极无惑

Posted 恋喵大鲤鱼

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Go Context 详解之终极无惑相关的知识,希望对你有一定的参考价值。

文章目录

1.什么是 Context

Go 1.7 标准库引入 Context,中文名为上下文,是一个跨 API 和进程用来传递截止日期、取消信号和请求相关值的接口。

context.Context 定义如下:

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

Deadline()返回一个完成工作的截止时间,表示上下文应该被取消的时间。如果 ok==false 表示没有设置截止时间。

Done()返回一个 Channel,这个 Channel 会在当前工作完成时被关闭,表示上下文应该被取消。如果无法取消此上下文,则 Done 可能返回 nil。多次调用 Done 方法会返回同一个 Channel。

Err()返回 Context 结束的原因,它只会在 Done 方法对应的 Channel 关闭时返回非空值。如果 Context 被取消,会返回context.Canceled 错误;如果 Context 超时,会返回context.DeadlineExceeded错误。

Value()从 Context 中获取键对应的值。如果未设置 key 对应的值则返回 nil。以相同 key 多次调用会返回相同的结果。

另外,context 包中提供了两个创建默认上下文的函数:

// TODO 返回一个非 nil 但空的上下文。
// 当不清楚要使用哪种上下文或无可用上下文尚应使用 context.TODO。
func TODO() Context

// Background 返回一个非 nil 但空的上下文。
// 它不会被 cancel,没有值,也没有截止时间。它通常由 main 函数、初始化和测试使用,并作为处理请求的顶级上下文。
func Background() Context

还有四个基于父级创建不同类型上下文的函数:

// WithCancel 基于父级创建一个具有 Done channel 的 context
func WithCancel(parent Context) (Context, CancelFunc)

// WithDeadline 基于父级创建一个不晚于 d 结束的 context
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

// WithTimeout 等同于 WithDeadline(parent, time.Now().Add(timeout))
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

// WithValue 基于父级创建一个包含指定 key 和 value 的 context
func WithValue(parent Context, key, val interface) Context

在后面会详细介绍这些不同类型 context 的用法。

2.为什么要有 Context

Go 为后台服务而生,如只需几行代码,便可以搭建一个 HTTP 服务。

在 Go 的服务里,通常每来一个请求都会启动若干个 goroutine 同时工作:有些执行业务逻辑,有些去数据库拿数据,有些调用下游接口获取相关数据…

协程 a 生 b c d,c 生 e,e 生 f。父协程与子孙协程之间是关联在一起的,他们需要共享请求的相关信息,比如用户登录态,请求超时时间等。如何将这些协程联系在一起,context 应运而生。

话说回来,为什么要将这些协程关联在一起呢?以超时为例,当请求被取消或是处理时间太长,这有可能是使用者关闭了浏览器或是已经超过了请求方规定的超时时间,请求方直接放弃了这次请求结果。此时所有正在为这个请求工作的 goroutine 都需要快速退出,因为它们的“工作成果”不再被需要了。在相关联的 goroutine 都退出后,系统就可以回收相关资源了。

总的来说 context 的作用是为了在一组 goroutine 间传递上下文信息(cancel signal,deadline,request-scoped value)以达到对它们的管理控制。

3.context 包源码一览

我们分析的 Go 版本依然是 1.17。

3.1 Context

context 是一个接口,某个类型只要实现了其申明的所有方法,便实现了 context。再次看下 context 的定义。

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

方法的作用在前文已经详述,这里不再赘述。

3.2 CancelFunc

另外 context 包中还定义了一个函数类型 CancelFunc

type CancelFunc func()

CancelFunc 通知操作放弃其工作。CancelFunc 不会等待工作停止。多个 goroutine 可以同时调用 CancelFunc。在第一次调用之后,对 CancelFunc 的后续调用不会执行任何操作。

3.3 canceler

context 包还定义了一个更加简单的用于取消操作的 context,名为 canceler,其定义如下。

// A canceler is a context type that can be canceled directly. The
// implementations are *cancelCtx and *timerCtx.
type canceler interface 
	cancel(removeFromParent bool, err error)
	Done() <-chan struct

因其首字母小写,所以该接口未被导出,外部包无法直接使用,只在 context 包内使用。实现该接口的类型有 *cancelCtx*timerCtx

为什么其中一个方法 cancel() 首字母是小写,未被导出,而 Done() 确是导出一定要实现的呢?为何如此设计呢?

(1)“取消”操作应该是建议性,而非强制性。
caller 不应该去关心、干涉 callee 的情况,决定如何以及何时 return 是 callee 的责任。caller 只需发送“取消”信息,callee 根据收到的信息来做进一步的决策,因此接口并没有定义 cancel 方法。

(2)“取消”操作应该可传递。
“取消”某个函数时,和它相关联的其他函数也应该“取消”。因此,Done() 方法返回一个只读的 channel,所有相关函数监听此 channel。一旦 channel 关闭,通过 channel 的“广播机制”,所有监听者都能收到。

3.4 Context 的实现

context 包中定义了 Context 接口后,并且给出了四个实现,分别是:

  • emptyCtx
  • cancelCtx
  • timerCtx
  • valueCtx

我们可以根据不同场景选择使用不同的 Context。

3.4.1 emptyCtx

emptyCtx 正如其名,是一个空上下文。无法被取消,不携带值,也没有截止日期。

// An emptyCtx is never canceled, has no values, and has no deadline. It is not
// struct, since vars of this type must have distinct addresses.
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

其未被导出,但被包装成如下两个变量,通过相应的导出函数对外提供使用。

var (
    background = new(emptyCtx)
    todo       = new(emptyCtx)
)

// Background returns a non-nil, empty Context. It is never canceled, has no
// values, and has no deadline. It is typically used by the main function,
// initialization, and tests, and as the top-level Context for incoming
// requests.
func Background() Context 
	return background


// TODO returns a non-nil, empty Context. Code should use context.TODO when
// it's unclear which Context to use or it is not yet available (because the
// surrounding function has not yet been extended to accept a Context
// parameter).
func TODO() Context 
	return todo

从源代码来看,Background()TODO() 分别返回两个同类型不同的空上下文对象,没有太大的差别,只是在使用和语义上稍有不同:

  • Background() 是上下文的默认值,所有其他的上下文都应该从它衍生出来;比如用在 main 函数或作为最顶层的 context。
  • TODO() 通常用在并不知道传递什么 context 的情形下使用。如调用一个需要传递 context 参数的函数,你手头并没有现成 context 可以传递,这时就可以传递 todo。这常常发生在重构进行中,给一些函数添加了一个 Context 参数,但不知道要传什么,就用 todo “占个位子”,最终要换成其他 context。

3.4.2 cancelCtx

cancelCtx 是一个用于取消操作的 Context,实现了 canceler 接口。它直接将接口 Context 作为它的一个匿名字段,这样,它就可以被看成一个 Context。

// A cancelCtx can be canceled. When canceled, it also cancels any children
// that implement canceler.
type cancelCtx struct 
	Context

	mu       sync.Mutex            // protects following fields
	done     atomic.Value          // of chan struct, created lazily, closed by first cancel call
	children map[canceler]struct // set to nil by the first cancel call
	err      error                 // set to non-nil by the first cancel call

cancelCtx 是一个未导出类型,通过创建函数WithCancel()暴露给用户使用。

// WithCancel returns a copy of parent with a new Done channel. The returned
// context's Done channel is closed when the returned cancel function is called
// or when the parent context's Done channel is closed, whichever happens first.
//
// Canceling this context releases resources associated with it, so code should
// call cancel as soon as the operations running in this Context complete.
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) 


// newCancelCtx returns an initialized cancelCtx.
func newCancelCtx(parent Context) cancelCtx 
	return cancelCtxContext: parent

传入一个父 Context(这通常是一个 background,作为根结点),返回新建的 Context,新 Context 的 done channel 是新建的。

注意: 从 cancelCtx 的定义和生成函数WithCancel()可以看出,我们基于父 Context 每生成一个 cancelCtx,相当于在一个树状结构的 Context 树中添加一个子结点。类似于下面这个样子:

先来看其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)

c.done 采用惰性初始化的方式创建,只有调用了Done()方法的时候才会被创建。再次说明,函数返回的是一个只读的 channel,而且没有地方向这个 channel 里面写数据。所以,直接读这个 channel,协程会被 block 住。一般通过搭配 select 来使用。一旦关闭,就会立即读出零值。

再看一下Err()String()方法,二者较为简单,Err() 用于返回错误信息,String()用于返回上下文名称。

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


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"

下面重点看下cancel()方法的实现。

// cancel closes c.done, cancels each of c's children, and, if
// removeFromParent is true, removes c from its parent's children.
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()方法的功能就是关闭 channel(c.done)来传递取消信息,并且递归地取消它的所有子结点;如果入参 removeFromParent 为 true,则从父结点从删除自己。达到的效果是通过关闭 channel,将取消信号传递给了它的所有子结点。goroutine 接收到取消信号的方式就是 select 语句中的读 c.done 被选中。

当 WithCancel() 函数返回的 CancelFunc 被调用或者父结点的 done channel 被关闭(父结点的 CancelFunc 被调用),此 context(子结点) 的 done channel 也会被关闭。

注意传给cancel()方法的参数,前者是 true,也就是说取消的时候,需要将自己从父结点里删除。第二个参数则是一个固定的取消错误类型:

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

还要注意到一点,调用子结点 cancel 方法的时候,传入的第一个参数 removeFromParent 是 false。

removeFromParent 什么时候会传 true,什么时候传 false 呢?

先看一下当 removeFromParent 为 true 时,会将当前 context 从父结点中删除操作。

// removeChild removes a context from its parent.
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)就是完成从父结点 map 中删除自己。

什么时候会传 true 呢?答案是调用 WithCancel() 方法的时候,也就是新创建一个用于取消的 context 结点时,返回的 cancelFunc 函数会传入 true。这样做的结果是:当调用返回的 cancelFunc 时,会将这个 context 从它的父结点里“除名”,因为父结点可能有很多子结点,我自己取消了,需要清理自己,从父亲结点删除自己。

在自己的cancel()方法中,我所有的子结点都会因为c.children = nil完成断绝操作,自然就没有必要在所有的子结点的cancel() 方法中一一和我断绝关系,没必要一个个做。


如上左图,代表一棵 Context 树。当调用左图中标红 Context 的 cancel 方法后,该 Context 从它的父 Context 中去除掉了:实线箭头变成了虚线。且虚线圈框出来的 Context 都被取消了,圈内的 context 间的父子关系都荡然无存了。

在生成 cancelCtx 的函数WithCancel()有一个操作需要注意一下,便是propagateCancel(parent, &c)

// propagateCancel arranges for child to be canceled when parent is.
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():
			
		()
	

该函数的作用就是将生成的当前 cancelCtx 挂靠到“可取消”的父 Context,这样便形成了上面描述的 Context 树,当父 Context 被取消时,能够将取消操作传递至子 Context。

这里着重解释下为什么会有 else 描述的情况发生。else 是指当前结点 Context 没有向上找到可以取消的父结点,那么就要再启动一个协程监控父结点或者子结点的取消动作。

这里就有疑问了,既然没找到可以取消的父结点,那case <-parent.Done()这个 case 就永远不会发生,所以可以忽略这个 case;而case <-child.Done()这个 case 又啥事不干。那这个 else 不就多余了吗?

其实不然,我们来看parentCancelCtx()的代码:

// parentCancelCtx returns the underlying *cancelCtx for parent.
// It does this by looking up parent.Value(&cancelCtxKey) to find
// the innermost enclosing *cancelCtx and then checking whether
// parent.Done() matches that *cancelCtx. (If not, the *cancelCtx
// has been wrapped in a custom implementation providing a
// different done channel, in which case we should not bypass it.)
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

如果 parent 携带的 value 并不是一个 *cancelCtx,那么就会判断为不可取消。这种情况一般发生在一个 struct 匿名嵌套了 Context,就识别不出来了,因为parent.Value(&cancelCtxKey)返回的是*struct,而不是*cancelCtx

3.4.3 timerCtx

timerCtx 是一个可以被取消的计时器上下文,基于 cancelCtx,只是多了一个 time.Timer 和一个 deadline。Timer 会在 deadline 到来时,自动取消 Context。

// A timerCtx carries a timer and a deadline. It embeds a cancelCtx to
// implement Done and Err. It implements cancel by stopping its timer then
// delegating to cancelCtx.cancel.
type timerCtx struct 
	cancelCtx
	timer *time.Timer // Under cancelCtx.mu.

	deadline time.Time

timerCtx 首先是一个 cancelCtx,所以它能取消。看下其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()

同样地,timerCtx 也是一个未导出类型,其对应的创建函数是WithTimeout()

// WithTimeout returns WithDeadline(parent, time.Now().Add(timeout)).
//
// Canceling this context releases resources associated with it, so code should
// call cancel as soon as the operations running in this Context complete:
//
// 	func slowOperationWithTimeout(ctx context.Context) (Result, error) 
// 		ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
// 		defer cancel()  // releases resources if slowOperation completes before timeout elapses
// 		return slowOperation(ctx)
// 	
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) 
	return WithDeadline(parent, time.Now().Add(timeout))

该函数直接调用了WithDeadline(),传入的 deadline 是当前时间加上 timeout 的时间,也就是从现在开始再经过 timeout 时间就算超时。也就是说,WithDeadline()需要用的是绝对时间。重点来看下:

// WithDeadline returns a copy of the parent context with the deadline adjusted
// to be no later than d. If the parent's deadline is already earlier than d,
// WithDeadline(parent, d) is semantically equivalent to parent. The returned
// context's Done channel is closed when the deadline expires, when the returned
// cancel function is called, or when the parent context's Done channel is
// closed, whichever happens first.
//
// Canceling this context releases resources associated with it, so code should
// call cancel as soon as the operations running in this Context complete.
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.cancelgolang之context详解

grpc-go源码剖析五十九之客户端一侧,是如何处理截止时间呢?

Go 并发模式: context.Context 上下文详解

Golang Context 包详解

最详尽的 JS 原型与原型链终极详解,没有「可能是」。

Go context