跳过正文
  1. 文章/

context:Go 后端请求生命周期的控制信号

高华阳·1KURA
作者
高华阳·1KURA
记录我的 Golang源码理解、工程实践、项目复盘和长期学习笔记。

一个 http GET 请求进入服务端,调用了 redis,调用了 mysql,开启了几个协程,客户端如果取消了请求,这些操作如何停下来?如果没有 context,操作正常进行,但是这些数据对客户端是无意义的。

Context 的作用是控制一次请求的生命周期,实现协程、请求的超时控制和级联取消。

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}

Context 的本质是一个接口,需要实现四个方法 Deadline()Done()Err()Value()

Deadline 方法:返回 context 的截止时间 deadline,ok 判断是否有截止时间。

Done 方法:这是 context 实现协程间通信的重要方法,返回一个只读的 struct{} channel。空 struct 因为不占用内存,非常适合作为信号传递。

Err 方法:返回 context 被 cancel 的原因。

Value 方法:只保存链路的元信息,如 TraceID。

Background() 返回的 emptyCtx 作为地基,是所有 ctx 的根节点。下面开始我们把 ctx 作为实现了 context 接口的实例,如 emptyCtxcancelCtxtimerCtx

cancelCtx, cancel := context.WithCancel(parentCtx)
defer cancel()

项目中我们常用 context.WithCancel(ctx) 获取一个 cancelCtx,它以参数中 ctx 作为父 ctx。

type cancelCtx struct {
    Context

    mu       sync.Mutex
    done     atomic.Value
    children map[canceler]struct{}
    err      atomic.Value
    cause    error
}

只需要了解到除了 emptyCtx 外,其他 ctx 自带 Mutex,是并发安全的。cancelCtx 还嵌套了一个 ctx 作为父 ctx,children map 是实现级联取消的。

cancelCtx 的 cancel 方法通过 close Done 方法返回的 <-chan struct{},所有正在监听被 cancel 的 ctx 的 Done 方法的协程全部会读到 chan 的零值,并停止阻塞并被唤醒。

这里有个非常巧妙的设计,cancel 方法是 close channel 而不是发送数据给 channel。为什么要这样设计?因为发送数据只有一个接收者,而 close channel 后所有监听 channel 的协程全部都会被唤醒,类似于广播的效果。

cancelCtx 的 children map,在父 ctx 调用了 cancel 方法后,父 ctx 会遍历 children map,调用所有子 ctx 的 cancel 方法,这样就实现了 ctx 的级联取消功能。我们可以联想成一个 ctx 树,一个 cancelCtx 被 cancel,它下面的所有子 ctx 全部被 cancel。

Go context 父子树与 Done channel 级联取消流程

cancelCtx 需要 defer cancel(),把自己从父 ctx 的 children map 里移除。不 cancel,父 ctx 一直持有这个子 ctx 的引用,子 ctx 用完了也无法被 GC 回收。

timerCtx, cancel := context.WithTimeout(parentCtx, time)
timerCtx, cancel := context.WithDeadline(parentCtx, time)

项目中常用这两种方法获取一个 timerCtx,区别只是 WithTimeout 方法的参数是时间段,WithDeadline 方法参数是时间戳。

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
    return WithDeadline(parent, time.Now().Add(timeout))
}

WithTimeout 只是重新调用了 WithDeadline,重新算了一遍截止时间。

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

    deadline time.Time
}

timerCtx 只是在 cancelCtx 的基础上添加了一个 timer,所以除了 cancelCtx 的级联取消,还可以实现 timer 的超时控制。

timerCtx 在创建时,会调用 time.AfterFunc(),和 timer 类似,也是 runtime 底层创建一个定时事件,只不过这次是定时执行调用了 timerCtx 的 cancel 方法。timerCtx 调用 cancel 后,Done() 返回的 channel 被 close,所有监听 ctx.Done() 的协程被唤醒,由于 timerCtx 还嵌套了 cancelCtx,所有子 ctx 也被 cancel。

timerCtx 通过 AfterFunc 到期触发 cancel 并关闭 Done channel

和 timer 一样,AfterFunc 创建的底层定时事件也需要被 stop,所以也需要 defer cancel()

回归到问题,给 http 请求携带一个 ctx 作为父 ctx,执行业务逻辑时的调 redis、mysql 和开启协程等每个子请求都会携带一个子 ctx 参数,上游 ctx 一旦 cancel,所有子 ctx 全部级联取消。

回头看 context 的整个设计,用 channel 传递信号、用树形结构实现级联取消,其实都是“通过通信共享内存”这个 Go 哲学的具体体现。