首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Go Context 详解之终极无惑

Go Context 详解之终极无惑

作者头像
恋喵大鲤鱼
发布2022-05-09 11:21:41
3K0
发布2022-05-09 11:21:41
举报
文章被收录于专栏:C/C++基础C/C++基础

文章目录

  • 1.什么是 Context
  • 2.为什么要有 Context
  • 3.context 包源码一览
    • 3.1 Context
    • 3.2 CancelFunc
    • 3.3 canceler
    • 3.4 Context 的实现
      • 3.4.1 emptyCtx
      • 3.4.2 cancelCtx
      • 3.4.3 timerCtx
      • 3.4.4 valueCtx
  • 4.Context 的用法
    • 4.1 使用建议
    • 4.2 传递共享的数据
    • 4.3 取消 goroutine
    • 4.4 防止 goroutine 泄漏
  • 5.Context 的不足
  • 6.小结
  • 参考文献

1.什么是 Context

Go 1.7 标准库引入 Context,中文名为上下文,是一个跨 API 和进程用来传递截止日期、取消信号和请求相关值的接口。

context.Context 定义如下:

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key interface{}) interface{}
}

Deadline()返回一个完成工作的截止时间,表示上下文应该被取消的时间。如果 ok==false 表示没有设置截止时间。

Done()返回一个 Channel,这个 Channel 会在当前工作完成时被关闭,表示上下文应该被取消。如果无法取消此上下文,则 Done 可能返回 nil。多次调用 Done 方法会返回同一个 Channel。

Err()返回 Context 结束的原因,它只会在 Done 方法对应的 Channel 关闭时返回非空值。如果 Context 被取消,会返回context.Canceled 错误;如果 Context 超时,会返回context.DeadlineExceeded错误。

Value()从 Context 中获取键对应的值。如果未设置 key 对应的值则返回 nil。以相同 key 多次调用会返回相同的结果。

另外,context 包中提供了两个创建默认上下文的函数:

// TODO 返回一个非 nil 但空的上下文。
// 当不清楚要使用哪种上下文或无可用上下文尚应使用 context.TODO。
func TODO() Context

// Background 返回一个非 nil 但空的上下文。
// 它不会被 cancel,没有值,也没有截止时间。它通常由 main 函数、初始化和测试使用,并作为处理请求的顶级上下文。
func Background() Context

还有四个基于父级创建不同类型上下文的函数:

// WithCancel 基于父级创建一个具有 Done channel 的 context
func WithCancel(parent Context) (Context, CancelFunc)

// WithDeadline 基于父级创建一个不晚于 d 结束的 context
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

// WithTimeout 等同于 WithDeadline(parent, time.Now().Add(timeout))
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

// WithValue 基于父级创建一个包含指定 key 和 value 的 context
func WithValue(parent Context, key, val interface{}) Context

在后面会详细介绍这些不同类型 context 的用法。

2.为什么要有 Context

Go 为后台服务而生,如只需几行代码,便可以搭建一个 HTTP 服务。

在 Go 的服务里,通常每来一个请求都会启动若干个 goroutine 同时工作:有些执行业务逻辑,有些去数据库拿数据,有些调用下游接口获取相关数据…

在这里插入图片描述
在这里插入图片描述

协程 a 生 b c d,c 生 e,e 生 f。父协程与子孙协程之间是关联在一起的,他们需要共享请求的相关信息,比如用户登录态,请求超时时间等。如何将这些协程联系在一起,context 应运而生。

话说回来,为什么要将这些协程关联在一起呢?以超时为例,当请求被取消或是处理时间太长,这有可能是使用者关闭了浏览器或是已经超过了请求方规定的超时时间,请求方直接放弃了这次请求结果。此时所有正在为这个请求工作的 goroutine 都需要快速退出,因为它们的“工作成果”不再被需要了。在相关联的 goroutine 都退出后,系统就可以回收相关资源了。

总的来说 context 的作用是为了在一组 goroutine 间传递上下文信息(cancel signal,deadline,request-scoped value)以达到对它们的管理控制。

3.context 包源码一览

我们分析的 Go 版本依然是 1.17。

3.1 Context

context 是一个接口,某个类型只要实现了其申明的所有方法,便实现了 context。再次看下 context 的定义。

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key interface{}) interface{}
}

方法的作用在前文已经详述,这里不再赘述。

3.2 CancelFunc

另外 context 包中还定义了一个函数类型 CancelFunc

type CancelFunc func()

CancelFunc 通知操作放弃其工作。CancelFunc 不会等待工作停止。多个 goroutine 可以同时调用 CancelFunc。在第一次调用之后,对 CancelFunc 的后续调用不会执行任何操作。

3.3 canceler

context 包还定义了一个更加简单的用于取消操作的 context,名为 canceler,其定义如下。

// A canceler is a context type that can be canceled directly. The
// implementations are *cancelCtx and *timerCtx.
type canceler interface {
	cancel(removeFromParent bool, err error)
	Done() <-chan struct{}
}

因其首字母小写,所以该接口未被导出,外部包无法直接使用,只在 context 包内使用。实现该接口的类型有 *cancelCtx*timerCtx

为什么其中一个方法 cancel() 首字母是小写,未被导出,而 Done() 确是导出一定要实现的呢?为何如此设计呢?

(1)“取消”操作应该是建议性,而非强制性。 caller 不应该去关心、干涉 callee 的情况,决定如何以及何时 return 是 callee 的责任。caller 只需发送“取消”信息,callee 根据收到的信息来做进一步的决策,因此接口并没有定义 cancel 方法。

(2)“取消”操作应该可传递。 “取消”某个函数时,和它相关联的其他函数也应该“取消”。因此,Done() 方法返回一个只读的 channel,所有相关函数监听此 channel。一旦 channel 关闭,通过 channel 的“广播机制”,所有监听者都能收到。

3.4 Context 的实现

context 包中定义了 Context 接口后,并且给出了四个实现,分别是:

  • emptyCtx
  • cancelCtx
  • timerCtx
  • valueCtx

我们可以根据不同场景选择使用不同的 Context。

3.4.1 emptyCtx

emptyCtx 正如其名,是一个空上下文。无法被取消,不携带值,也没有截止日期。

// An emptyCtx is never canceled, has no values, and has no deadline. It is not
// struct{}, since vars of this type must have distinct addresses.
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
}

其未被导出,但被包装成如下两个变量,通过相应的导出函数对外提供使用。

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

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

从源代码来看,Background()TODO() 分别返回两个同类型不同的空上下文对象,没有太大的差别,只是在使用和语义上稍有不同:

  • Background() 是上下文的默认值,所有其他的上下文都应该从它衍生出来;比如用在 main 函数或作为最顶层的 context。
  • TODO() 通常用在并不知道传递什么 context 的情形下使用。如调用一个需要传递 context 参数的函数,你手头并没有现成 context 可以传递,这时就可以传递 todo。这常常发生在重构进行中,给一些函数添加了一个 Context 参数,但不知道要传什么,就用 todo “占个位子”,最终要换成其他 context。

3.4.2 cancelCtx

cancelCtx 是一个用于取消操作的 Context,实现了 canceler 接口。它直接将接口 Context 作为它的一个匿名字段,这样,它就可以被看成一个 Context。

// A cancelCtx can be canceled. When canceled, it also cancels any children
// that implement canceler.
type cancelCtx struct {
	Context

	mu       sync.Mutex            // protects following fields
	done     atomic.Value          // of 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 是一个未导出类型,通过创建函数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) }
}

// newCancelCtx returns an initialized cancelCtx.
func newCancelCtx(parent Context) cancelCtx {
	return cancelCtx{Context: parent}
}

传入一个父 Context(这通常是一个 background,作为根结点),返回新建的 Context,新 Context 的 done channel 是新建的。

注意: 从 cancelCtx 的定义和生成函数WithCancel()可以看出,我们基于父 Context 每生成一个 cancelCtx,相当于在一个树状结构的 Context 树中添加一个子结点。类似于下面这个样子:

先来看其Done()方法的实现:

func (c *cancelCtx) Done() <-chan struct{} {
	d := c.done.Load()
	if d != nil {
		return d.(chan struct{})
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	d = c.done.Load()
	if d == nil {
		d = make(chan struct{})
		c.done.Store(d)
	}
	return d.(chan struct{})
}

c.done 采用惰性初始化的方式创建,只有调用了Done()方法的时候才会被创建。再次说明,函数返回的是一个只读的 channel,而且没有地方向这个 channel 里面写数据。所以,直接读这个 channel,协程会被 block 住。一般通过搭配 select 来使用。一旦关闭,就会立即读出零值。

再看一下Err()String()方法,二者较为简单,Err() 用于返回错误信息,String()用于返回上下文名称。

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

type stringer interface {
	String() string
}

func contextName(c Context) string {
	if s, ok := c.(stringer); ok {
		return s.String()
	}
	return reflectlite.TypeOf(c).String()
}

func (c *cancelCtx) String() string {
	return contextName(c.Context) + ".WithCancel"
}

下面重点看下cancel()方法的实现。

// cancel closes c.done, cancels each of c's children, and, if
// removeFromParent is true, removes c from its parent's children.
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
	d, _ := c.done.Load().(chan struct{})
	if d == nil {
		c.done.Store(closedchan)
	} else {
		close(d)
	}
	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()方法的功能就是关闭 channel(c.done)来传递取消信息,并且递归地取消它的所有子结点;如果入参 removeFromParent 为 true,则从父结点从删除自己。达到的效果是通过关闭 channel,将取消信号传递给了它的所有子结点。goroutine 接收到取消信号的方式就是 select 语句中的读 c.done 被选中。

当 WithCancel() 函数返回的 CancelFunc 被调用或者父结点的 done channel 被关闭(父结点的 CancelFunc 被调用),此 context(子结点) 的 done channel 也会被关闭。

注意传给cancel()方法的参数,前者是 true,也就是说取消的时候,需要将自己从父结点里删除。第二个参数则是一个固定的取消错误类型:

var Canceled = errors.New("context canceled")

还要注意到一点,调用子结点 cancel 方法的时候,传入的第一个参数 removeFromParent 是 false。

removeFromParent 什么时候会传 true,什么时候传 false 呢?

先看一下当 removeFromParent 为 true 时,会将当前 context 从父结点中删除操作。

// removeChild removes a context from its parent.
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()
}

其中delete(p.children, child)就是完成从父结点 map 中删除自己。

什么时候会传 true 呢?答案是调用 WithCancel() 方法的时候,也就是新创建一个用于取消的 context 结点时,返回的 cancelFunc 函数会传入 true。这样做的结果是:当调用返回的 cancelFunc 时,会将这个 context 从它的父结点里“除名”,因为父结点可能有很多子结点,我自己取消了,需要清理自己,从父亲结点删除自己。

在自己的cancel()方法中,我所有的子结点都会因为c.children = nil完成断绝操作,自然就没有必要在所有的子结点的cancel() 方法中一一和我断绝关系,没必要一个个做。

在这里插入图片描述
在这里插入图片描述

如上左图,代表一棵 Context 树。当调用左图中标红 Context 的 cancel 方法后,该 Context 从它的父 Context 中去除掉了:实线箭头变成了虚线。且虚线圈框出来的 Context 都被取消了,圈内的 context 间的父子关系都荡然无存了。

在生成 cancelCtx 的函数WithCancel()有一个操作需要注意一下,便是propagateCancel(parent, &c)

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

该函数的作用就是将生成的当前 cancelCtx 挂靠到“可取消”的父 Context,这样便形成了上面描述的 Context 树,当父 Context 被取消时,能够将取消操作传递至子 Context。

这里着重解释下为什么会有 else 描述的情况发生。else 是指当前结点 Context 没有向上找到可以取消的父结点,那么就要再启动一个协程监控父结点或者子结点的取消动作。

这里就有疑问了,既然没找到可以取消的父结点,那case <-parent.Done()这个 case 就永远不会发生,所以可以忽略这个 case;而case <-child.Done()这个 case 又啥事不干。那这个 else 不就多余了吗?

其实不然,我们来看parentCancelCtx()的代码:

// parentCancelCtx returns the underlying *cancelCtx for parent.
// It does this by looking up parent.Value(&cancelCtxKey) to find
// the innermost enclosing *cancelCtx and then checking whether
// parent.Done() matches that *cancelCtx. (If not, the *cancelCtx
// has been wrapped in a custom implementation providing a
// different done channel, in which case we should not bypass it.)
func parentCancelCtx(parent Context) (*cancelCtx, bool) {
	done := parent.Done()
	if done == closedchan || done == nil {
		return nil, false
	}
	p, ok := parent.Value(&cancelCtxKey).(*cancelCtx)
	if !ok {
		return nil, false
	}
	pdone, _ := p.done.Load().(chan struct{})
	if pdone != done {
		return nil, false
	}
	return p, true
}

如果 parent 携带的 value 并不是一个 *cancelCtx,那么就会判断为不可取消。这种情况一般发生在一个 struct 匿名嵌套了 Context,就识别不出来了,因为parent.Value(&cancelCtxKey)返回的是*struct,而不是*cancelCtx

3.4.3 timerCtx

timerCtx 是一个可以被取消的计时器上下文,基于 cancelCtx,只是多了一个 time.Timer 和一个 deadline。Timer 会在 deadline 到来时,自动取消 Context。

// A timerCtx carries a timer and a deadline. It embeds a cancelCtx to
// implement Done and Err. It implements cancel by stopping its timer then
// delegating to cancelCtx.cancel.
type timerCtx struct {
	cancelCtx
	timer *time.Timer // Under cancelCtx.mu.

	deadline time.Time
}

timerCtx 首先是一个 cancelCtx,所以它能取消。看下其cancel()方法:

func (c *timerCtx) cancel(removeFromParent bool, err error) {
	c.cancelCtx.cancel(false, err)
	if removeFromParent {
		// Remove this timerCtx from its parent cancelCtx's children.
		removeChild(c.cancelCtx.Context, c)
	}
	c.mu.Lock()
	if c.timer != nil {
		c.timer.Stop()
		c.timer = nil
	}
	c.mu.Unlock()
}

同样地,timerCtx 也是一个未导出类型,其对应的创建函数是WithTimeout()

// WithTimeout returns WithDeadline(parent, time.Now().Add(timeout)).
//
// Canceling this context releases resources associated with it, so code should
// call cancel as soon as the operations running in this Context complete:
//
// 	func slowOperationWithTimeout(ctx context.Context) (Result, error) {
// 		ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
// 		defer cancel()  // releases resources if slowOperation completes before timeout elapses
// 		return slowOperation(ctx)
// 	}
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
	return WithDeadline(parent, time.Now().Add(timeout))
}

该函数直接调用了WithDeadline(),传入的 deadline 是当前时间加上 timeout 的时间,也就是从现在开始再经过 timeout 时间就算超时。也就是说,WithDeadline()需要用的是绝对时间。重点来看下:

// WithDeadline returns a copy of the parent context with the deadline adjusted
// to be no later than d. If the parent's deadline is already earlier than d,
// WithDeadline(parent, d) is semantically equivalent to parent. The returned
// context's Done channel is closed when the deadline expires, 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 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) }
}

也就是说仍然要把子节点挂靠到父结点,一旦父结点取消了,会把取消信号向下传递到子结点,子结点随之取消。

有一个特殊情况是,如果要创建的这个子结点的 deadline 比父结点要晚,也就是说如果父结点是时间到自动取消,那么一定会取消这个子结点,导致子结点的 deadline 根本不起作用,因为子结点在 deadline 到来之前就已经被父结点取消了。

这个函数最核心的一句是:

c.timer = time.AfterFunc(d, func() {
    c.cancel(true, DeadlineExceeded)
})

c.timer 会在 d 时间间隔后,自动调用 cancel 函数,并且传入的错误就是超时错误DeadlineExceeded

// DeadlineExceeded is the error returned by Context.Err when the context's
// deadline passes.
var DeadlineExceeded error = deadlineExceededError{}

type deadlineExceededError struct{}

func (deadlineExceededError) Error() string   { return "context deadline exceeded" }
func (deadlineExceededError) Timeout() bool   { return true }
func (deadlineExceededError) Temporary() bool { return true }

3.4.4 valueCtx

valueCtx 是一个只用于传值的 Context,其携带一个键值对,其他的功能则委托给内嵌的 Context。

// A valueCtx carries a key-value pair. It implements Value for that key and
// delegates all other calls to the embedded Context.
type valueCtx struct {
	Context
	key, val interface{}
}

看下其实现的两个方法。

func (c *valueCtx) String() string {
	return contextName(c.Context) + ".WithValue(type " +
		reflectlite.TypeOf(c.key).String() +
		", val " + stringify(c.val) + ")"
}

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

由于它直接将 Context 作为匿名字段,因此仅管它只实现了 2 个方法,其他方法继承自父 Context。但它仍然是一个 Context,这是 Go 语言的一个特点。

同样地,valueCtx 也是一个未导出类型,其对应的创建函数是WithValue()

// WithValue returns a copy of parent in which the value associated with key is
// val.
//
// Use context Values only for request-scoped data that transits processes and
// APIs, not for passing optional parameters to functions.
//
// The provided key must be comparable and should not be of type
// string or any other built-in type to avoid collisions between
// packages using context. Users of WithValue should define their own
// types for keys. To avoid allocating when assigning to an
// interface{}, context keys often have concrete type
// struct{}. Alternatively, exported context key variables' static
// type should be a pointer or interface.
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}
}

对 key 的要求是可比较,因为之后需要通过 key 取出 context 中的值,可比较是必须的。

通过层层传递 context,最终形成这样一棵树:

和链表有点像,只是它的方向相反。Context 指向它的父结点,链表则指向下一个结点。通过WithValue()函数,可以创建层层的 valueCtx,存储 goroutine 间可以共享的变量。

取值的过程,实际上是一个递归查找的过程。再次看一下其Value()方法。

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

因为查找方向是往上走的,所以,父结点没法获取子结点存储的值,子结点却可以获取父结点的值。

WithValue 创建 Context 结点的过程实际上就是创建链表节点的过程。两个结点的 key 值是可以相等的,但它们是两个不同的 Context 结点。查找的时候,会向上查找到最后一个挂载的 Context 结点,也就是离得比较近的一个父结点 Context。所以,整体上而言,用 WithValue 构造的其实是一个低效率的链表。

如果你接手过项目,肯定经历过这样的窘境:在一个处理过程中,有若干子函数、子协程。各种不同的地方会向 context 里塞入各种不同的 k-v 对,最后在某个地方使用。

你根本就不知道什么时候什么地方传了什么值?这些值会不会被“覆盖”(底层是两个不同的 Context 节点,查找的时候,只会返回一个结果)?你肯定会崩溃的。

而这也是 Context 最受争议的地方,很多人建议尽量不要通过 Context 传值。

4.Context 的用法

4.1 使用建议

一般情况下,我们使用Background()获取一个空的 Context 作为根节点,有了根结点 Context,便可以根据不同的业务场景选择使用如下四个函数创建对应类型的子 Context。

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context

官方 context 包说明文档中已经给出了 context 的使用建议:

1.Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx.

2.Do not pass a nil Context, even if a function permits it. Pass context.TODO if you are unsure about which Context to use.

3.Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.

4.The same Context may be passed to functions running in different goroutines; Contexts are safe for simultaneous use by multiple goroutines.

对应的中文释义为: 1.不要将 Context 塞到结构体里;直接将 Context 类型作为函数的第一参数,且命名为 ctx。

2.不要向函数传入一个 nil Context,如果你实在不知道传哪个 Context 请传 context.TODO。

3.不要把本应该作为函数参数的数据放到 Context 中传给函数,Context 只存储请求范围内在不同进程和 API 间共享的数据(如登录信息 Cookie)。

4.同一个 context 可能会被传递到多个 goroutine,别担心,context 是并发安全的。

4.2 传递共享的数据

对于 Web 服务端开发,往往希望将一个请求处理的整个过程串起来,这就非常依赖于 Thread Local(对于 Go 可理解为单个协程所独有) 的变量,而在 Go 语言中并没有这个概念,因此需要在函数调用的时候传递 Context。

package main

import (
    "context"
    "fmt"
)

func main() {
    ctx := context.Background()
    process(ctx)

    ctx = context.WithValue(ctx, "traceID", "foo")
    process(ctx)
}

func process(ctx context.Context) {
    traceId, ok := ctx.Value("traceID").(string)
    if ok {
        fmt.Printf("process over. trace_id=%s\n", traceId)
    } else {
        fmt.Printf("process over. no trace_id\n")
    }
}

运行输出:

process over. no trace_id
process over. trace_id=foo

当然,现实场景中可能是从一个 HTTP 请求中获取到 Request-ID。所以,下面这个样例可能更适合:

const requestIDKey int = 0

func WithRequestID(next http.Handler) http.Handler {
    return http.HandlerFunc(
        func(rw http.ResponseWriter, req *http.Request) {
            // 从 header 中提取 request-id
            reqID := req.Header.Get("X-Request-ID")
            // 创建 valueCtx。使用自定义的类型,不容易冲突
            ctx := context.WithValue(
                req.Context(), requestIDKey, reqID)

            // 创建新的请求
            req = req.WithContext(ctx)

            // 调用 HTTP 处理函数
            next.ServeHTTP(rw, req)
        }
    )
}

// 获取 request-id
func GetRequestID(ctx context.Context) string {
    ctx.Value(requestIDKey).(string)
}

func Handle(rw http.ResponseWriter, req *http.Request) {
    // 拿到 reqId,后面可以记录日志等等
    reqID := GetRequestID(req.Context())
    ...
}

func main() {
    handler := WithRequestID(http.HandlerFunc(Handle))
    http.ListenAndServe("/", handler)
}

4.3 取消 goroutine

Context 的作用是为了在一组 goroutine 间传递上下文信息,其重便包括取消信号。取消信号可用于通知相关的 goroutine 终止执行,避免无效操作。

我们先来设想一个场景:打开外卖的订单页,地图上显示外卖小哥的位置,而且是每秒更新 1 次。app 端向后台发起 websocket 连接(现实中可能是轮询)请求后,后台启动一个协程,每隔 1 秒计算 1 次小哥的位置,并发送给端。如果用户退出此页面,则后台需要“取消”此过程,退出 goroutine,系统回收资源。

后端可能的实现如下:

func Perform() {
    for {
        calculatePos()
        sendResult()
        time.Sleep(time.Second)
    }
}

如果需要实现“取消”功能,并且在不了解 Context 功能的前提下,可能会这样做:给函数增加一个指针型的 bool 变量,在 for 语句的开始处判断 bool 变量是发由 true 变为 false,如果改变,则退出循环。

上面给出的简单做法,可以实现想要的效果。没有问题,但是并不优雅。并且一旦通知的信息多了之后,函数入参就会很臃肿复杂。优雅的做法,自然就要用到 Context。

func Perform(ctx context.Context) {
    for {
        calculatePos()
        sendResult()

        select {
        case <-ctx.Done():
            // 被取消,直接返回
            return
        case <-time.After(time.Second):
            // block 1 秒钟 
        }
    }
}

主流程可能是这样的:

ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
go Perform(ctx)

// ……
// app 端返回页面,调用cancel 函数
cancel()

注意一个细节,WithTimeut 函数返回的 Context 和 cancelFun 是分开的。Context 本身并没有取消函数,这样做的原因是取消函数只能由外层函数调用,防止子结点 Context 调用取消函数,从而严格控制信息的流向:由父结点 Context 流向子结点 Context。

4.4 防止 goroutine 泄漏

前面那个例子里,goroutine 还是会自己执行完,最后返回,只不过会多浪费一些系统资源。这里给出一个如果不用 context 取消,goroutine 就会泄漏的例子(源自Using contexts to avoid leaking goroutines)。

// gen 是一个整数生成器且会泄漏 goroutine
func gen() <-chan int {
    ch := make(chan int)
    go func() {
        var n int
        for {
            ch <- n
            n++
            time.Sleep(time.Second)
        }
    }()
    return ch
}

上面的生成器会启动一个具有无限循环的 goroutine,调用者会从信道这些值,直到 n 等于 5。

for n := range gen() {
    fmt.Println(n)
    if n == 5 {
        break
    }
}

当 n == 5 的时候,直接 break 掉。那么 gen 函数的协程就会无限循环,永远不会停下来。发生了 goroutine 泄漏。

我们可以使用 Context 主动通知 gen 函数的协程停止执行,阻止泄漏。

func gen(ctx context.Context) <-chan int {
	ch := make(chan int)
	go func() {
		var n int
		for {
			select {
			case <-ctx.Done():
				return // 当 ctx 结束时避免 goroutine 泄漏
			case ch <- n:
				n++
			}
		}
	}()
	return ch
}

现在,调用方可以在完成后向生成器发送信号。调用 cancel 函数后,内部 goroutine 将返回。

ctx, cancel := context.WithCancel(context.Background())
defer cancel() // make sure all paths cancel the context to avoid context leak

for n := range gen(ctx) {
    fmt.Println(n)
    if n == 5 {
        cancel()
        break
    }
}

// ...

5.Context 的不足

Context 的作用很明显,当我们在开发后台服务时,能帮助我们完成对一组相关 goroutine 的控制并传递共享数据。注意是后台服务,而不是所有的场景都需要使用 Context。

Go 官方建议我们把 Context 作为函数的第一个参数,甚至连名字都准备好了。这造成一个后果:因为我们想控制所有的协程的取消动作,所以需要在几乎所有的函数里加上一个 Context 参数。很快,我们的代码里,context 将像病毒一样扩散的到处都是。

另外,像 WithCancel、WithDeadline、WithTimeout、WithValue 这些创建函数,实际上是创建了一个个的链表结点而已。我们知道,对链表的操作,通常都是 O(n) 复杂度的,效率不高。

Context 解决的核心问题是 cancelation,即便它不完美,但它却简洁地解决了这个问题。

6.小结

Go 1.7 引入 context 包,目的是为了解决一组相关 goroutine 的取消问题,即并发控制。当然还可以用于传递一些共享的数据。这种场景往往在开发后台 server 时会遇到,所以 context 有其适用的场景,而非所有场景。

使用上,先创建一个根结点的 Context,之后根据 context 包提供的四个函数创建相应功能的子结点 context。由于它是并发安全的,所以可以放心地传递。

context 并不完美,有固定的使用场景,切勿滥用。

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2022-02-16,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 文章目录
  • 1.什么是 Context
  • 2.为什么要有 Context
  • 3.context 包源码一览
    • 3.1 Context
      • 3.2 CancelFunc
        • 3.3 canceler
          • 3.4 Context 的实现
            • 3.4.1 emptyCtx
            • 3.4.2 cancelCtx
            • 3.4.3 timerCtx
            • 3.4.4 valueCtx
        • 4.Context 的用法
          • 4.1 使用建议
            • 4.2 传递共享的数据
              • 4.3 取消 goroutine
                • 4.4 防止 goroutine 泄漏
                • 5.Context 的不足
                • 6.小结
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档