在Go语言圈子中流行着一句话:
Never start a goroutine without knowing how it will stop。
翻译:如果你不知道协程如何退出,就不要使用它。
在创建协程时,我们可能还会再创建一些别的子协程,那么这些协程的退出就成了问题。在Go1.7之后,Go官方引入了Context来实现协程的退出。不仅如此,Context还提供了跨协程、甚至是跨服务的退出管理。
Context本身的含义是上下文,我们可以理解为它内部携带了超时信息、退出信号,以及其他一些上下文相关的值(例如携带本次请求中上下游的唯一标识trace_id)。由于Context携带了上下文信息,父子协程之间就可以”联动“
了。
在Context标准库中重要的结构 context.Context其实是一个接口,它提供了Deadline、Done、Err、Value这4种方法:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
Deadline方法用于返回Context的过期时间。Deadline第一个返回值表示Context的过期时间,第二个返回值表示是否设置了过期时间,如果多次调用Deadline方法会返回相同的值。
Done是使用最频繁的方法,它会返回一个通道。一般的做法是调用者在select中监听该通道的信号,如果该通道关闭则表示服务超时或异常,需要执行后续退出逻辑。多次调用Done方法会返回相同的通道。
通道关闭后,Err方法回返回退出的原因。
Value方法返回指定Key对应的value,这是Context携带的值。Key必须是可比较的,一般用法Key是一个全局变量,通过context.WithValue将key存储到Context中,并通过Context.Value方法退出。
Context是一个接口,这意味着需要有对应的具体实现。用户可以自己实现Context接口,并严格遵守Context接口。
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
}
因此,要具体使用Context,需要派生出新的Context。我们使用的最多的还是Go标准库中的实现。
前三个函数都用于派生出有退出功能的Context。
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context
eg:
下面的代码中childCtx是preCtx的子Context,其设置的超时时间为300ms。但是preCtx的超时时间为100ms,因此父Context退出后,子Context会立即退出,实际的等待时间只有100ms。
func main() {
ctx := context.Background()
before := time.Now()
preCtx, _ := context.WithTimeout(ctx, 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())
}
}
这是输出如下,父Context与子Context退出的时间差接近100ms:
pre during: 104
child during: 104
当我们把preCtx的超时时间修改为500ms时:
preCtx ,_:= context.WithTimeout(ctx,500*time.Millisecond)
从新的输出中可以看出,子协程的退出不会影响父协程的退出。
child during: 304
pre during: 500
Context在很大程度上利用了通道的一个特性:通道在close时,会通知所有监听它的协程。
每个派生出的子Context都会创建一个新的退出通道,这样,只要组织好Context之间的关系,就可以实现继承链上退出信号的传递。如图所示的三个协程中,关闭通道A会连带关闭调用链上的通道B,通道B会关闭通道C。
要使用context的退出功能,需要调用WithCancel或WithTimeout,派生出一个新的结构Context。WithCancel底层对应的结构为cancelCtx,WithTimeout底层对应的结构为timerCtx,timerCtx包装了cancelCtx,并存储了超时时间。
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
cause error // set to non-nil by the first cancel call
}
type timerCtx struct {
cancelCtx
timer *time.Timer // Under cancelCtx.mu.
deadline time.Time
}
cancelCtx第一个字段保留了父Context的信息。children字段则保存了当前Context派生的子Context的信息,每个Context都会有一个单独的done通道。
而WithDeadline函数会先判断父Context设置的超时时间是否比当前Context的超时时间短,如果是,那么子协程会随着父Context的退出而退出,没有必要再设置定时器。
当我们使用了标准库中默认的Context实现时,propagateCancel函数将子Context加入父协程的children哈希表中,并开启一个定时器。当定时器到期时,会调用cancel方法关闭通道,级联关闭当前Context派生的子Context,并取消与父Context的绑定关系。这种特性就产生了调用链上连锁的退出反应。