context 从入门到深入了解


1. 前言

在 Go 语言中,上下文 context.Context 用来设置截止日期,同步信号,传递值的功能,它与 goroutine 关系密切,被用来解决 goroutine 之间 退出通知元数据传递 等的任务。本文通过示例代码来学习梳理 context.Context 包,希望做到从入门到深入了解。

2. context.Context 包类型

首先看类图如下:

从类图可以看出:

  • context.Context 是个接口类型,它实现了 Deadline(),Done(),Err() 和 Value(key interface{}) 方法。各方法的功能如下:
    • Deadline: 返回上下文 context.Context 的截止时间,截止时间到将取消该上下文。
    • Done: 返回只读空结构体通道。源码中没有向该通道写结构体,调用该方法会使通道阻塞在接收数据,直到关闭该通道(关闭通道会读到结构体的零值)。
    • Err: 返回上下文 context.Context 结束的错误类型。有两种错误类型:
      • 如果 context.Context 被取消,则返回 canceled 错误;
      • 如果 context.Context 超时,则返回 DeadlineExceeded 错误。
    • Value: 返回 context.Context 存储的键 key 对应的值。
  • canceler 也是一个接口,该接口实现了 cancel(removeFromParent bool, err error) 和 Done() 方法。实现了该接口的上下文 context.Context 均能被取消(通过调用 cancel 方法取消)。
  • cancelCtx 和 timerCtx(timerCtx 内嵌了 cancelCtx 结构体) 均实现了 canceler 接口,因此这两类上下文是可取消的。
  • emptyCtx 是空的上下文。它被 Backgroud 函数调用作为父上下文或被 ToDo 函数调用,用于不明确传递什么上下文 context.Context 时使用。
  • valueCtx 是存储键值对的上下文。

3. 代码示例

前面解释如果看不懂也没关系,这里通过代码来分析 context.Context 包的内部原理,毕竟 talk is cheap...

3.1 代码示例一: 单子 goroutine

func worker1(ctx context.Context, name string) {
	time.Sleep(2 * time.Second)
	for {
		select {
		case <-ctx.Done():
			fmt.Println("worker1 stop", name, ctx.Err())
			return
		default:
			fmt.Println(name, "send request")
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())

	go worker1(ctx, "worker1")

	time.Sleep(1 * time.Second)
	cancel()
	time.Sleep(1 * time.Second)
}

代码的输出为:worker1 stop worker1 context canceled

逐层分析看代码为何输出信息如上。首先,查看 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) }
}

函数接受的是父上下文,也就是 main 中传入的函数 context.Background() 返回的 emptyCtx 上下文。在 newCancelCtx 函数新建 context.cancelCtx 上下文:

func newCancelCtx(parent Context) cancelCtx {
	return cancelCtx{Context: parent}
}

然后,propagateCancel 函数将父上下文的 cancel 信号传递给新建的 context.cancelCtx:

func propagateCancel(parent Context, child canceler) {
	done := parent.Done()
	if done == nil {
		return // parent is never canceled
	}
    ...

截取部分函数内容分析,后续会接着介绍。由于 emptyCtx 不会被取消,它的 Done 方法返回值为 nil,实际上执行到第一个判断 if done == nil 函数就会返回。

最后,返回新建上下文 context.cancelCtx 的地址及 CancelFunc 函数 func() { c.cancel(true, Canceled) }。后续取消上下文即是通过调用该函数取消的。

花开两朵,各表一枝。在把视线移到 worker1 函数,这个函数需要介绍的即是 ctx.Done() 方法,前面说过它返回只读通道,如果通道不关闭,将一直是阻塞状态。从时间上看,当子 goroutine 还在 sleep,即还未调用 ctx.Done 方法,main 中的 cancel() 函数已经执行完了。那么,cancel 函数做了什么动作呢?接着看:

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
	if c.done == nil {
		c.done = closedchan
	} else {
		close(c.done)
	}
	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 函数中的关键代码是 c.done = closedchan,由于 goroutine 中还未调用 ctx.Done 方法,所以这里 context.cancelCtx 的 done 属性还是 nil。closedchan 是个已关闭通道,它在 context.Context 包的 init 函数就已经关闭了:

var closedchan = make(chan struct{})

func init() {
	close(closedchan)
}

那么等 goroutine 睡醒了就知道通道已经关闭了从而读取到通道类型的零值,然后退出 goroutine。即打印输出 worker1 stop worker1 context canceled

到这里这一段代码的解释基本上结束了,还有一段是 cancel() 的执行要介绍,在 c.children for 循环这里,由于 c context.cancelCtx 没有 children 也即 c.children 是 nil,从而跳出 for 循环。

在 removeChild 函数中,父上下文 parent 并未取消,所以函数 parentCancelCtx 返回 ok 为 false,从而退出函数:

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

3.2 代码示例二:单子 goroutine

讨论完上一段代码,在看另一种变形就不难理解了,即子 goroutine 在取消前执行的情况。代码就不贴了,只是 sleep 时间换了下。区别在于 cancel 函数的判断:

if c.done == nil {
    c.done = closedchan
} else {
    close(c.done)
}

由于子 goroutine 中已经调用了 ctx.Done() 方法:

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
} 

所以这里 c.done 的判断将不等于 nil 而走向 close(c.done) 直接关闭通道。

3.3 代码示例三:多子 goroutine

多子 goroutine 即一个 parent context.Context 有多个子 context.cancelCtx 的情况。如代码所示:

func worker1(ctx context.Context, name string, cancel context.CancelFunc) {
	time.Sleep(2 * time.Second)
	cancel()

	for {
		select {
		case <-ctx.Done():
			fmt.Println("worker1 stop", name, ctx.Err())
			return
		default:
			fmt.Println(name, "send request")
		}
	}
}

func worker2(ctx context.Context, name string) {
	time.Sleep(2 * time.Second)

	for {
		select {
		case <-ctx.Done():
			fmt.Println("worker2 stop", name, ctx.Err())
			return
		default:
			fmt.Println(name, "send request")
		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())

	go worker1(ctx, "worker1", cancel)
	go worker2(ctx, "worker2")

	time.Sleep(1 * time.Second)
	cancel()
	time.Sleep(2 * time.Second)
}

类似于代码示例一中单子 goroutine 的情况。区别在于同步锁这里:

c.mu.Lock()
if c.err != nil {
	c.mu.Unlock()
	return // already canceled
}

这里如果有一个 goroutine 调用了 cancel() 方法,c.err 就不等于 nil,其它 goroutine 再去调用 cancel() 就会判断 if c.err != nil 从而直接退出。这也引申出一点,上下文 context.Context 的方法是幂等性的,对于不同 goroutine 调用同样的上下文 context.Context 会得到相同的结果。

3.4 代码示例四:单父单子和单孙上下文

3.4.1 场景一

构造这样一种场景:父上下文 parent 有一个子上下文,该子上下文还有一个子上下文,也就是父上下文 parent 的孙上下文:

func worker3(ctx context.Context, name string, cancel context.CancelFunc) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("worker3 stop", name, ctx.Err())
			return
		default:
			fmt.Println(name, "send request")
		}
	}
}

func worker2(ctx context.Context, name string) {
	time.Sleep(2 * time.Second)

	cctx, cancel := context.WithCancel(ctx)
	go worker3(cctx, "worker3", cancel)
	time.Sleep(2 * time.Second)
	cancel()

	for {
		select {
		case <-ctx.Done():
			fmt.Println("worker2 stop", name, ctx.Err())
			return
		default:
			fmt.Println(name, "send request")

		}
	}
}

func main() {
	ctx, cancel := context.WithCancel(context.Background())

	go worker2(ctx, "worker2")

	time.Sleep(1 * time.Second)
	cancel()
	time.Sleep(2 * time.Second)
}

输出结果:

worker3 stop worker3 context canceled
worker2 stop worker2 context canceled

在这样一个场景下,子上下文会先于孙上下文取消,同样的层层查看为何会打印以上输出。首先,对于main 中的 cancel() 函数,当它运行时孙上下文还未创建,所以它的运行和代码示例一样。那么,我们看当孙上下文 cctx 创建时发生了什么:

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

还是看 propagateCancel 函数,由于传入的 parent context.Context 已经取消了,所以 case <- done 会读到结构体的零值,进而调用 child.cancel 方法:

func (c *cancelCtx) cancel(removeFromParent bool, err error) {
    ...
	c.err = err
	if c.done == nil {
		c.done = closedchan
	} else {
		close(c.done)
	}
	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 代码,类似前文调用 c.done = closedchan 关闭上下文 cctx 的通道,接着执行 cancel 方法,由于 cctx 并没有 children 同样的 for child := range c.children 将跳出循环,并且 removeFromParent 为 false 跳出 if 判断。

此时孙上下文 cctx 通道已经被关闭了,再次调用 cancel() context.cancelFunc 会判断 if c.err != nil 进而退出。

3.4.2 场景二

更改 sleep 时间,使得 main 中 cancel 函数在孙上下文 cancel() 执行后执行。由于子上下文并未 cancel,在 propagateCancel 里会走到 parentCancelCtx 判断这里,这里通过 p.children[child] = struct{}{} 将孙上下文绑定:

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

绑定的目的是:对下,当子上下文取消时会直接调用孙上下文取消,实现了取消信号的同步。对上,当孙上下文取消时会切断和子上下文的关系,保持子上下文的运行状态。这部分是在 cancel 函数里实现的:

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

对于 removeFromParent 函数,重点是其中的 delete 函数 delete(p.children, child) 将子上下文从父上下文的 p.children map 中移除掉:

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

3.5 代码示例四:多子上下文

直接看图:

图片来自 深度解密 Go 语言之context,这里不作过多分析,有兴趣的读者可自行研究。相信通过前文几个代码示例的梳理已基本到深入了解的程度了。

4. 附言

本文希望通过代码的梳理达到从入门上下文 context.Context 到深入了解的程度,然而本文并未高屋建瓴的对其中的设计进行抽象,也并未分析 context.Context 的由来及其它上下文 context.Context 如 valueCtx 和 timerCtx 等的分析,这些内容是本文缺乏的。幸好网上有较好的文章记录,想更深入了解,推荐博文:

  1. 深度解密 Go 语言之 context
  2. Go 语言设计与实现:上下文 Context
  3. Go 标准库之 context

芝兰生于空谷,不以无人而不芳。
原文地址:https://www.cnblogs.com/xingzheanan/p/15692269.html