在 Go 服务中,每个传入的请求都在其自己的 goroutine 中处理。请求处理程序通常启动额外的 goroutine 来访问其他后端,如数据库和 RPC 服务。处理请求的 goroutine 通常需要访问特定于请求(request-specific context)的值,例如最终用户的身份、授权令牌和请求的截止日期(deadline)。
当一个请求被取消或超时时,处理该请求的所有 goroutine 都应该快速退出(fail fast),这样系统就可以回收它们正在使用的任何资源。
Go 1.7 引入一个 context 包,它使得跨 API 边界的请求范围元数据、取消信号和截止日期很容易传递给处理请求所涉及的所有 goroutine(显示传递)。
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
集成 Context 到 API 中
一般来说,将 Context 集成到 API 中,需要将 API 的首参数作为 Context:
func (d *Dialer) DialContext(ctx context.Context, network, address string) (Conn, error)
在此函数执行 Dial 操作的过程中,可以通过 ctx 来超时控制和取消
Context.WithValue
func WithValue(parent Context, key, val any) Context
WithValue 返回父级的副本,其中与 key 关联的值为 val。
仅将 Value 用于传输进程和 API 的请求范围数据,而 不是用于将可选参数传递给函数。
提供的键必须是可比较的,并且 不应是字符串类型或任何其他内置类型,以避免使用上下文的包之间发生 冲突。
WithValue 的用户应该 定义自己的键类型。
为了避免在将值分配给一个 interface{} 类型时进行额外的内存分配(allocation),上下文键(context keys)常常采用具体类型 struct{}
type contextKey struct{} // 使用 struct{} 类型的键
func main() {
ctx := context.Background()
value := "some value"
ctx = context.WithValue(ctx, contextKey{}, value)
retrievedValue := ctx.Value(contextKey{})
fmt.Println(retrievedValue)
}
在 Go 中,interface{} 是一个空接口,它可以容纳任何类型的值。当你将一个具体类型的值分配给 interface{} 类型时,Go 通常需要进行类型转换(type assertion),这可能会导致额外的内存分配和运行时开销。为了避免这些开销,上下文(context)包提供了一种 context.Context 类型,它使用了一种特殊的技巧,其中键(keys)通常采用具体类型 struct{} 而不是接口类型。
context.WithValue 方法允许上下文携带请求范围的数据。这些数据必须是安全的,以便多个 goroutine 同时使用。
这里的数据,更多是面向请求的元数据,不应该作为函数的可选参数来使用(比如 context 里面挂了一个 sql.Tx 对象,传递到 data 层使用),因为 元数据相对函数参数更加是隐含的 ,面向请求的。而参数是更加显示的。
原理
context.WithValue 内部基于 valueCtx 实现:
func WithValue(parent Context, key, val any) Context {
if parent == nil {
panic("cannot create context from nil parent")
}
if key == nil {
panic("nil key")
}
if !reflectlite.TypeOf(key).Comparable() {
panic("key is not comparable")
}
return &valueCtx{parent, key, val}
}
valueCtx 的相关定义如下:
// A valueCtx carries a key-value pair. It implements Value for that key and
// delegates all other calls to the embedded Context.
type valueCtx struct {
Context
key, val any
}
// 递归的查找,从父节点寻找匹配的 key,直到 root context(Backgrond 和 TODO Value 函数会返回
func (c *valueCtx) Value(key any) any {
if c.key == key {
return c.val
}
return value(c.Context, key)
}
小技巧:如果要传递的参数很多,可以传递一个 map,避免产生的查询链太长,影响效率
安全
同一个 context 对象可以传递给在不同 goroutine 中运行的函数;上下文对于多个 goroutine 同时使用是安全的。
对于值类型最容易犯错的地方,在于 context 的 value 应该是 不可修改(immutable) 的,每次重新赋值应该是新的 context,即:
// pkg context
func WithValue(parent Context, key, val any) Context
这种思想就是 写时拷贝 思想
修改一个 Context Value 时,不能直接修改,先 deep-copy 一份,再生成一个新的 context 传给下游
这样各自读取的副本都是自己的数据,写行为追加的数据,在 ctx2 中也能完整读取到,同时也不会污染 ctx1 中的数据。
Cancellation
When a Context is canceled, all Contexts derived from it are also canceled
当一个 context 被取消时,从它派生的所有 context 也将被取消。
WithCancel(ctx) 参数 ctx 认为是 parent ctx,在内部会进行一个传播关系链的关联。
Done() 返回 一个 chan,当我们取消某个 parent context, 实际上上会递归层层 cancel 掉自己的 child context 的 done chan,从而让整个调用链中所有监听 cancel 的 goroutine 退出。
package main
import (
"context"
"fmt"
)
func main() {
gen := func(ctx context.Context) <-chan int {
dst := make(chan int)
go func() {
n := 0
defer close(dst)
for {
select {
// 防止 goroutine 泄漏
case <-ctx.Done():
fmt.Printf("ctx.Err(): %v\n", ctx.Err().Error())
return
case dst <- n:
n++
}
}
}()
return dst
}
ctx, cancel := context.WithCancel(context.Background())
dst := gen(ctx)
defer cancel()
for v := range dst {
fmt.Printf("v: %v\n", v)
if v == 5 {
break
}
}
}
TimeOut
All blocking/long operations should be cancelable
这点可以通过使用 context.WithDeadline 实现
例如,将上面的代码加上超时时间
func main() {
gen := func(ctx context.Context) <-chan int {
dst := make(chan int)
go func() {
n := 0
defer close(dst)
for {
select {
// 防止 goroutine 泄漏
case <-ctx.Done():
fmt.Printf("ctx.Err(): %v\n", ctx.Err().Error())
return
case dst <- n:
n++
}
}
}()
return dst
}
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(time.Millisecond))
dst := gen(ctx)
defer cancel()
for v := range dst {
fmt.Printf("v: %v\n", v)
}
}
使用原则
- Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it.
- Replace a Context using WithCancel, WithDeadline, WithTimeout, or WithValue
- Do not pass a nil Context , even if a function permits it. Pass a TODO context if you are unsure about which Context to use.
TODO returns a non-nil, empty [Context]. Code should use context.TODO when it’s unclear which Context to use or it is not yet available (because the surrounding function has not yet been extended to accept a Context parameter).
- Use context values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions
- All blocking/long operations should be cancelable.
- Context.Value should inform, not control.(即不要使用 context.Value 来控制程序的流程)
- Try not to use context.Value.
示例
下面实现一个简易的 MySQL 客户端,使用 context 来实现查询的超时和取消操作。
先来看看目录结构:
Sky_Lee@SkyLeeMacBook-Pro context_demo % tree
.
├── context_demo
├── go.mod
├── main.go
└── sql
└── sql.go
2 directories, 4 files
main.go 的内容如下:
package main
import (
"context"
"context_demo/sql"
"log"
"net/http"
"time"
)
var db *sql.DB
func init() {
var err error
// 这里是一个演示,并不会真正连接数据库
db, err = sql.Open()
if err != nil {
log.Fatal(err)
}
}
func main() {
defer db.Close()
http.HandleFunc("/user", handlerGetUser)
log.Fatal(http.ListenAndServe(":1145", nil))
}
// controller 层
func handlerGetUser(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) // 控制 queryUsers 的超时时间为 3s
defer cancel()
user, err := queryUsers(ctx)
if err != nil {
http.Error(w, "Failed to query users", http.StatusInternalServerError)
log.Printf("Failed to query users, reason: %v\n", err.Error())
return
}
w.Write([]byte(user + "\n"))
}
// logic 层
func queryUsers(ctx context.Context) (string, error) {
return SelectUser(ctx, db)
}
// dao 层
func SelectUser(ctx context.Context, db *sql.DB) (string, error) {
query := "SELECT username FROM user LIMIT 1" // 示例查询语句
// 使用context控制超时时间
res, err := db.QueryWithContext(ctx, query)
if err != nil {
return "", err
}
return res, nil
}
为了方便,main.go 实现了 controller、logic、dao 层的内容
main.go 控制了单次查询的超时时间为 3s,并且通过 context.WithTimeout 控制了查询操作的超时时间
再来看看 sql.go 文件:
package sql
import (
"context"
"errors"
"time"
)
var (
ErrTimeout = errors.New("sql: timeout")
)
type DB struct {
}
// 使用 context 控制超时时间
// 返回 string 作为查询结果,仅仅是一个示例
func (db *DB) QueryWithContext(ctx context.Context, query string) (res string, err error) {
// 先检查 context 是否超时
select {
case <-ctx.Done():
return "", ctx.Err()
default:
}
ch := make(chan struct{})
go func() {
defer close(ch)
res, err = db.execute(query)
}()
// 异步检查查询执行完毕,或者 context 超时
select {
// context 超时或者其它错误
case <-ctx.Done():
// 根据 query 取消查询
// 但取消的是查询本身,不是之前的 goroutine
// 因此存在 goroutine 泄漏的风险
// 理想情况下,我们希望 cancelQuery 这类函数可以在数据库驱动层面上,使得 execute 返回,以避免 goroutine 泄漏
db.cancelQuery(query)
switch ctx.Err() {
case context.DeadlineExceeded:
err = ErrTimeout
default:
err = ctx.Err()
}
res = ""
// 查询执行完毕
case <-ch:
}
return
}
// 这里就涉及到了与 DB 交互了
// 返回类型简单设置为 string
func (db *DB) execute(query string) (string, error) {
// 模拟 RTT 为 10s
time.Sleep(time.Second * 10)
return "done", nil
}
// 取消查询
func (db *DB) cancelQuery(query string) {
// 一般来说,query 应包含一个 connectionID
// 可以根据 connectionID 来关闭与 DB 的连接
// 从而取消查询
// do nothing
// just a example
}
func Open() (*DB, error) {
// do nothing
// just a example
return new(DB), nil
}
func (db *DB) Close() {
// do nothing
// just a example
}
sql.go 最重要的方法就是 QueryWithContext 了
QueryWithContext 方法的实现,需要考虑 context 超时的情况:
- 先检查 context 是否超时
- 异步执行查询
- 异步检查查询执行完毕,或者 context 超时
- 根据 context 超时或者其它错误,返回错误
- 查询执行完毕,返回结果
来看看执行结果吧:
可以看到,即使 execute 模拟的查询时间为 10s,总查询时间也控制在了 3s
注意:
这个 demo 是存在 goroutine 泄漏的问题的:如果 execute 函数一直不返回,那么创建的 goroutine 永远不会被销毁
实际的实现,避免 goroutine 泄漏,依赖 cancelQuery 方法,在数据库驱动层面让 execute 返回,从而避免 goroutine 泄漏
Context 到底解决了什么?
Context should go away for Go 2 这篇文章的作者提到了:Go2.0 版本应该去掉 context,理由如下:
Context is like a virus
每个函数的第一个参数都是 context,即使你不使用它(传一个 context.ToDo),显得代码十分丑陋,可读性差
Context is mostly an inefficient linked list
并且,context.Value,内部实现实际上就是一个链表,查询的时间复杂度为 O(n),如果调用链很长,那么效率也是很低的
那么,Context 到底解决了什么问题?为什么要使用 Context?
Context 提供了一种优雅的 cancellation,仅管它并不完美,但它确实很简洁地解决了问题。