Go context包使用

前言

  • 本文基于go1.18
  • 本文使用context代表context包,Context代表context.Context接口并泛指环境对象

为什么需要context

场景举例

context是在go1.7中引入的,为什么需要context呢,首先考虑下面一个简单但常见的业务场景:服务端接收到并处理客户端的一个请求,处理过程又是通过多个下游服务组成的,并且为了加速处理过程提高响应速度,为每个下游服务单独开启一个协程并行处理

存在问题

上述过程听起来非常自然,但是难免会遇到某个下游服务在业务高峰期中响应变慢,而同事又有新请求需要使用这个下游服务,从而导致等待这个下游服务的协程越来越多,内存占用增高,最终导致服务不可用

解决思路

为了解决这样的情况,自然而然就想要对下游服务设置timeout(这里先不考虑更复杂的限流处理),当超过这个timeout后下游服务就超时返回,即关闭处理这个下游服务的协程。而在go中无法在协程外部直接关闭协程,而是用chan+select的方式,通过协程通信来通知协程关闭。但是上面所说的timeout超时返回或上游由于其他原因主动要求下游结束的需求,都需要除了chan+select额外的代码来实现,当服务的调用更加复杂的时候,单纯基于chan+select的方式就会非常麻烦。于是聪明的Gopher想到了用context包来封装这些常用的需求,是的你没有听错,只是封装,context的实现依然是基于chan+select,但其设计又十分巧妙,以至于可以被加入标准库中,并应对各种各样的业务场景。

context解决方案

context这个名字也取得很合理,“上下文”/“环境”,其基本思想与步骤如下(用字母代表不同的协程/服务):

  1. A需要调用B,并且需要控制B的生命周期(比如需要中途杀死B)
  2. 那么A只需要创建一个环境,让B置身运行在这个环境当中
  3. 当A想要中途结束B的运行,只需要结束上述环境的运行即可

上面是基本的使用思路,手动用chan+select肯定也能轻松做到,但context还提供了以下额外功能:

  • 给环境设置timeout,使得超时自动结束环境的运行
  • 环境的继承,即父环境结束后,子环境自动结束
  • 环境可以携带额外的数据

第一个功能很好理解

第二个功能首先需要知道,Context一定是通过父Context来创建的,不存在没有父ContextContext(除了context包自带的全局环境,但该环境是为了方便我们创建新环境而存在的)。然后考虑下面一个业务场景:下游服务调用顺序为A->B->C,其中A需要控制B的生命周期,B需要控制C的生命周期,因此除了A需要创建Context传入B中,B也需要通过传入的环境作为父Context创建一个新环境并传入C中,作为C的环境。此时若A结束了传入B的环境,此时传入C的环境也会被结束。设想一下如果单纯用chan来实现这个需求,传入B和传入C的两个chan并没有任何联系,B收到结束的请求后,需要手动通知C也结束,而使用Context的继承性就能自动结束子环境的运行!

第三个功能比较少用到,而且一般也不建议使用。所谓携带额外的数据指的是,创建Context时传入一个键值对,类型为any,以后可以通过Context.Value(key)来取出该值。

context包的使用

接口和方法定义一览

理解了context的思想后,context包就很容易上手。首先来看下Context接口的定义

type Context interface {
  // 返回超时时间点,如果没有设置超时时间点,ok=false
	Deadline() (deadline time.Time, ok bool)
  // 返回一个chan,若Context已经被取消则<-chan语句不阻塞。Done()返回nil表示该Context不会主动被取消
  // 但可能会由于继承关系,随着父Context取消而被动取消
  Done() <-chan struct{}
  // 只有两种返回值:Canceled表示被取消,DeadlineExceeded表示超时
	Err() error
  // 根据键返回Context携带的数据
	Value(key any) any
}

如何创建Context呢,有四个构造函数可以用,另外由于Context必须从父Context创建而来,因此可以看到所有的构造函数第一个参数都需要传入父Context

// 创建一个可以被显式取消的Context
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
// 创建一个可以被显式取消的,并且有超时取消的Context,其传入一个超时时间点
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
// 与WithDeadline一样,只不过后者传入时间点,后者传入timeout
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
// 创建一个不可以被显式取消的,并且携带数据的Context
func WithValue(parent Context, key, val any) Context
  • 可以显式取消的意思是返回一个CancelFunc类型的变量,其实就是一个没有参数没有返回值的函数,定义如下

    type CancelFunc func()
    

    需要取消的时候调用cancel()就可以了

  • 超时取消也不难理解,到达指定时间点时,相当于Context内部自动调用cancel(),不同的在于Err()返回DeadlineExceeded

关于父Context:假设我们创建Context的时候没有可以依赖的父Context,那么创建的时候应该传入哪个Context呢?答案是context.Background(),如果把程序所有创建的Context想象成一个个节点,所有节点就可以组成一颗树,那么这个context.Background()返回的就是树的根节点:

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
}

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

func Background() Context {
	return background
}

func TODO() Context {
	return todo
}

可以看到emptyCtx类型实际上是一个实现了Context接口的int,并且实现的方法里面什么都没有做,即不可取消、没有携带值、没有超时,其有两个实例backgroundtodo,前者只是用来创建新Context的,作为所有Context的根节点,后者是当你还不能明确用哪个Context作为父Context,那么就使用context.TODO(),先把程序运行起来,以后再修改。

使用例子

本小节最后来看下Context的简单使用吧~

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	go watch(ctx,"【监控1】")
	go watch(ctx,"【监控2】")
	go watch(ctx,"【监控3】")

	time.Sleep(10 * time.Second)
	fmt.Println("可以了,通知监控停止")
	cancel()
	//为了检测监控过是否停止,如果没有监控输出,就表示停止了
	time.Sleep(5 * time.Second)
}

func watch(ctx context.Context, name string) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println(name,"监控退出,停止了...")
			return
		default:
			fmt.Println(name,"goroutine监控中...")
			time.Sleep(2 * time.Second)
		}
	}
}

context包解析

context包的使用中简单介绍了Context接口、构造函数、background/todo。下面就来掀开Context接口几个标准实现的真面目

emptyCtx

就是如上所说的,emptyCtx类型实际上是一个实现了Context接口的int,其只有两个有专门用途的实例:backgroundtodo

cancelCtx

这个类型表示可主动取消的Context,定义如下:

type cancelCtx struct {
	Context                        // 父Context

	mu       sync.Mutex            // 用来保护以下字段的访问
  done     atomic.Value          // 作为Done()的返回值
	children map[canceler]struct{} // 子Context
  err      error                 // 作为Err()的返回值
}

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

创建流程如下为

  1. 创建cancelCtx对象并设置父节点
  2. 调用propagateCancel继承父节点的取消机制,换个说法就是把取消传递到child节点,也就是如果父节点已经取消或者将来要取消,该节点也会被取消,这个函数稍后讲解
  3. 返回创建好的对象与用于取消的CancelFunc函数,函数体调用的c.cancel稍后讲解

cancelCtx实现了Context接口和canceler接口,下面先来看看实现这两个接口里的几个方法

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

Value比较直观,cancelCtxKey是内部用于方便获取节点直到祖先节点路径上最近的那个可取消节点的,和之前所说的携带数据的特性实际上没什么关系,不是重点。如果key不是cancelCtxKey,那么将返回父节点的value(因为cancelCtx不携带数据,所以数据从父节点获取),其中value函数可以简单看下:

func value(c Context, key any) any {
	for {
		switch ctx := c.(type) {
		case *valueCtx:
			if key == ctx.key {
				return ctx.val
			}
			c = ctx.Context // 找到父节点
		case *cancelCtx:
			if key == &cancelCtxKey {
				return c
			}
			c = ctx.Context // 找到父节点
		case *timerCtx:
			if key == &cancelCtxKey {
				return &ctx.cancelCtx
			}
			c = ctx.Context // 找到父节点
		case *emptyCtx:
			return nil // background/todo
		default:
			return c.Value(key) // 自定义的Context结构
		}
	}
}

这里还是比较好理解的,c记录当前遍历到的节点,通过更新cc的父节点层层遍历,找到携带数据的那个祖先节点并返回数据,如果一路上都没有携带数据的节点,若根节点是background,函数最终返回nil

然后来看看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{})
}

cancelCtx.done的类型是atomic.Value,了解它的LoadStore都是并发安全的即可。Done()方法也很明了,正如之前所说的done使用懒汉式创建,先获取done的值,若不是nil则直接返回,否则加锁创建chan struct{}并赋值给done。在旧版本go的代码中done的类型是chan struct{},这样在每次调用Done()时都需要加锁检查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
}

但考虑到done是一次Store多次Load,因此新版本中,对done取值使用了相比加锁效率更高的atomic.Value.Load,了解一下即可

另外还有Err()方法比较简单,可以自己看源码

最后是canceler接口里的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
	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传入的两个参数,第一个removeFromParent表示被取消节点是否与其父亲断开,第二个参数是被取消的原因(通过error表示)

执行流程如下:

  1. if c.err!=nil判断节点是否已经被取消,如果被取消了,就直接返回
  2. c.err=err记录被取消的原因
  3. 关闭done,因为done是在Done()方法中懒汉式创建,所以done可能为空,那么这里无论如何done channel都是被关闭的状态,于是为了执行效率就不再创建新chan,而是用一个已经预先创建好的并关闭的chandone赋值,即closedchan
  4. 递归取消所有挂载的子节点,并将children置空与子节点断开
  5. 根据removeFromParent决定是否要把节点与父节点断开

有一个细节问题需要注意,传入的参数removeFromParent什么时候传false/true,首先看一下removeChild函数:

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)意思是把本节点从父节点的子节点列表中删除,这个操作不会影响其他的子节点。然后我们回到cancel方法递归取消的这一行:child.cancel(false, err)传入的是false,原因是节点已经通过c.children=nil与子节点断开(就如同执行流程第4点中的描述),所以子节点取消的时候不用再多此一举再把自身与父节点断开

但是再回到WithCancel构造函数中,CancelFunc中执行的是c.cancel(true, Canceled),这个时候为什么又要传true使得本节点主动与父节点断开呢?因为这是本节点要求主动取消的,并不会影响父节点的状态,更不会影响兄弟节点的状态,说明本节点要主动将其从父节点的引用列表中移除。因此可以总结出只有节点要求主动与父节点断开的情况,比如外界调用CancelFunc、超时取消,才传入truecontext包内还有其他几个地方也调用了c.cancel函数,想必就能举一反三地想清楚为什么有的地方传true有的地方是false

最后我们再回过头看WithCancel构造函数流程中的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:
	}

	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说明父节点不可以被取消,则返回
  2. 通过检查parent.Done()返回的chan是否被关闭来检查父节点是否已经取消,如果已经取消,将本节点也取消并返回,此时子节点还没挂载到父节点上,因此child.cancel传入false。否则继续执行下面的代码
  3. 通过parentCacnelCtx函数(稍后讲解)尝试将父节点转换为可取消的Context类型即cancelCtx,如果转换成功,则加锁并通过父节点的err成员检查是否已经取消(这里再次检查是否已经取消是因为上一次检查到这次检查的这段时间内可能会因为没有加锁而被取消),如果已经取消,则把本节点也取消即可,否则将本节点挂载到父节点的引用列表,以便父节点在将来取消的时候能够发现该子节点并将其取消
  4. 如果上一步没有转换成功,但也不能说明父节点在不会被取消,因此需要开启一个监听父节点取消的协程,从而将子节点取消。而第二个case是因为本节点可能比父节点先取消,所以也需要监听本节点是否被取消,防止上一个case持有child的引用而泄漏,就算不泄漏,再次把child取消也是多此一举

propagateCancel函数实现了“传递取消”这个十分重要的机制,上面已经把实现流程梳理了一遍,还有个细节问题:parentCancelCtx函数的实现

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
}

函数执行流程如下:

  1. 如果节点不可取消或者已关闭则返回失败,否则继续执行

  2. 通过Value(&cancelCtxKey).(*cancelCtx)将节点转换成cancelCtx派生类型,若转换失败则返回失败

  3. 最后一步可能是最困惑的地方:为什么要将parent.Done()返回的chanp.done.Load()返回的chan进行比较,并且不相等的话就返回失败呢?是因为context包的设计者考虑到的情况如下:cancelCtx被嵌入到用户Context里,并且重载了Done()方法!

    type MyContext struct {
      Context
    }
    func (c *MyContext) Done() <-chan struct{} {
      return otherDone // 返回了别的channel,而不是cancelCtx里的done channel
    }
    

    在这种情况下,Done()就不会返回cancelCtx中的done了,也就是这个用户定制的Context的是否取消不再依赖于done,而cancelCtx内部的cancel函数、children都和done有关,因此如果Done()!=done,我见识不多,于是先不考虑这个脑回路清奇的“用户”为什么这么做,总之childrencancel等私有成员都已经没有用了,因为他们都依赖于done。再看一下parentCancelCtx函数在哪几个地方被调用:removeChild函数和propagateCancel函数这两个地方,我这里直接再复制出关键代码来看看:

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

    可以看到通过parentCancelCtx函数获取到ctxCancel后,要使用他的私有成员比如children,这样就和上面说的对应起来了!done!=Done()children已经没有意义了,因此不需要挂载节点到MyContextchildren上,这样就很清晰为什么当done!=Done()parentCancelCtx返回转换失败的结果了!源码对于这个函数的注释也只是简单的说我们不能忽略这种情况而没有说明为什么:“If not, the *cancelCtx has been wrapped in a custom implementation providing a different done channel, in which case we should not bypass it.”

经过上面分析,可以知道context包对于协程的取消机制还是挺完善的。除非收益巨大,不然最好不要自定义Context,因为可以看到propagateCancel中对于非标准的Context实现类采取的是开一个goroutine来监听取消,本来业务代码己经 goroutine满天飞了,不加节制的使用只会增加系统负担

那么Context最重要的一个实现:cancelCtx,到这里已经讲解完毕了,下面两个Context实现类都非常简单

timerCtx

先看下timerCtx的定义:

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

	deadline time.Time
}

可以看到timerCtx是基于cancelCtx的,另外带了一个timerdeadline帮助实现超时机制

构造函数有两个:WithDeadlineWithTimeout,其中WithTimeout基于前者,就不放代码了,自己看看也很好理解,主要看一下WithDeadline

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

创建执行流程如下:

  1. 检查parentdeadline如果在节点的deadline之前,那么parent将会比节点更早超时取消,这时节点的deadline就没有意义了,因此相当于创建cancelCtx
  2. 调用propagateCancel传递父节点的取消机制
  3. 检查deadline是否在now之前,如果是的话直接对节点执行超时取消
  4. 检查节点如果没被取消的话,就调用time.AfterFunc设置定时任务,即时间到达deadline后取消该节点
  5. 返回结果

由于之前cancelCtx的铺垫,创建过程理解起来非常简单。看下它实现的几个方法,String()Deadline()方法几乎就是返回成员对象,就不说了。简单分析剩下的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()
}

首先调用了内嵌cancelCtx的取消方法,注意到把removeChild的操作单独拎了出来,是因为cancelCtx只是内嵌对象,真正被父节点引用的是timerCtx本身。最后通过timerCtx.timer将定时任务停止,因为节点已经取消了,所以同样用来的取消的定时任务就没有用了,需要停掉

回过头看一下timerCtx.timer是通过time.AfterFunc创建的time.Timer类型对象,用来控制定时任务的取消和重置

valueCtx

分析代码之前首先说明一点,除了框架层不要使用 WithValue来携带业务数据,因为键值对的类型是 any, 编译期无法确定,运行时assert有开销。如果真要携带也要用 thread-safe 的数据

type valueCtx struct {
	Context
	key, val any
}

看一下构造方法:

func WithValue(parent Context, key, val any) 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必须是可比较的,并且不要用stringintbuilt-in类型作为key以防止不同的包使用同一个Context时发生冲突

valueCtx实现的Value方法也非常简单,可以自行查阅源码

小结

这个小节分析了标准context包里的几个实现类:emptyCtxcancelCtxtimerCtx和不常用的valueCtx。写这篇文章越写到后面思路越发清晰:

  • emptyCtx用于background/todo用作全局和临时节点
  • cancelCtx主要实现了cancelDone这两个方法,实现的是取消机制
  • timerCtx主要实现了Deadline方法和内嵌cancelCtx,实现的是超时机制
  • valueCtx主要实现了Value方法,实现了携带数据的功能

而各个实现类没实现的方法,都通过委托或者说继承的方式,委托给了内嵌的Context对象,并且内嵌Context对象又扮演了父节点的角色,比如从timerCtx创建而来的cancelCtx,其Deadline方法返回的其实是其父亲的deadline。在这点上context的设计者完美结合了context的设计理念和golang的语法机制

参考链接

How to correctly use context.Context in Go 1.7

深度解密Go语言之context

Go语言实战笔记(二十)| Go Context

Go Context 最佳实践

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值