Go并发学习(二)——Context(上下文)

参考:Go语言设计与实现

什么是Context

上下文 context.Context是 Go 语言中用来设置截止日期、同步信号,传递请求相关值的结构体。上下文与 Goroutine 有比较密切的关系,是 Go 语言中独特的设计,在其他编程语言中我们很少见到类似的概念。

context.Context 是 Go 语言在 1.7 版本中引入标准库的接口,该接口定义了四个需要实现的方法,其中包括:

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

Context设计原理

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

如下图所示,程序可能会创建多个goroutine来处理一次请求,我们会借助Context在不同 Goroutine 之间同步请求特定数据、取消信号以及处理请求的截止日期。

Go并发学习(二)——Context(上下文)_第1张图片

Context在goroutine之间传递信息

每一个Context都会从顶层的goroutine一层一层传递到最底层,如果最上层的goroutine执行出错,那么下层goroutine也可以同步获知,这样就可以避免执行不必要的计算,节约资源
Go并发学习(二)——Context(上下文)_第2张图片
如果不借助Context,那么下层goroutine无法及时同步错误信息,可能会继续运行
Go并发学习(二)——Context(上下文)_第3张图片

下面是一个例子,展示了Context是如何同步传递信息的
在这段代码中,我们创建了一个过期时间为 1s 的上下文,并向上下文传入 handle 函数,该方法会使用 500ms 的时间处理传入的请求:

func main() {
	
	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
	// 这一步会清理还在工作的剩余协程
	defer cancel()

	go handle(ctx, 500*time.Millisecond)
	select {
	case <-ctx.Done():
		fmt.Println("main", ctx.Err())
	}
}

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

因为过期时间大于处理时间,所以我们有足够的时间处理该请求,运行上述代码会打印出下面的内容

process request with 500ms
main context deadline exceeded

假如我们将handle函数里传入的时间设置为1500ms,这个函数的处理时间比Context上下文过期时间更长,在1s的时候Context过期的时间就已经同步到这个工作协程中了,所以这个goroutine是不会成功输出的。

main context deadline exceeded

总结

其实Context最关键的一点就是同步信息,多个goroutine同时订阅了ctx.Done(),一旦接收到取消信号就停止当前工作

默认上下文

context 包中除了Context接口之外,最常用的方法还是 context.Background、context.TODO,这两个方法都会返回预先初始化好的私有变量 background 和 todo,它们会在同一个 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.Background 和 context.TODO 也只是互为别名,没有太大的差别,只是在使用和语义上稍有不同:

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

大多数情况下,如果当前函数没有Context作为入参,我们一般会使用context.Background作为起始的上下文进行传递

取消信号——WithCancel()

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

我们看一下context.WithCancel的源码,看看它是如何实现的

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
	c := newCancelCtx(parent) //1
	propagateCancel(parent, &c) //2
	return &c, func() { c.cancel(true, Canceled) }
}
  1. context.newCancelCtx 将传入的上下文包装成私有结构体 context.cancelCtx;
  2. 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 {
			p.children[child] = struct{}{}
		}
		p.mu.Unlock()
	} else {
		go func() {
			select {
			case <-parent.Done():
				child.cancel(false, parent.Err())
			case <-child.Done():
			}
		}()
	}
}

上述函数总共有3种不同的父子上下文的处理情景:

  1. 首先判断parent上下文是否已经取消,也就是parent.Done()==nil如果成立,表示parent未取消,所以child可以继续工作,直接return即可
  2. 当child的信息链路上包含了可以被取消的上下文,会再判断一次parent是否已经收到了取消信号
    i.如果收到了,那么child需要停止工作
    ii.如果没收到就无需执行操作
  3. 如果parent上下文是开发人员自定义的类型,并且实现了 context.Context 接口还在 Done() 方法中返回了非空的管道时;
    I.运行一个新的 Goroutine 同时监听 parent.Done() 和 child.Done() 两个 Channel;
    II.在 parent.Done() 关闭时调用 child.cancel 取消子上下文;

总结

context.propagateCancel 的作用是在 parent 和 child 之间同步取消和结束的信号,保证在 parent 被取消时,child 也会收到对应的信号,不会出现状态不一致的情况

补充

除了 context.WithCancel 之外,context 包中的另外两个函数 context.WithDeadlinecontext.WithTimeout 也都能创建可以被取消的计时器上下文 context.timerCtx
其实WithTimeout的实现也是依靠的底层调用WithDeadline函数。

// 1
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
	return WithDeadline(parent, time.Now().Add(timeout))
}
// 2
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
	if cur, ok := parent.Deadline(); ok && cur.Before(d) {
		return WithCancel(parent)
	}
	c := &timerCtx{
		cancelCtx: newCancelCtx(parent),
		deadline:  d,
	}
	propagateCancel(parent, c)
	dur := time.Until(d)
	if dur <= 0 {
		c.cancel(true, DeadlineExceeded) // 已经过了截止日期
		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 包中的 context.WithValue 能从父上下文中创建一个子上下文,传值的子上下文使用 context.valueCtx 类型

func WithValue(parent Context, key, val interface{}) Context {
	if key == nil {
		panic("nil key")
	}
	if !reflectlite.TypeOf(key).Comparable() {
		panic("key is not comparable")
	}
	return &valueCtx{parent, key, val}
}

context.valueCtx 结构体会将除了 Value 之外的 Err、Deadline 等方法代理到父上下文中它只会响应 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 (顶级Context)或者查找到对应的值。

小结

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

你可能感兴趣的:(golang)