Go并发模式:Context

在Go服务器中,每个传入的请求都在它自己的goroutine中处理。请求处理程序通常启动附加的goroutine来访问后端,例如数据库和RPC服务。在处理请求的一组goroutine通常需要访问请求特定的值,例如终端用户的身份,授权令牌和请求的截止时间。当请求被取消或超时时,所有正在处理该请求的goroutine应该快速退出,以便系统可以回收它们正在使用的任何资源。

在Google中,我们开发了一个Context包,使得在处理请求的所有涉及的goroutine之间跨API边界传递请求范围的值,取消信号和截止时间变得容易。该包作为context公开可用。本文介绍了如何使用该包并提供一个完整的工作示例。

Context

Context包的核心是Context类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Context 在 API 边界上携带截止时间、取消信号和请求范围的值。它的方法可以同时被多个 goroutine 安全使用。
type Context interface {
// Done 返回一个通道,该通道在此上下文被取消或超时时关闭。
Done() <-chan struct{}

// Err 表示在 Done 通道关闭后,此上下文被取消的原因。
Err() error

// Deadline 返回此上下文将被取消的时间,如果有的话。
Deadline() (deadline time.Time, ok bool)

// Value 返回与键关联的值,如果没有则返回 nil。
Value(key interface{}) interface{}
}

Done方法返回一个通道(channel),作为代表Context运行的函数的取消信号:当通道关闭时,函数应该放弃它们的工作并返回。

Err方法返回指示Context被取消的原因的错误。Pipelines and Cancellation文章详细讨论了Done通道惯用法。

Context没有Cancel方法,原因与Done通道是只接收的相同:接收取消信号的函数通常不是发送信号的函数。特别是,当父操作为子操作启动goroutine时,这些子操作不应该能够取消父操作。相反,WithCancel函数(下文将描述)提供了一种取消新Context值的方式。

Context对于多个goroutine的同时使用是安全的。代码可以将单个Context传递给任意数量的goroutine并取消该Context以通知它们所有。

Deadline方法允许函数确定它们是否应该开始工作;如果剩下的时间太少,则可能不值得。代码也可以使用截止时间为I/O操作设置超时。

Value允许Context携带请求范围的数据。该数据必须对多个goroutine的同时使用是安全的。

派生Context

context包提供了从现有Context派生新Context值的函数。这些值形成一棵树:当Context被取消时,由它派生的所有Context也被取消。

Background是任何Context树的根;它从不被取消:

1
2
3
4
// Background 返回一个空的 Context。它从不被取消,没有截止时间,
// 也没有值。 Background 通常在 main、init 和测试中使用,
// 作为传入请求的顶层上下文。
func Background() Context

WithCancel 和 WithTimeout 返回派生上下文值,可以更快地取消父上下文。与传入请求关联的上下文通常在请求处理程序返回时取消。在使用多个副本时,WithCancel 也有助于取消冗余请求。WithTimeout 有助于为后端服务器上的请求设置截止时间:

1
2
3
4
5
6
7
8
9
10
11
12
13
// WithCancel 返回 parent 的副本,其 Done 通道在
// parent.Done 被关闭或调用 cancel 时立即关闭。
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

// CancelFunc 取消上下文。
type CancelFunc func()

// WithTimeout 返回 parent 的副本,其 Done 通道在
// parent.Done 被关闭、调用 cancel 或超时后立即关闭。新的
// Context 的 Deadline 是 now+timeout 和 parent 的截止时间中更早的那个,如果
// 有的话。如果计时器仍在运行,取消函数会释放其
// 资源。
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithValue 提供了一种将请求范围的值与上下文关联的方法:

1
2
// WithValue 返回 parent 的副本,其 Value 方法对于 key 返回 val。
func WithValue(parent Context, key interface{}, val interface{}) Context

最好的了解上下文(context)包的方法是通过一个实例来进行详细讲解。

示例:谷歌网页搜索

我们的示例是一个 HTTP 服务器,处理像 /search?q=golang&timeout=1s 这样的 URL,通过将查询“golang”转发到 Google Web 搜索 API 并呈现结果来处理请求。timeout 参数告诉服务器在该持续时间过后取消请求。

代码分为三个包:

  • server 提供主函数和处理 /search 的处理程序。
  • userip 提供从请求中提取用户 IP 地址并将其与上下文关联的函数。
  • google 提供 Search 函数以向 Google 发送查询。

示例程序

服务通过提供 golang 的前几个 Google 搜索结果来处理请求,例如 /search?q=golang。它注册 handleSearch 来处理 /search 终端节点。处理程序创建一个名为 ctx 的初始上下文,并安排在处理程序返回时取消它。如果请求包括 timeout URL 参数,则在超时时间到期时自动取消上下文:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func handleSearch(w http.ResponseWriter, req *http.Request) {
// ctx 是此处理程序的上下文。调用 cancel 会关闭
// ctx.Done 通道,该通道是由此处理程序启动的请求的取消信号。
var (
ctx context.Context
cancel context.CancelFunc
)
timeout, err := time.ParseDuration(req.FormValue("timeout"))
if err == nil {
// 请求有超时时间,因此创建一个上下文,当超时时间到期时会自动取消。
ctx, cancel = context.WithTimeout(context.Background(), timeout)
} else {
ctx, cancel = context.WithCancel(context.Background())
}
defer cancel() // 只要 handleSearch 返回,就取消 ctx。

处理程序从请求中提取查询,并通过调用 userip 包提取客户端的 IP 地址。客户端的 IP 地址需要用于后端请求,因此 handleSearch 将其附加到 ctx 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 检查搜索查询。
query := req.FormValue("q")
if query == "" {
http.Error(w, "no query", http.StatusBadRequest)
return
}

// 为使用其他包中的代码而在 ctx 中存储用户 IP。
userIP, err := userip.FromRequest(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx = userip.NewContext(ctx, userIP)

处理程序使用 ctx 和查询调用 google.Search:

1
2
3
4
// 运行 Google 搜索并打印结果。
start := time.Now()
results, err := google.Search(ctx, query)
elapsed := time.Since(start)

如果搜索成功,则处理程序呈现结果:

1
2
3
4
5
6
7
8
9
10
11
if err := resultsTemplate.Execute(w, struct {
Results google.Results
Timeout, Elapsed time.Duration
}{
Results: results,
Timeout: timeout,
Elapsed: elapsed,
}); err != nil {
log.Print(err)
return
}

包 userip

userip 包提供了从请求中提取用户 IP 地址并将其与上下文关联的函数。上下文提供了一个键值映射,其中键和值都是 interface{} 类型。键类型必须支持相等性,值必须可以同时安全地用于多个 goroutine。像 userip 这样的包隐藏了此映射的细节,并提供了对特定上下文值的强类型访问。

为避免键冲突,userip 定义了一个未导出类型的键,并使用该类型的值作为上下文键:

1
2
3
4
5
// 为了避免与其他包中定义的上下文键冲突,键类型是未导出的。
type key int

// userIPKey 是用户 IP 地址的上下文键。它的值为零是任意的。如果该包定义了其他上下文键,它们将有不同的整数值。
const userIPKey key = 0

FromRequest 从 http.Request 中提取 userIP 值:

1
2
3
4
5
func FromRequest(req *http.Request) (net.IP, error) {
ip, _, err := net.SplitHostPort(req.RemoteAddr)
if err != nil {
return nil, fmt.Errorf("userip: %q is not IP:port", req.RemoteAddr)
}

NewContext 返回一个携带所提供的 userIP 值的新上下文:

1
2
3
func NewContext(ctx context.Context, userIP net.IP) context.Context {
return context.WithValue(ctx, userIPKey, userIP)
}

FromContext 从上下文中提取 userIP:

1
2
3
4
5
6
func FromContext(ctx context.Context) (net.IP, bool) {
// 如果 ctx 没有该键的值,则 ctx.Value 返回 nil;
// 对 net.IP 类型进行断言会使 ok=false。
userIP, ok := ctx.Value(userIPKey).(net.IP)
return userIP, ok
}

包 google

google.Search 函数向 Google Web 搜索 API 发出 HTTP 请求并解析 JSON 编码的结果。它接受一个上下文参数 ctx,并在请求进行中 ctx.Done 关闭时立即返回。

Google Web 搜索 API 请求包括搜索查询和用户 IP 作为查询参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
func Search(ctx context.Context, query string) (Results, error) {
// 准备 Google Search API 请求。
req, err := http.NewRequest("GET", "https://ajax.googleapis.com/ajax/services/search/web?v=1.0", nil)
if err != nil {
return nil, err
}
q := req.URL.Query()
q.Set("q", query)

// 如果 ctx 携带用户 IP 地址,则将其转发到服务器。
// Google APIs 使用用户 IP 来区分服务器发起的请求和最终用户发起的请求。
if userIP, ok := userip.FromContext(ctx); ok {
q.Set("userip", userIP.String())
}
req.URL.RawQuery = q.Encode()

Search 使用一个帮助函数 httpDo 来发出 HTTP 请求,并在处理请求或响应时关闭 ctx.Done。Search 传递一个闭包给 httpDo 处理 HTTP 响应:

1
2
var results Results
err = httpDo(ctx, req,

搜索使用一个帮助函数 httpDo 发出 HTTP 请求,并在请求或响应被处理时关闭 ctx.Done 来取消请求。搜索通过将一个闭包传递给 httpDo 来处理 HTTP 响应:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
var results Results
err = httpDo(ctx, req, func(resp *http.Response, err error) error {
if err != nil {
return err
}
defer resp.Body.Close()

// 解析 JSON 搜索结果。
// https://developers.google.com/web-search/docs/#fonje
var data struct {
ResponseData struct {
Results []struct {
TitleNoFormatting string
URL string
}
}
}
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return err
}
for _, res := range data.ResponseData.Results {
results = append(results, Result{Title: res.TitleNoFormatting, URL: res.URL})
}
return nil
})
// httpDo 等待我们提供的闭包返回,因此在这里读取 results 是安全的。
return results, err

httpDo 函数在一个新的 goroutine 中运行 HTTP 请求并处理其响应。如果在 goroutine 退出之前关闭了 ctx.Done,则它将取消请求:

1
2
3
4
5
6
7
8
9
10
11
12
13
func httpDo(ctx context.Context, req *http.Request, f func(*http.Response, error) error) error {
// 在 goroutine 中运行 HTTP 请求并将响应传递给 f
c := make(chan error, 1)
req = req.WithContext(ctx)
go func() { c <- f(http.DefaultClient.Do(req)) }()
select {
case <-ctx.Done():
<-c // 等待 f 返回。
return ctx.Err()
case err := <-c:
return err
}
}

为 Context 适配代码

许多服务器框架提供了用于携带请求范围值的包和类型。我们可以定义新的 Context 接口实现来连接使用现有框架的代码和期望 Context 参数的代码。

例如,Gorilla 的 github.com/gorilla/context 包允许处理程序通过从 HTTP 请求到键值对的映射来关联数据与传入请求。在 gorilla.go 中,我们提供了一个 Context 实现,其 Value 方法返回与 Gorilla 包中特定 HTTP 请求相关联的值。

其他包提供了类似 Context 的取消支持。例如,Tomb 提供了一个 Kill 方法,通过关闭 Dying 通道来信号取消。Tomb 还提供了一些方法来等待这些 goroutine 退出,类似于 sync.WaitGroup。在 tomb.go 中,我们提供了一个 Context 实现,当其父 Context 被取消或提供的 Tomb 被杀死时,它就会被取消。

结论 在 Google,我们要求 Go 程序员将 Context 参数作为进出请求调用路径上每个函数的第一个参数。这使得许多不同团队开发的 Go 代码可以很好地互操作。它提供了简单的超时和取消控制,并确保关键值如安全凭据适当地在 Go 程序之间传输。

原文

https://go.dev/blog/context