文章

golang-context源码解析

context

上下文Context

context.Context是Go用来设置截止日期,同步信号,传递请求相关值的结构体。上下文与Goroutine有比较密切的关系,是Go语言中独特的设计。

context.Context接口定义了四个需要实现的方法,其中包括:

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

context包中提供的context.Background,context.TODO,context.WithDeadline和context.WithVaule,函数会返回实现该接口的私有结构体。

设计原理

在Goroutine构成的树形结构中对信号进行同步以减少计算资源的浪费是context.Context的最大作用。

Go服务的每一个请求都是通过单独的Goroutine处理的,HTTP/RPC请求的处理器会启动新的Goroutine访问数据库和其他服务。

我们可能会创建多个Goroutine来处理一次请求,而context.Context的作用是在不同Goroutine之间同步请求特定数据、取消信号以及处理请求的截止日期。

image-20230707112606245

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

image-20230707112724998

当上层的Goroutine因为某些原因执行失败时,下层的Goroutine由于没有接收到这个信号所以会继续工作,但是当我们正确地使用context.Context时,就可以在下层及时停掉无用的工作以减少额外资源的消耗。

image-20230707113047744

context.Context是如何对信号进行同步的,我们创建一个过期时间为1s的上下文,并向上下文传入handle函数,该方法会使用500ms的时间处理传入的请求:

func main() {
   ctx, cancelFunc := context.WithTimeout(context.Background(), 1*time.Second)
   defer cancelFunc()
   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("请求处理时间:", duration)
   }
}

设置的过期时间大于处理时间,所以我们有足够的时间处理该请求,上面运行结果为:

请求处理时间: 500ms
main context deadline exceeded

默认上下文

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

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

这两个私有变量都是通过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 any) any {
   return nil
}

从上面的代码看,可以发现context.emptyCtx通过空方法实现了contetx.Context接口中的所有方法,它没有任何功能。

image-20230708134329665

从源码来看,context.Background和context.TODO也只是互为别名,没有太大的差别,只是在使用和语义上稍有不同:

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

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

取消信号

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

image-20230708140042461

直接看context.WithCancel函数的实现来看它到底做了什么:

// 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) }
}
  • context.newCancelCtx将传入的上下文包装成私有结构体 context.canelCtx;
  • context.propagateCancel会构建父子上下文之间的关联,当父上下文被取消时,子上下文也会被取消:
// propagateCancel arranges for child to be canceled when parent is.
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:
   }

   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 {
      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. 如果已经被取消,child会立刻被取消;
    2. 如果没有被取消,child会被加入parent的children列表中,等待parent释放取消信号;
  3. 当父上下文是开发者自定义的类型、实现了context.Context接口并在Done()方法中返回了非空的管道时;
    1. 运行一个新的Goroutine同时监听parent.Done()和child.Done()两个Channel;
    2. 在parent.Done()关闭时调用child.cancel取消子上下文;

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

context.cancelCtx实现的几个接口方法也没有太多值得分析的地方,该结构体最重要的方法是context.cancelCtx.cancel,该方法会关闭上下文中的Channel并向所有的子上下文同步取消信号:

func (c *cancelCtx) cancel(removeFromParent bool, err error) {
	c.mu.Lock()
	if c.err != nil {
		c.mu.Unlock()
		return
	}
	c.err = err
	if c.done == nil {
		c.done = closedchan
	} else {
		close(c.done)
	}
	for child := range c.children {
		child.cancel(false, err)
	}
	c.children = nil
	c.mu.Unlock()

	if removeFromParent {
		removeChild(c.Context, c)
	}
}

除了context.WithCancel之外,context包中的另外两个函数context.WithDeadline和context.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 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.WithDeadline在创建context.timerCtx的过程中判断了父上下文的截止日期与当前日期,并通过time.AfterFunc创建定时器,当时间超过了截止日期后会调用context.timerCtx.cancel同步取消信号。

context.timerCtx内部不仅通过嵌入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 {
		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包中的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或者查找到对应的值。

以上内容借鉴https://draveness.me/golang/docs/part3-runtime/ch06-concurrency/golang-context/

License:  CC BY 4.0 test