Context
在Go语言圈子中流行着一句话:
Never start a goroutine without knowing how it will stop。
翻译:如果你不知道协程如何退出,就不要使用它。
在创建协程时,我们可能还会再创建一些别的子协程,那么这些协程的退出就成了问题。在Go1.7之后,Go官方引入了Context来实现协程的退出。不仅如此,Context还提供了跨协程、甚至是跨服务的退出管理。
Context本身的含义是上下文,我们可以理解为它内部携带了超时信息、退出信号,以及其他一些上下文相关的值(例如携带本次请求中上下游的唯一标识trace_id)。由于Context携带了上下文信息,父子协程之间就可以”联动“
了。
Context标准库
在Context标准库中重要的结构 context.Context其实是一个接口,它提供了Deadline、Done、Err、Value这4种方法:
type Context interface {Deadline() (deadline time.Time, ok bool)Done() <-chan struct{}Err() errorValue(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
- WithCancel函数回返回一个子Context和cancel方法。子Context会在两种情况下触发退出:一种情况是调用者主动调用了返回的cancel方法;另一种情况是当参数中的父Context退出时,子Context将级联退出。
- WithTimeout函数指定超时时间。当超时发生后,子Context将退出。因此,子Context的退出有三种时机,一种是父Context退出;一种是超时退出;最后一种是主动调用cancel函数退出。
- WithDeadline和WithTimeout函数的处理方法相似,不过它们的参数指定的是最后到期的时间。
- WithValue函数会返回带key-value的子Context。
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底层原理
Context在很大程度上利用了通道的一个特性:通道在close时,会通知所有监听它的协程。
每个派生出的子Context都会创建一个新的退出通道,这样,只要组织好Context之间的关系,就可以实现继承链上退出信号的传递。如图所示的三个协程中,关闭通道A会连带关闭调用链上的通道B,通道B会关闭通道C。
要使用context的退出功能,需要调用WithCancel或WithTimeout,派生出一个新的结构Context。WithCancel底层对应的结构为cancelCtx,WithTimeout底层对应的结构为timerCtx,timerCtx包装了cancelCtx,并存储了超时时间。
type cancelCtx struct {Contextmu sync.Mutex // protects following fieldsdone atomic.Value // of chan struct{}, created lazily, closed by first cancel callchildren map[canceler]struct{} // set to nil by the first cancel callerr error // set to non-nil by the first cancel callcause error // set to non-nil by the first cancel call
}type timerCtx struct {cancelCtxtimer *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的绑定关系。这种特性就产生了调用链上连锁的退出反应。