什么是 context
通常 server 接收到一个请求之后会创建一个 goroutine 来处理任务。处理请求时, 会创建额外的 goroutine 来访问下游服务「rpc、mysql、redis...」。针对同一个请求产生的不同 goroutines, 他们之间会传递变量「token, timeout...」。当请求超时或取消, 对应的 goroutines 都应该及时的被取消, 释放系统资源。
在 1.7 版本中, go 将 context 包加入到官方库。通过 context 实现不同 goroutines 之前传递变量, 取消信号, 超时信息。
类型定义
Context 本身是一个接口, 提供了 Done()、Err()、Deadline()、Value() 方法。Context 并没有提供 Cancel 方法, 这是因为 Context 提供了 Done 方法, 接收一个 channel。Context 不应该同时提供 Cancel 和 Done 方法, 可以通过 context.WithCancel 来取消一个新的 context。
Done():
返回一个 channel, 这个 channel 会在 ctx 完成或取消后关闭, 重复调用返回的是同一个 channel Err():
返回 ctx 结束的具体原因, 如果 ctx 没有被关闭, 返回 nil。
Deadline():
返回 ctx 结束的具体时间, 也就是任务什么时候被截止; 如果 ctx 没有设置截止时间, 返回 ok: false
Value():
返回 ctx 中指定 key 的值。
// A Context carries a deadline, cancellation signal, and request-scoped values// across API boundaries. Its methods are safe for simultaneous use by multiple// goroutines.
type Context interface {
// Done returns a channel that is closed when this Context is canceled// or times out.
Done() <-chan struct{}
// Err indicates why this context was canceled, after the Done channel// is closed.
Err() error
// Deadline returns the time when this Context will be canceled, if any.
Deadline() (deadline time.Time, ok bool)
// Value returns the value associated with key or nil if none.
Value(key interface{}) interface{}
}
context 派生
context 包提供了方法派生不同的 ctx. 可以派生的逻辑看作一个树。当跟节点取消之后, 派生出来的所有 ctx 都会取消。 BackGround() 返回一个空 ctx, 通常作为 ctx 的根节点, 对于不同请求生成的 ctx 都应该基于它来派生。 WitchCancel 和 WithTimeout 可以派生出 ctx, 它可以比父 ctx 要更早取消。在 http 请求中, 当 request 超时, 可以及时取消派生出的 ctx。
// Background returns an empty Context. It is never canceled, has no deadline,// and has no values. Background is typically used in main, init, and tests,// and as the top-level Context for incoming requests.
func Background() Context
// A CancelFunc cancels a Context.
type CancelFunc func()
// WithCancel returns a copy of parent whose Done channel is closed as soon as// parent.Done is closed or cancel is called.
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
// WithTimeout returns a copy of parent whose Done channel is closed as soon as// parent.Done is closed, cancel is called, or timeout elapses. The new// Context's Deadline is the sooner of now+timeout and the parent's deadline, if// any. If the timer is still running, the cancel function releases its// resources.
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
// WithValue returns a copy of parent whose Value method returns val for key.
func WithValue(parent Context, key interface{}, val interface{}) Context
怎么使用 context
go 的官方也给我推荐了使用 context 的一些基本原则:
-
不应该在 struct 里存储 ctx, 而应该以传参的形式使用 ctx, 将 ctx 作为第一个参数「why:大概是说通过穿参的形式可以做到方法粒度的控制 ctx 里的数据, 超时, 取消信号; 将 ctx 放到 struct 上, 容易造成混淆, 无法确定 ctx 应该作用到哪些方法上; 当然我们在基础库中也看到有 ctx 存到 sturct 中, 这更多的是兼容性考虑」
-
不要传递一个 nil 的 ctx, 即使函数允许这么做。如果我们不知道传递什么, 可以传递一个 context.TODO()
-
ctx 传递的 value 应该是请求粒度的参数「token、rip」, value 应该是不被修改的, 不应该传递业务逻辑处理的参数 ctx 可以在不同的 goroutines 之间传递, 是并发安全的
服务端处理示例
在服务端开发场景中, 框架通常会在 ctx 存储一些数据「logid, trace_id...」, 便于将同一个 request 的调用链路串起来; 同时需要设置超时时长, 当 request 处理超时, 及时取消对应的子任务, 释放资源
1.创建 context
针对每个请求均需要创建一个 context, 这里创建一个指定超时 5s 和携带 reqID 的 ctx。提供函数, 支持从 ctx 里获取 reqID。针对 ctx 的 key, 应该是不可导出的, 避免 key 冲突。
const DefaultTimeOut = 5 * time.Second
const KReqID = "reqID"
func genReqCtx(ctx context.Context, reqNum int) (context.Context, context.CancelFunc) {
// 设置 5s 的超时时间
reqCtx, cancel := context.WithTimeout(ctx, DefaultTimeOut)
// 添加 reqID
reqCtx = context.WithValue(reqCtx, KReqID, reqNum)
return reqCtx, cancel
}
func getReqID(ctx context.Context) (int, error) {
reqId, ok := ctx.Value(KReqID).(int)
if ok {
return reqId, nil
}
return 0, errors.New("invalid reqID")
}
- 编写业务代码
在 bizHandler 里编写处理逻辑, 通过 select 来判断 ctx 是否结束或子任务执行完毕, 及时退出任务。
func bizHandler(ctx context.Context) error {
reqID, err := getReqID(ctx)
if err != nil {
return err
}
fmt.Printf("start processing reqNum: %d \n", reqID)
errChan := make(chan error)
// 启动 goroutine 进行计算
go func() {
errChan <- calculate(ctx, reqID)
}()
select {
// 超时退出
case <-ctx.Done():
// 需要等待 errChan 关闭, 不然会泄露 goroutine
<-errChan
return ctx.Err()
// rpc 出错退出
case e := <-errChan:
return e
}
}
- 编写子任务代码
模拟子任务处理逻辑
func calculate(ctx context.Context, reqId int) error {
fmt.Printf("start calculate reqID: %d \n", reqId)
for {
select {
// 超时或取消, 退出计算
case <-ctx.Done():
fmt.Printf("cancel calculate reqID: %d, err: %v \n", reqId, ctx.Err())
return ctx.Err()
// 计算
default:
fmt.Printf("calculate reqID: %d \n", reqId)
time.Sleep(time.Second * 2)
}
}
}
- 函数入口
编写 mian 函数, 模拟不同的 req 请求
func main() {
// 创建 root ctx
root := context.Background()
for reqNum := 0; reqNum < 2; reqNum++ {
// 创建 goroutine, 处理 req 请求
go func(rn int) {
reqCtx, cancel := genReqCtx(root, rn)
defer cancel()
if err := bizHandler(reqCtx); err != nil {
fmt.Printf("process reqNum: %d meet err:%v \n", rn, err)
} else {
fmt.Printf("process reqNum: %d success\n", rn)
}
}(reqNum)
}
time.Sleep(time.Second * 10)
runtime.GC()
// 是否有 goroutine 泄露
fmt.Println(runtime.NumGoroutine())
time.Sleep(time.Hour)
}
总结
ctx 核心要解决的问题其实还是不同 goroutines 之间传递信号, 及时告诉子 goroutines 取消相关操作。但是, 按照官方的推荐, 我们每个函数第一个参数都为 ctx, 这会导致 ctx 像病毒一样不断扩散; 同时, ctx 里面可以携带数据, 在开发过程中, 我们很难知道 ctx 里都携带了哪些数据, 很难做相应处理。
从开发者视角来看, 我们可以使用 ctx 很好的设置 timeout, 通知 goroutines 及时取消。应尽量避免在 ctx 里传递非通用的数据。