# Golang Context

参考:

context 是 Golang 并发编程中常用到的一种编程模式,它主要用于同一任务不同步骤、父子任务和后台任务之间的同步取消信号,本质上是一种协程调度的方式。

  • context 出现意义
  • context 实现原理
  • context 注意事项
  • context 总结

# 1. context 出现意义

在并发程序中,由于超时、取消操作或者一些异常情况,往往需要进行抢占操作或者中断后续操作。

我们可以使用done channel来处理此类问题。比如以下这个例子:

func main() {
    messages := make(chan int, 10)
    done := make(chan bool)

    defer close(messages)
    // consumer
    go func() {
        ticker := time.NewTicker(1 * time.Second)
        for _ = range ticker.C {
            select {
            case <-done:
                fmt.Println("child process interrupt...")
                return
            default:
                fmt.Printf("send message: %d\n", <-messages)
            }
        }
    }()

    // producer
    for i := 0; i < 10; i++ {
        messages <- i
    }
    time.Sleep(5 * time.Second)
    close(done)
    time.Sleep(1 * time.Second)
    fmt.Println("main process exit!")
}

上述例子中定义了一个buffer0 的``channel done,子协程运行着定时任务。如果主协程需要在某个时刻发送消息通知子协程中断任务退出,那么就可以让子协程监听这个 done channel,一旦主协程关闭 done channel,那么子协程就可以推出了,这样就实现了主协程通知子协程的需求。

这很好,但是这也是有限的。

如果我们可以在简单的通知上附加传递额外的信息来控制取消:为什么取消,或者有一个它必须要完成的最终期限,更或者有多个取消选项,我们需要根据额外的信息来判断选择执行哪个取消选项。

考虑下面这种情况:假如主协程中有多个任务 1, 2, …m,主协程对这些任务有超时控制;而其中任务 1 又有多个子任务 1, 2, …n,任务 1 对这些子任务也有自己的超时控制,那么这些子任务既要感知主协程的取消信号,也需要感知任务 1 的取消信号。

如果还是使用 done channel 的用法,我们需要定义两个 done channel,子任务们需要同时监听这两个 done channel。这样其实好像也还行哈。但是如果层级更深,如果这些子任务还有子任务,那么使用 done channel 的方式将会变得非常繁琐且混乱。

我们需要一种优雅的方案来实现这样一种机制:

  • 上层任务取消后,所有的下层任务都会被取消;
  • 中间某一层的任务取消后,只会将当前任务的下层任务取消,而不会影响上层的任务以及同级任务。

这个时候 context 就派上用场了。

# 2. context 实现原理

# 2.1 context 接口

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

这里建议读一读 Go 源码上的注释,解释得非常详细。

context 接口包含四个方法:

  • Deadline(): 返回绑定当前 context 的任务被取消的截止时间,如果没有设定期限,将返回 ok == false
  • Done(): 当当前的 context 被取消时,将返回一个关闭的 channel,如果当前 context 不会被取消,将返回 nil
  • Err():
    • 如果 Done() 返回的 channel 没有关闭,将返回 nil
    • 如果 Done() 返回的 channel 已经关闭,将返回非空的值表示任务结束的原因;
    • 如果是 context 被取消,Err() 将返回 Canceled
    • 如果是 context 超时,Err() 将返回 DeadlineExceeded
  • Value(): 返回 context 存储的键值对中当前 key 对应的值,如果没有对应的 key,则返回 nil

可以看到 Done() 方法返回的 channel 正是用来传递结束信号以抢占并中断当前任务;Deadline()方法指示一段时间后当前 goroutine 是否会被取消;以及一个Err()方法,来解释 goroutine 被取消的原因;而 Value() 则用于获取特定于当前任务树的额外信息。

context 所包含的额外信息键值对是如何存储的呢?其实可以想象一颗树,树的每个节点可能携带一组键值对,如果当前节点上无法找到 key 所对应的值,就会向上去父节点里找,直到根节点,具体后面会说到。

再来看看 context 包中的其他关键内容。

# 2.2 emptyCtx

emptyCtx 是一个 int 类型的变量,但实现了 context 的接口。emptyCtx 没有超时时间,不能取消,也不能存储任何额外信息,所以 emptyCtx 用来作为 context 树的根节点。

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

func (e *emptyCtx) String() string {
	switch e {
	case background:
		return "context.Background"
	case todo:
		return "context.TODO"
	}
	return "unknown empty Context"
}

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
}

BackgroundTODO 并没有什么不同,只不过用不同的名字来区分不同的场景罢了。

  • Background 通常被用于主函数、初始化以及测试中,作为一个顶层的 `context``
  • TODO 是在不确定使用什么 context 或者不知道有没有必要使用 context 的时候才会使用

# 2.3 context.WithValue

valueCtx

// A valueCtx carries a key-value pair. It implements Value for that key and
// delegates all other calls to the embedded Context.
type valueCtx struct {
	Context
	key, val interface{}
}

func (c *valueCtx) Value(key interface{}) interface{} {
	if c.key == key {
		return c.val
	}
	return c.Context.Value(key)
}

valueCtx利用一个 Context 类型的变量来表示父节点 context,所以当前 context 继承了父 context的所有信息;

valueCtx 类型还携带一组键值对,也就是说这种 context 可以携带额外的信息。valueCtx 实现了 Value 方法,用以在 context 链路上获取 key 对应的值,如果当前 context 上不存在需要的 key,会沿着 context 链向上寻找 key 对应的值,直到根节点。

WithValue

WithValue 用以向 context 添加键值对:

  • WithValue 返回父级的副本,其中与键关联的值为 val。
  • 用于传输进程和 API 的请求范围的数据,而不用于将可选参数传递给函数。
  • key 必须具有可比性(comparable),并且不应是 string 类型或任何其他内置类型,以避免在不同的 package 之间使用context 包发生冲突。
  • 用户应为 key 定义自己的类型。为了避免在分配给 interface {} 时进行分配,key 通常具有具体的 struct{}。或者,导出的 key 变量的静态类型应为指针或接口。
// WithValue returns a copy of parent in which the value associated with key is
// val.
//
// Use context Values only for request-scoped data that transits processes and
// APIs, not for passing optional parameters to functions.
//
// The provided key must be comparable and should not be of type
// string or any other built-in type to avoid collisions between
// packages using context. Users of WithValue should define their own
// types for keys. To avoid allocating when assigning to an
// interface{}, context keys often have concrete type
// struct{}. Alternatively, exported context key variables' static
// type should be a pointer or interface.
func WithValue(parent Context, key, val interface{}) 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}
}

这里添加键值对不是在原 context 结构体上直接添加,而是以此 context 作为父节点,重新创建一个新的 valueCtx 子节点,将键值对添加在子节点上,由此形成一条 context 链。获取 value 的过程就是在这条 context 链上由尾部向前搜寻:

image-20211212165244936

# 2.4 context.WithCancel

cancelCtx

valueCtx 类似,cancelCtx 中也有一个 context 变量作为父节点,其他属性:

  • 变量 done 表示一个 channel,用来表示传递关闭信号;
  • children表示一个 map,存储了当前 context 节点下的子节点;
  • err 用于存储错误信息表示任务结束的原因。
// 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     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
}

func (c *cancelCtx) Value(key interface{}) interface{} {
	if key == &cancelCtxKey {
		return c
	}
	return c.Context.Value(key)
}

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
}

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

下面来看最重要的 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
	}
  // 设置本次取消的原因
  // 1. 设置一个关闭的 channel
  // 2. 将 done channel 关闭
	c.err = err
	if c.done == nil {
		c.done = closedchan
	} else {
		close(c.done)
	}
  // 依次取消子 context
	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 {
    // 将当前 context 从父节点上移除
		removeChild(c.Context, c)
	}
}

cancelCtx类型的 context在调用 cancel() 方法时会设置取消原因,将 done channel 设置为一个关闭 channel 或者关闭 done channel,然后将子节点 context依次取消,如果有需要还会将当前节点从父节点上移除。

WithCancel

WithCancel 函数用来创建一个可取消的 context,即 cancelCtx 类型的contextWithCancel 返回一个 context 和一个 CancelFunc,调用 CancelFunc 即可触发 cancel 操作。直接看源码:

type CancelFunc func()

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

// propagateCancel arranges for child to be canceled when parent is.
func propagateCancel(parent Context, child canceler) {
	done := parent.Done()
      
  // 1. 如果 `parent.Done()` 返回 `nil`,
  //    表明父节点以上的路径上没有可取消的 `context`,
  //    不需要处理;
	if done == nil {
		return // parent is never canceled
	}

  // 2. 如果父节点可取消且取消的话,就取消当前节点
	select {
	case <-done:
		// parent is already canceled
		child.cancel(false, parent.Err())
		return
	default:
	}

  // 3. 如果父节点可取消但是没有取消的话
  // 			判断父节点的父节点有没有取消(也就是判断祖先节点有没有取消)
  //					如果取消的话,那也取消当前节点
  //					如果没有取消的话,就把当前节点加入到祖先节点的 children 列表中
	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 {
    // 4. 如果祖先节点也不能取消的话,就开一个协程,监督 parent.Done() 和 child.Done()
    //		重点是监督 parent.Done(),一旦它返回的 channel 被取消了,
    //		即 context 链中某个祖先节点被取消,则将当前 context 也取消
		atomic.AddInt32(&goroutines, +1)
		go func() {
			select {
			case <-parent.Done():
				child.cancel(false, parent.Err())
			case <-child.Done():
			}
		}()
	}
}


// parentCancelCtx returns the underlying *cancelCtx for parent.
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
	}
	p.mu.Lock()
	ok = p.done == done
	p.mu.Unlock()
	if !ok {
		return nil, false
	}
	return p, true
}

之前说到 cancelCtx 取消时,会将后代节点中所有的 cancelCtx 都取消,propagateCancel 即用来建立当前节点与祖先节点这个取消关联逻辑:

  1. 如果 parent.Done() 返回 nil,表明父节点以上的路径上没有可取消的 context,不需要处理;
  2. 如果在 context 链上找到到 cancelCtx 类型的祖先节点,则判断这个祖先节点是否已经取消:
    1. 如果已经取消就取消当前节点;
    2. 如果没有取消,就尝试寻找可取消的祖先节点:
      1. 找到可取消的祖先节点,判断祖先节点有没有取消:
        1. 祖先节点取消了,则取消当前节点;
        2. 祖先节点没有取消,将当前节点加入到祖先节点的 children 列表;
      2. 没找到可取消的祖先节点,则开一个新的协程监听 parent.Done()child.Done(),重点是监听 parent.Done(),一旦它返回的 channel 被取消了,即 context 链中某个祖先节点被取消,则将当前 context 也取消。

为什么要加入祖先节点的 children 列表,而不是加入父节点?

因为父节点未必是可取消的节点,如 backgroudContext -> cancelCtx -> valueCtx(parent) -> cancelCtx(current),这个时候父节点是不具备 cancelCtx 的,它没有 children 列表。

# 2.5 context.WithDeadline

timerCtx

timerCtx是一种基于 cancelCtxcontext 类型,从字面上就能看出,这是一种可以定时取消的 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
}

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

func (c *timerCtx) cancel(removeFromParent bool, err error) {
  // 取消内部 cancelCtx
	c.cancelCtx.cancel(false, err)
  
  // 需要则从父节点的 children 列表中移除
	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内部使用 cancelCtx 实现 cancel(),另外使用定时器 timer 和过期时间 deadline 实现定时取消的功能。timerCtx在调用cancel() 方法,会先将内部的 cancelCtx() 取消,如果需要则将自己从 cancelCtx 祖先节点上移除,最后取消计时器。

WithDeadline

WithDeadline返回一个基于 parent的可取消的 context,并且其过期时间 deadline 不晚于所设置时间 d

// 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")
	}
  
  // 简单是否有更早的 deadline,如果有则用更早的
	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,
	}
  
  // 建议新建 context 与可取消的 context 祖先节点的取消关联关系
	propagateCancel(parent, c)
  
  // 检查 deadline 是否已经过去了(相比于 d)
	dur := time.Until(d)
	if dur <= 0 {
		c.cancel(true, DeadlineExceeded) // deadline has already passed
		return c, func() { c.cancel(false, Canceled) }
	}
  
  // 设置定时器,过期则取消当前 timerCtx
	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) }
}
  1. 如果父节点 parent 有过期时间并且过期时间早于给定时间 d,那么新建的子节点 context无需设置过期时间,使用 WithCancel 创建一个可取消的 context 即可;
  2. 否则,就要利用 parent 和过期时间 d 创建一个定时取消的 timerCtx,并建立新建 context 与可取消 context 祖先节点的取消关联关系,接下来判断当前时间距离过期时间 d的时长 dur
  3. 如果 dur 小于0,即当前已经过了过期时间,则直接取消新建的 timerCtx,原因为 DeadlineExceeded
  4. 否则,为新建的 timerCtx 设置定时器,一旦到达过期时间即取消当前 timerCtx

# 2.6 context.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 类似,WithTimeout也是创建一个定时取消的 context,只不过:

  • WithDeadline 是接收一个过期时间点
  • WithTimeout 接收一个相对当前时间的过期时长 timeout

# 3. context 注意事项

  • 不要把 context 放在结构体中,要以参数的方式显示传递;
  • context 作为参数的函数方法,应该把 context 作为第一个参数;
  • 给一个函数方法传递 context 的时候,不要传递 nil,如果不知道传递什么,就使用 context.TODO()
  • contextValue 相关方法应该传递请求域的必要数据,不应该用于传递可选参数;
  • context 是线程安全的,可以放心的在多个 Goroutine 中传递。
  • 使用 context.WithValue 来传值的情况非常少,在真正使用传值的功能时我们也应该非常谨慎,不能将请求的所有参数都使用 context进行传递,这是一种非常差的设计,比较常见的使用场景是传递请求对应用户的认证令牌以及用于进行分布式追踪的请求 ID。

# 4. context 总结

  • context.TODO: 不知道用什么 context 以及不知道需不需要用 context 的时候用
  • context.Background: 一般用于根 context
  • context.WithValue 传值
  • context.WithCancel 可取消
  • context.WithDeadline 到指定时间点自动取消(或在这之前手动取消)
  • context.WithTimeout 一段时间后自动取消(或在这之前手动取消)

Go 语言中的 context 的主要作用还是在多个 Goroutine 或者模块之间同步取消信号或者截止日期,用于减少对资源的消耗和长时间占用,避免资源浪费。

上次更新: 12/12/2021, 5:41:03 PM