前言

goroutine
goroutine
goroutinegoroutine

什么是 context

Contextgoroutine
contextselect-casegoroutine

context 基本特性

在 Go context 用法中,我们常常将其与 select 关键字结合使用,用于监听其是否结束、取消等。

演示代码:

func main() {
	parentCtx := context.Background()
	ctx, cancel := context.WithTimeout(parentCtx, 1*time.Millisecond)
	defer cancel()

	select {
	case <-time.After(1 * time.Second):
		fmt.Println("overslept")
	case <-ctx.Done():
		fmt.Println(ctx.Err())
	}
}

输出结果:

context deadline exceeded
context.WithTimeoutparentCtxselect-casecontext.Doneselectcontext.Errcasecontext deadline exceeded

除了上述所描述的方法外,标准库 context 还支持下述方法:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
type Context
    func Background() Context
    func TODO() Context
    func WithValue(parent Context, key, val interface{}) Context
  • WithCancel:基于父级 context,创建一个可以取消的新 context。
  • WithDeadline:基于父级 context,创建一个具有截止时间(Deadline)的新 context。
  • WithTimeout:基于父级 context,创建一个具有超时时间(Timeout)的新 context。
  • Background:创建一个空的 context,一般常用于作为根的父级 context。
  • TODO:创建一个空的 context,一般用于未确定时的声明使用。
  • WithValue:基于某个 context 创建并存储对应的上下文信息。

如果是更进一步结合 goroutine 的话,常见的例子是:

func(ctx context.Context) <-chan int {
  dst := make(chan int)
  n := 1
  go func() {
   for {
    select {
    case <-ctx.Done():
     return
    case dst <- n:
     n++
    }
   }
  }()
  return dst
 }
goroutinegoroutinefor+selectcontextgoroutine

context 正确使用方式

对第三方调用传入 context

在 Go 语言中,Context 已经是默认支持的规范了。因此我们对第三方有调用诉求的时候,可以传入 context:

func main() {
 req, err := http.NewRequest("GET", "https://xxx.com/", nil)
 if err != nil {
  fmt.Printf("http.NewRequest err: %+v", err)
  return
 }

 ctx, cancel := context.WithTimeout(req.Context(), 50*time.Millisecond)
 defer cancel()

 req = req.WithContext(ctx)
 resp, err := http.DefaultClient.Do(req)
 if err != nil {
  fmt.Printf("http.DefaultClient.Do err: %+v", err)
  return
 }
 defer resp.Body.Close()
}
context
context

不要将上下文存储在结构类型中

context
contextctxcontext
  • 底层基础库。
  • DDD 结构。
context

函数调用链必须传播上下文

我们会把 context 作为方法首位,本质目的是为了传播 context,自行完整调用链路上的各类控制:

func List(ctx context.Context, db *sqlx.DB) ([]User, error) {
 ctx, span := trace.StartSpan(ctx, "internal.user.List")
 defer span.End()

 users := []User{}
 const q = `SELECT * FROM users`

 if err := db.SelectContext(ctx, &users, q); err != nil {
  return nil, errors.Wrap(err, "selecting users")
 }

 return users, nil
}

像在上述例子中,我们会把所传入方法的 context 一层层的传进去下一级方法。这里就是将外部的 context 传入 List 方法,再传入 SQL 执行的方法,解决了 SQL 执行语句的时间问题。

context 的继承和派生

在 Go 标准库 context 中具有以下派生 context 的标准方法:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

代码例子如下:

func handle(w http.ResponseWriter, req *http.Request) {
  // parent context
 timeout, _ := time.ParseDuration(req.FormValue("timeout"))
 ctx, cancel := context.WithTimeout(context.Background(), timeout)

  // chidren context
 newCtx, cancel := context.WithCancel(ctx)
 defer cancel()
 // do something...
}
contextcontextgoroutinecontextcontext

不传递 nil context

context
context.Background
var (
   background = new(emptyCtx)
   todo       = new(emptyCtx)
)

func Background() Context {
   return background
}

func TODO() Context {
   return todo
}
contextcontext.TODOcontextContext

context 仅传递必要的值

contextgRPCmetadatacontextcontext
type Context
    func WithValue(parent Context, key, val interface{}) Context

代码例子如下:

func main() {
 type favContextKey string
 f := func(ctx context.Context, k favContextKey) {
  if v := ctx.Value(k); v != nil {
   fmt.Println("found value:", v)
   return
  }
  fmt.Println("key not found:", k)
 }

 k := favContextKey("小米")
 ctx := context.WithValue(context.Background(), k, "小米")

 f(ctx, k)
 f(ctx, favContextKey("小红"))
}

输出结果:

found value: 小米
key not found: 小红
contextgoroutinegRPC
contextcontext

总结

  • 对第三方调用要传入 context,用于控制远程调用。
  • 不要将上下文存储在结构类型中,尽可能的作为函数第一位形参传入。
  • 函数调用链必须传播上下文,实现完整链路上的控制。
  • context 的继承和派生,保证父、子级 context 的联动。
  • 不传递 nil context,不确定的 context 应当使用 TODO。
  • context 仅传递必要的值,不要让可选参数揉在一起。

context 本质

我们在基本特性中介绍了不少 context 的方法,其基本大同小异。看上去似乎不难,接下来我们看看其底层的基本原理和设计。

context 相关函数的标准返回如下:

func WithXXXX(parent Context, xxx xxx) (Context, CancelFunc)

其返回值分别是 Context 和 CancelFunc,接下来我们将进行分析这两者的作用。

接口

Context 接口:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}
  • Deadline:获取当前 context 的截止时间。
  • Done:获取一个只读的 channel,类型为结构体。可用于识别当前 channel 是否已经被关闭,其原因可能是到期,也可能是被取消了。
  • Err:获取当前 context 被关闭的原因。
  • Value:获取当前 context 对应所存储的上下文信息。

Canceler 接口:

type canceler interface {
	cancel(removeFromParent bool, err error)
	Done() <-chan struct{}
}
  • cancel:调用当前 context 的取消方法。
  • Done:与前面一致,可用于识别当前 channel 是否已经被关闭。

基础结构

emptyCtxcancelCtxtimerCtxvalueCtx

emptyCtx

context.Backgroundcontext.TODO

源码如下:

var (
	background = new(emptyCtx)
	todo       = new(emptyCtx)
)

func Background() Context {
	return background
}

func TODO() Context {
	return todo
}
emptyCtxemptyCtx
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
}
emptyCtx

cancelCtx

context.WithCancelcancelCtx
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
	c := newCancelCtx(parent)
	propagateCancel(parent, &c)
	return &c, func() { c.cancel(true, Canceled) }
}

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

首先 main goroutine 创建并传递了一个新的 context 给 goroutine b,此时 goroutine b 的 context 是 main goroutine context 的子集:


传递过程中,goroutine b 再将其 context 一个个传递给了 goroutine c、d、e。最后在运行时 goroutine b 调用了
cancel
方法。使得该 context 以及其对应的子集均接受到取消信号,对应的 goroutine 也进行了响应。
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
}
children

而其他的属性主要用于并发控制(互斥锁)、取消信息和错误的写入:

func (c *cancelCtx) Value(key interface{}) interface{} {
	if key == &cancelCtxKey {
		return c
	}
	return c.Context.Value(key)
}

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
}

func (c *cancelCtx) Err() error {
	c.mu.Lock()
	err := c.err
	c.mu.Unlock()
	return err
}
doneDoneselect-case

timerCtx

context.WithTimeouttimerCtx
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) {
	...
	c := &timerCtx{
		cancelCtx: newCancelCtx(parent),
		deadline:  d,
	}
}
timerCtxcancelCtxtimerCtx
type timerCtx struct {
	cancelCtx
	timer *time.Timer // Under cancelCtx.mu.

	deadline time.Time
}
cancelCtxtime.Timer

我们进一步看看其配套的 cancel 方法,思考一下其是如何进行取消动作的:

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()
}
cancelCtxtimerCtxWithDeadline
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
	if cur, ok := parent.Deadline(); ok && cur.Before(d) {
		// The current deadline is already sooner than the new one.
		return WithCancel(parent)
	}
	...
}

该方法会先进行前置判断,若父级节点的 Deadline 时间早于当前所指定的 Deadline 时间,将会直接生成一个 cancelCtx 的 context。

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
	...
	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) }
}
timeCtxtime.AfterFunccancel
func WithValue(parent Context, key, val interface{}) Context {
	...
	if !reflectlite.TypeOf(key).Comparable() {
		panic("key is not comparable")
	}
	return &valueCtx{parent, key, val}
}

你会发现 valueCtx 结构体也非常的简单,核心就是键值对:

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 是如何实现跨 context 的上下文信息获取的?

valueCtxValue

本质上
valueCtx
类型是一个单向链表,会在调用
Value
方法时先查询自己的节点是否有该值。若无,则会通过自身存储的上层父级节点的信息一层层向上寻找对应的值,直到找到为止。

而在实际的工程应用中,你会发现各大框架,例如:gin、grpc 等。他都是有自己再实现一套上下文信息的传输的二次封装,本意也是为了更好的管理和观察上下文信息。

context 取消事件

在我们针对 context 的各类延伸类型和源码进行了分析后。我们进一步提出一个疑问点,context 是如何实现跨 goroutine 的取消事件并传播开来的,是如何实现的?

WithCancelWithDeadlinepropagateCancel
func propagateCancel(parent Context, child canceler) {
	done := parent.Done()
	if done == nil {
		return
	}

	select {
	case <-done:
		child.cancel(false, parent.Err())
		return
	default:
	}
	...
}
DonenilBackgroundTODODonenil
func propagateCancel(parent Context, child canceler) {
	...
	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():
			}
		}()
	}
}

经过前面一个代码片段的判断,已得知父级 context 未触发取消事件,当前父级和子级 context 均正常(未取消)。

将会执行以下流程:

  • 调用 parentCancelCtx 方法找到具备取消功能的父级 context。并将当前 context,也就是 child 加入到 父级 context 的 children 列表中,等待后续父级 context 的取消事件通知和响应。
  • 调用 parentCancelCtx 方法没有找到,将会启动一个新的 goroutine 去监听父子 context 的取消事件通知。

通过对 context 的取消事件和整体源码分析,可得知 cancelCtx 类型的上下文包含了其下属的所有子节点信息:

childrenmap[canceler]struct{}
propagateCancelcacenl

总结

作为 Go 语言的核心功能之一,其实标准库 context 非常的短小精悍,使用的都是基本的数据结构和理念。既满足了跨 goroutine 的调控控制,像是并发、超时控制等。

同时也满足了上下文的信息传递。在工程应用中,例如像是链路ID、公共参数、鉴权校验等,都会使用到 context 作为媒介。

目前官方对于 context 的建议是作为方法的首参数传入,虽有些麻烦,但也有人选择将其作为结构体中的一个属性传入。但这也会带来一些心智负担,需要识别是否重新 new 一个。