今天说一下这个Go Context解析 A Brief Inquiry Into Go Context

什么是context

Package context defines the Context type, which carries deadlines,

cancellation signals, and other request-scoped values across API boundaries

and between processes.

在context的package中如此介绍context,很容易可以看出context的三个主要功能

  • 携带截止时间
  • 携带取消信号
  • 在携带请求相关的值

作用范围为api边界和进程之间

为什么需要context

从context的介绍中就可以看出context的主要是为了进行协程取消或者并发控制,传值为一额外功能。

在Go语言的wiki中如此介绍 Go is syntactically similar to C, but with memory safety, garbage collection, structural typing,and CSP-style concurrency. 最后一项处理并发也就是Go语言的一大特性,这也就能理解为什么Go需要context来作为并发控制中重要的一环。

众所周知,Go语言有四个进行并发控制的工具

  • 全局变量
  • channel
  • waitgroup
  • context

要理解为什么context也是其中不可或缺的一环,不妨可以提出一个疑问: 如果没有context的话,会怎么样呢?

// withoutCancel 不取消协程
func withoutCancel() {
	go func() {
		go func() {
			defer fmt.Println("child dead")
			for {
				fmt.Println("child running...")
				time.Sleep(1 * time.Second)
			}
		}()
		for i := 0; i < 2; i++ {
			fmt.Println("father running...")
			time.Sleep(1 * time.Second)
		}
		defer fmt.Println("father dead")
	}()
	time.Sleep(5 * time.Second)
}
output:
father running...
child running...
child running...
father running...
father dead
child running...
child running...
child running...
child running...

很明显这边father协程在结束后,child协程仍然在运行中,且最后并没有进行正常退出,那么是否有什么替代品来进行协程取消呢?

全局变量?明显不可,如果服务每次请求的控制都由同一个全局变量来控制,那很容易就会发生阻塞。

waitgroup是被设计用来控制多协程同步的,似乎也不适合用来控制单个协程。

那只能用channel来控制单个协程了,试下呗

// withChannel 使用通道取消协程
func withChannel() {
	go func() {
		c := make(chan bool)
		defer fmt.Println("father dead")
		go func() {
			defer fmt.Println("child dead")
			for {
				select {
				case <-c:
					return
				default:
					fmt.Println("child running...")
					time.Sleep(1 * time.Second)
				}
			}
		}()
		for i := 0; i < 2; i++ {
			time.Sleep(1 * time.Second)
			fmt.Println("father running...")
		}
		c <- true
	}()
	time.Sleep(5 * time.Second)
}
output:
child running...
father running...
child running...
child running...
father running...
child dead
father dead

这里看起来效果不错,father协程通过往channel中放入一个值来通知child协程结束。

如果有多个协程呢?往一个channel内塞多个值显然不可能,多个消费协程会进行争抢无法进行有效管理。如果是一个协程对应一个channel,新建多个channel的资源消耗不说,多个channel的管理与使用在协程数量变多后将会变得异常混乱复杂。而context可以很简单优雅的解决这一问题。

context源码及设计

Interface

先看下context的接口

type Context interface {
    Deadline() (deadline time.Time, ok bool) // 返回context被取消的时间
    Done() <-chan struct{} // 返回一个channel,这个channel会在当前工作完成或者上下文被取消后关闭
    Err() error // 返回context结束的原因
    Value(key interface{}) interface{}
}

在context包中有4个重要的类分别实现了这个interface

  • emptyCtx
  • valueCtx
  • cancelCtx
  • timerCtx

这四个类分别实现了context的强大功能

emptyCtx

type emptyCtx int

emptyCtx就是一个空的context,无法被取消,没有截止时间,没有值。

context.Background() 和 context.TODO()就是返回一个emptyCtx

valueCtx

type valueCtx struct {
	Context
	key, val interface{}
}

valueCtx是一个用来携带key-value的context,可以看到一个valueCtx中只存了一个key和一个value,那么context是如何实现存储多个kv的呢,可以看下 func WithValue是怎么实现的

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

可以看出来每个valueCtx中还存有一个parentCtx,当context中需要存多个值时,实际上就是个valueCtx的linked list。context.Value()实际上就是通过遍历linked list来实现kv的查找的, 看下context.Value()的实现就可以很容易验证出来。

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

cancelCtx

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
}

cancelCtx主要用来实现context 取消的功能。和valueCtx一样,一个cancelCtx中包含一个parentCtx,说明cancelCtx也拥有继承关系,另外其中还包含children map,看起来是一个树状的结构,等会可以观察下如何使用children。以及一个channel来实现context.Done()中返回的channel,一个mutex保证并发安全,以及一个err来记录取消的原因。

WithCancel()function可以生成一个cancelCtx,看下是如何实现的

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) }
}
func newCancelCtx(parent Context) cancelCtx {
	return cancelCtx{Context: parent}
}

可以看到和valueCtx一样,先把parentCtx存到cancelCtx中,再进行一个propagateCancel 传播取消的动作。看下propagateCancel是如何实现的

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

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

前几步很简单,是对parent是否已经结束状态的一些检查。

p, ok := parentCancelCtx(parent)是从parent中一直往上寻找,看parent是不是也是继承自另外一个cancelCtx

如果parent确实是继承自一个cancelCtx,那么就把新的child也挂载到此cancelCtx的children map下。

如果没有,则新建一个协程监听parent是否取消,如果parent取消,child也会取消

可以看出来 cancelCtx其实是一个树状的结构,当parentCtx取消后,children map中的child ctx也都会进行取消的动作。

figma-cancelCtx.png

timerCtx

type timerCtx struct {
	cancelCtx
	timer *time.Timer // Under cancelCtx.mu.

	deadline time.Time
}

了解了cancelCtx之后timerCtx就非常简单,一个timerCtx内包含了一个cancelCtx来执行取消的操作,一个计时器以及一个时间来记录结束时间

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

创建一个timerCtx也很容易,可以看出大部分和创建一个cancelCtx几乎一样,只要再新建一个timer计时器来进行cancel的操作即可。

思考: context真的是个好设计吗?

至此相信你应该以及知道context是如何实现的了。

context一直是被认为一个小而美的设计,context包确实也以一种巧妙的方式实现了context的这些功能。但是任然存在一些值得斟酌的点,以下观点并非全部来自个人,仅供讨论

Context everywhere! Context在go代码中像病毒一样蔓延,即使是不需要的代码也需要传递context。

相信你曾经问过或者被问过一个问题,这个函数里context要传什么呢?回答:传context.TODO()就行了,没啥原因,传就行了。

正是因为context在go代码中到处蔓延,所以才会出现context.TODO()这种让人匪夷所思的东西

valueCtx是否有必要?为什么用一个链表实现了map?

在context包一开始就有介绍写到 Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.

相信大家在readability考试中看到过把整个结构体塞到context中来传递的行为,但是如何定义哪些数据可以放到context中呢,哪些又不可以呢?我们现在的各种日志插件,error插件,已经把这些信息也存到context中,这些也并非所谓的request-scoped data,这也是值得探讨的一个点。

Last but not least

ctx context.Context这个写法符合readability吗?

Reference

正文完