上下文 context 包简介

上下文context.Context是用来设置终止时间、同步信号、传递请求相关的值的接口,与 Goroutine 关联密切。

context.Context接口需要实现四个方法:

  • Deadline:返回context.Context被终止的时间,即完成任务的最终时限
  • Done:返回一个 Channel,这个 Channel 会在当前工作完成或者上下文被取消后关闭,多次调用Done方法会返回同一个 Channel
  • Err:返回context.Context的结束原因,它只会有Done方法对应的 Channel 关闭时才返回非空的值
    • 如果context.Context被动取消,返回Canceled错误
    • 如果context.Context超时,返回DeadlineExceeded错误
  • Value:从context.Context中获取键对应的值,对于同一个上下文来说,多次调用Value并传入相同的Key会返回相同的结果,该方法可以用来传递请求特定的数据
type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key interface{}) interface{}
}

一、设计原理

在 Goroutine 构成的树形结构中对信息进行同步以减少计算资源的浪费是context.Context的最大作用。Go 服务的每一个请求都是通过单独的 Goroutine 处理的,HTTP/RPC 请求的处理器会启动新的 Goroutine 访问数据库和其他服务。

一次请求可能需要创建多个 Goroutine 来处理,而context.Context的作用是在不同的 Goroutine 之间同步特定数据、取消信号以及处理请求的最终时限。

每一个context.Context都会从最顶层的 Goroutine 一层一层传递到最下层。context.Context可以在上层 Goroutine 执行出现错误时,将信号及时同步给下层。

如果没有这个信息传递,当最上层的 Goroutine 执行失败时,下层的 Goroutine 会继续占用资源。context.Context就可以通过信号及时停掉下层无用的工作以减少额外资源消耗。

1 简单示例

创建一个过期时间为 1s 的上下文,并向上下文传入handle函数,该方法会使用 500ms 的时间处理传入的请求:

func handle(ctx context.Context, duration time.Duration) {
	select {
	case <-time.After(duration):
		fmt.Println("process request with", duration)
	case <-ctx.Done():
		fmt.Println("handle", ctx.Err())
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()

	go handle(ctx, 1100*time.Millisecond)

	select {
	case <-ctx.Done():
		fmt.Println("main", ctx.Err())
	}

start:
	goto start
}

因为过期时间大于处理时间,所以我们有足够的时间处理该请求,上述代码的输出结果:

$ go run main.go
process request with 500ms
main context deadline exceeded

handle函数没有进入select的超时分支,但是main函数的select却会等待

context.Context超时并打印“main context deadline exceeded”。

如果将处理请求的时间增加到 1500ms,整个程序都会因为上下文的过期而被中止。

$ go run main.go
main context deadline exceeded
handle context deadline exceeded

多个 Goroutine 同时订阅ctx.Done()管道中的消息,一旦接收到取消信号就立刻停止当前正在执行的任务。

二、默认上下文

context包中最常用的方法是context.Backgroundcontext.TODO,这两个方法都会返回预先初始化好的私有变量backgroundtodo,它们会在同一个 Go 程序中被复用:

func Background() Context {
	return background
}

func TODO() Context {
	return todo
}

这两个私有变量都是通过new(emptyCtx)语句初始化的,它们指向私有结构体context.emptyCtx指针:

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.Backgroundcontext.TODO只是互为别名,没有太大的区别,只是在使用的语义上稍有不同:

  • context.Background是上下文的默认值,所有其他的上下文都应该从它衍生出来
  • context.TODO应该仅在不确定应该使用哪种上下文时使用

多数情况下,如果当前函数没有上下文作为入参,应该使用context.Background作为起始的上下文向下传递。

三、取消信号

context.WithCancel函数能够从context.Context中衍生出一个新的子上下文并返回用于取消该上下文的函数。一旦我们执行返回的取消函数,当前上下文以及它的子上下文都会被取消,所有的 Groutine 都会同步收到这一取消信号。

context.WithCancel的函数实现:

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

context.newCancelCtx函数将传入的上下文包装成私有结构体context.cancelCtx

context.propagateCancel函数的作用是当父上下被取消时,让子上下文也被取消。

func propagateCancel(parent Context, child canceler) {
	done := parent.Done()
	if done == nil {
		return // 父上下文没被取消时直接返回
	}

	select {
	case <-done:
		child.cancel(false, parent.Err())  // 父上下文已被取消
		return
	default:
	}

	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 {
		atomic.AddInt32(&goroutines, +1)
		go func() {
			select {
			case <-parent.Done():
				child.cancel(false, parent.Err())
			case <-child.Done():
			}
		}()
	}
}

上述函数中,与父上下文相关的情况有三种:

  1. parent.Done() == nil,也就是parent没有触发取消事件时,当前函数直接返回
  2. child的继承链包含以下可以取消的上下文时,会判断parent是否已经触发了取消信号:
    1. 如果parent已经被取消,child会立刻被取消
    2. 如果parent没有被取消,child会被加入parentchildren列表中,等待parent释放取消信号
  3. parent是开发者自定义的实现了context.Context接口的类型并在Done()方法中返回了非空的管道时:
    1. 运行一个新的 Goroutine 同时监听parent.Done()child.Done()两个 Channel
    2. parent.Done()关闭时调用child.cancel取消子上下文

取消信号还有两个函数context.WithDeadlinecontext.WithTimeoutcontext.WithDeadlinecontext.WithTimeout都能创建可以被取消的计时器上下文context.timerCtx

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

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

context.WithDeadline在创建context.timerCtx的过程中判断了父上下文的最终时限与当前时间,并通过time.AferFunc创建定时器,当时间超过了最终时限后会调用context.timerCtx.cancel同步取消信号。

context.timeCtx内部不仅通过嵌入context.cancelCtx结构体继承了相关的变量和方法,还通过持有的定时器timer和最终时限deadline实现定时取消的功能:

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

context.timerCtx.cancel方法不仅调用了context.cancelCtx.cancel,还会停止持有的定时器以减少不必在的资源浪费。

四、传值

上下文还有一个功能——传值。

context.WithValue能从父上下文中创建一个子上下文,传值的子上下文使用context.valueCtx类型:

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.valueCtx结构体会将除了Value之外的ErrDeadline等方法代理到父上下文中,它只会响应context.valueCtx.Value方法,该方法的实现也很简单:

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

如果context.valueCtx中存储的键值对与context.valueCtx.Value方法中传入的参数不匹配,就会从父上下文中查找该键对应的值到某个父上下文中返回nil或者查到到对的应值。

五、小结

Go 语言中的context.Context的主要作用还是在多个 Goroutine 组成的树中同步取消信号以减少对资源的消耗和占用,虽然它也有传值的功能,但是这个功能很少用到。

在使用传值功能时要非常谨慎,因为使用context.Context传递参数请求的所有参数是一种非常差的设计,比较常见的使用场景是传递请求对应用户的认证令牌以及用于进行分布式追踪的请求 ID。

posted @ 2021-04-06 08:20  thepoy  阅读(477)  评论(0编辑  收藏  举报