Golang并发控制 - Context
为什么需要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个函数处理退出:
WithCancel(parent Context) (Context, CancelFunc)
:子context会在两种情况下退出:(1)主动调用cancel函数;(2)父context退出。
WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
:子context退出有3种时机:(1)主动调用cancel函数,(2)父context退出,(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方法
- 关闭自身的通道
- 遍历当前children哈希表,调用当前所有子context的退出函数,实现继承链上的连锁退出反应
- 从父context哈希表中移除该context,避免父context退出后,重复关闭子context通道产生错误
- 关闭通道之后,所有监听这个通道的子协程都将收到信号,子协程需要处理这个信号操作退出。
WithValue函数
用于传值,使用方式:
ctx1 := WithValue(parentCtx, key1, val1)
ctx2 := WithValue(ctx1, key2, val2)
ctx3 := WithValue(ctx2, key3, val3)
nextCall(ctx3, req)
// 必须以这种方式链式传递,查找时也是链式查找
这种设计也导致了Value方法的查找key的效率不是很高,是个O(n)的查找。