[译]Go 并发 : Context

翻译自:Go Concurrency Patterns: Context
地址:https://blog.golang.org/context

原文发表于 2014 年。context 在 Go 1.7 中进入标准库,文中的 Google 网页搜索 API 已停用。下文保留原例来说明 Context 的用法,代码片段需结合链接中的完整源码阅读。

简介

在Go server端,每个请求都是通过新起goroutine来处理。请求处理程序通常会启动其他goroutine来访问后端,例如数据库和RPC服务。处理该请求的goroutine集合通常需要访问特定于请求的值,例如最终用户的身份,授权令牌和请求的截止时间(deadline)。当一个请求被取消或超时时,处理该请求的所有goroutine应该迅速退出,以便系统可以回收它们正在使用的资源。

在Google,我们开发了一个 context 包,可以轻松地跨API边界将请求范围(request-scoped)的值,取消信号和截止时间(deadlines)传递给处理请求的所有goroutine。该包以 context 之名公开发布。本文将介绍如何使用该包,并通过一个 HTTP 服务示例说明它的用法。

Context

context 包的核心是Context类型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Context跨API边界携带截止时间(deadline)、取消信号和请求范围(request-scoped)的值。Context的方法是协程安全的。
type Context interface {
// Done返回一个channel,该channel在Context被取消或超时时关闭。
Done() <-chan struct{}

// Err表示在Done channel关闭后取消此Context的原因。
Err() error

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

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

更详细的介绍,参见godoc:https://golang.org/pkg/context/

Done方法返回一个channel,作为代表Context运行的函数的取消信号:当channel关闭时,函数应该放弃它们的工作并返回。Err方法返回一个错误,指示Context被取消的原因。《Go Concurrency Patterns: Pipelines and Cancellation》一文进一步讨论了 Done channel 的惯用法。

Context 没有 Cancel 方法,原因与 Done channel 只能接收是一样的:接收取消信号的方法与发送信号方法往往不是同一个。特别是,当父操作(parent operation)为子操作(sub-operations)启动goroutines时,这些子操作应该不能取消父操作。相反,WithCancel函数(如下所述)提供了一种取消新的 Context 值的方法。

Context是协程安全的。代码中可以将单个Context传递给任意数量的goroutine,并在取消该Context时可以将信号传递给所有的goroutine。

截止时间(Deadline)方法允许函数判断是否应该开始工作;如果剩下的时间太少,可能就不值得了。代码也可以使用截止时间(Deadline)来设置I/O操作的超时。

值(Value)允许Context携带请求范围(request-scoped)的数据。 该数据必须是协程安全的,以便多个goroutine可以同时使用。

Derived contexts

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

Background是所有Context树的根;它永远不会被取消:

1
2
3
// Background返回一个空Context。它永远不会被取消,没有截止时间,也没有值。
// 后台通常用于main、init和测试,并作为传入请求的顶级 Context。
func Background() Context

WithCancel和WithTimeout返回派生的Context值,该值可以比父Context早被取消。请求处理程序返回时,通常会取消与传入请求关联的Context。 使用多个副本时,WithCancel对于取消冗余请求也很有用。WithTimeout对于设置对后端服务器的请求的截止时间很有用:

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

// CancelFunc取消Context。
type CancelFunc func()

// WithTimeout 返回父对象的副本,该副本的 Done channel 会在 parent.Done 关闭、调用 cancel 或超时时立即关闭。
// 调用 cancel 或超时时也会关闭 Done。
// 新的 Context 截止时间是now + timeout和父级截止时间(如果有)中的较早者。
// 如果计时器仍在运行,则 cancel 函数会释放其(计时器)资源。
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

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

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

最好的理解方式是通过一个完整的示例来看 context 包的用法。

示例:Google 网页搜索

我们的示例是一个 HTTP 服务器,处理形如 /search?q=golang&timeout=1s 的 URL:它把查询词 “golang” 转发给 Google 网页搜索 API,并渲染结果。timeout 参数告诉服务器在该时长过后取消请求。

代码分布在三个包中:

  • server 提供 main 函数和 /search 端点的处理函数。
  • userip 提供从请求中提取用户 IP 地址、并将其与 Context 关联的函数。
  • google 提供向 Google 发送查询的 Search 函数。

server 程序

server 程序处理 /search?q=golang 这样的请求,返回 golang 的前几条 Google 搜索结果。它把 handleSearch 注册为 /search 端点的处理函数。处理函数创建一个初始的 Context(称为 ctx),并安排在处理函数返回时将其取消。如果请求包含 timeout URL 参数,则超时发生时 Context 会自动取消:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
func handleSearch(w http.ResponseWriter, req *http.Request) {
// ctx 是此处理函数的 Context。调用 cancel 会关闭
// ctx.Done channel,即本处理函数启动的请求的取消信号。
var (
ctx context.Context
cancel context.CancelFunc
)
timeout, err := time.ParseDuration(req.FormValue("timeout"))
if err == nil {
// 请求带了 timeout,创建一个超时后自动取消的 context。
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
}

// 将用户 IP 存入 ctx,供其他包中的代码使用。
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
5
6
7
8
// 执行 Google 搜索并打印结果。
start := time.Now()
results, err := google.Search(ctx, query)
elapsed := time.Since(start)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

如果搜索成功,处理函数渲染结果:

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 地址、并将其与 Context 关联的函数。Context 提供键值映射,键和值都是 interface{} 类型:键类型必须支持相等比较,值必须对多个 goroutine 并发安全。像 userip 这样的包隐藏了这个映射的细节,提供对特定 Context 值的强类型访问。为避免键冲突,userip 定义了一个未导出的 key 类型,并以该类型的值作为 context 键:

1
2
3
4
5
6
// key 类型未导出,以避免与其他包中定义的 context 键冲突。
type key int

// userIPKey 是用户 IP 地址的 context 键。其取值 0 是任意的。
// 如果本包还定义了其他 context 键,它们会有不同的整数值。
const userIPKey key = 0

FromRequest 从 http.Request 中提取 userIP 值:

1
2
3
4
5
6
7
8
9
10
11
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)
}
userIP := net.ParseIP(ip)
if userIP == nil {
return nil, fmt.Errorf("userip: %q is not IP:port", req.RemoteAddr)
}
return userIP, nil
}

NewContext 返回携带指定 userIP 值的新 Context:

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

FromContext 从 Context 中提取 userIP:

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

google 包

google.Search 函数向 Google 网页搜索 API 发起 HTTP 请求,并解析 JSON 编码的结果。它接受一个 Context 参数 ctx,如果请求在途时 ctx.Done 被关闭,则立即返回。Google 网页搜索 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 API 使用用户 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
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 channel 来发出取消信号;Tomb 还提供了等待这些 goroutine 退出的方法,类似于 sync.WaitGroup。在 tomb.go 中,我们提供了一个 Context 实现:当其父 Context 被取消、或给定的 Tomb 被 kill 时,它即被取消。

结论

在 Google,我们要求 Go 程序员在进出请求之间调用路径上的每个函数中,都把 Context 作为第一个参数传递。这使得由许多不同团队开发的 Go 代码能够良好地协同工作。它提供了对超时和取消的简单控制,并确保安全凭证等关键值能够正确地穿过 Go 程序。

想要基于 Context 构建的服务框架,应当提供 Context 的实现,在自己的包与期望 Context 参数的包之间架起桥梁;它们的客户端库随后即可接受调用方传入的 Context。通过为请求范围的数据和取消建立一个共同的接口,Context 让包开发者更容易共享构建可扩展服务的代码。

延伸阅读


📚 系列文章 · Go 并发与运行时