为什么需要context

  • 一句关于Go的名言:如果你不知道如何退出一个协程,那么就不要创建它

  • 在context之前,要管理协程退出需要借助通道的close机制,但是在不同的项目之间,在命名及处理方式上都会有所不同,如果有一套统一的规范,那么语义将更加清晰。例如都使用 <-ctx.Done()

  • 协程之间时常存在级联关系,退出需要具有传递性。为了优雅的管理协程的退出,特别时多个协程甚至是网络服务之间的退出,Go引入了context包

context接口

context.Context其实时一个接口,提供了以下4种方法

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}
  • Deadline():第一个返回值表示还有多久到期,第二个返回值表示是否到期
  • Done():返回一个通道,一般做法时监听该通道信号,如果收到信号表示通道已关闭,需要执行退出
  • Err():返回退出的原因
  • Value():返回指定key对应的value,该值的作用域在结束时终结,key必须是并发安全的。

context退出与传递

context包配套有3个函数处理退出:

  1. WithCancel(parent Context) (Context, CancelFunc)

    子context会在两种情况下退出:(1)主动调用cancel函数;(2)父context退出。

  2. WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

    子context退出有3种时机:(1)主动调用cancel函数,(2)父context退出,(3)超时退出

  3. WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

    和WithTimeout函数处理方法相似,不过其参数指定的是最后的到期时间

context退出的传播关系是:父context退出会导致所有子context退出,子context退出不会影响父context。如下代码所示:

func main() {
	before := time.Now()
	preCtx, _ := context.WithTimeout(context.TODO(), 100*time.Millisecond)
	go func() {
		childCtx, _ := context.WithTimeout(preCtx, 300*time.Millisecond)
		select {
		case <-childCtx.Done():
			after := time.Now()
			fmt.Println("child during:", after.Sub(before).Milliseconds())
		}
	}()
	
	select {
	case <-preCtx.Done():
		after := time.Now()
		fmt.Println("pre during:", after.Sub(before).Milliseconds())
	}
}
// 输出:
// child during 100
// pre during 100
// 也有可能只输出:pre during 100

当把preCtx的超时时间设置为500ms,总是输出:

// child during: 300
// pre during: 500

context 原理

context主要利用了通道在close时会通知所有监听它的协程这一特性来实现

如下关闭通道通知子协程:

func main() {
	ch := make(chan int)
	go func() {
		select {
		case v, ok := <-ch:
			fmt.Printf("f1通道关闭:v:%v,ok:%v\n", v, ok)
		}
	}()
	go func() {
		select {
		case <-ch:
			fmt.Println("f2通道关闭")
		}
	}()
	close(ch)
	time.Sleep(time.Second * 2)
}
// 输出:
// f2通道关闭
// f1通道关闭:v:0,ok:false

每一个派生出的子协程都会创建一个新的退出通道,组织好context之间的关系,即可实现继承链上退出的传递。

emptyCtx

  • context.Background函数和context.TODO函数是一样的,都返回emptyCtx

  • emptyCtx什么内容都没有,不可以被退出,也不能携带值,一般作为初始的根对象

cancelCtx

当调用context.WithCancel,会产生一个cancelCtx,并保留了父context信息

type cancelCtx struct {
	Context                        // 父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
	cause    error                 // set to non-nil by the first cancel call
}

children字段保存当前context之后派生的子context信息,每个新context都会有一个新的done通道,保证了子context退出不会影响父context

timerCtx

  • WithTimeout函数最终会调用WithDeadline函数

  • WithDeadline函数会先判断父context是否比当前设置的超时参数d先退出,如果是,子协程会随着父context的退出而退出,没必要设置定时器,因此直接调用WithCancel,返回一个cancelContext

  • 否则,将当前context加入父context,并开启一个定时器,返回一个timerCtx。定时器到期时会调用cancel方法关闭通道。

    timerCtx包含了cancelCtx和一个定时器:

    type timerCtx struct {
    	*cancelCtx
    	timer *time.Timer
    	deadline time.Time
    }

cancel方法

  1. 关闭自身的通道
  2. 遍历当前children哈希表,调用当前所有子context的退出函数,实现继承链上的连锁退出反应
  3. 从父context哈希表中移除该context,避免父context退出后,重复关闭子context通道产生错误
  4. 关闭通道之后,所有监听这个通道的子协程都将收到信号,子协程需要处理这个信号操作退出。

WithValue函数

用于传值,使用方式:

ctx1 := WithValue(parentCtx, key1, val1)
ctx2 := WithValue(ctx1, key2, val2)
ctx3 := WithValue(ctx2, key3, val3)
nextCall(ctx3, req)
// 必须以这种方式链式传递,查找时也是链式查找

这种设计也导致了Value方法的查找key的效率不是很高,是个O(n)的查找。