Go 并发模式:上下文
Sameer Ajmani
29 July 2014
介绍
在 Go 的服务器程序中,每一个请求都有一个 goroutine 进行处理。然而,处理程序经常创建额外的 goroutines 去请求后端的数据库或者 RPC 服务等。这些处理请求的 goroutine 通常需要访问这个请求相关的值,例如最终用户的身份,认证 token 以及请求的期限。 当一个请求取消或超时时,处理该请求的所有goroutine 应该迅速退出,以便系统可以回收他们正在使用的任何资源。
在 Google,我们开发了一个 context
package,可轻松跨过API边界,把request-scoped values,cancelation signal和deadline等传递给处理此请求的所有 goroutine。该软件包可作为context 公开获得。本文介绍了如何使用该程序包,并提供了一个完整的示例。
Context
context
package 的核心是 Context
类型:
// 一个 Context 可以跨过API边界携带deadline, cancelation signal, 和 request-scoped values。
// 它的方法可安全地被多个 goroutines 同时使用。
type Context interface {
// Done 返回一个 channel,当这个 Context 取消或者超时时,此 channel 也被关闭。
Done() <-chan struct{}
// 在 Done channel 关闭之后, Err 指出这个 context 取消的原因。
Err() error
// Deadline 返回这个 Context 取消的时间, 如果有的话。
Deadline() (deadline time.Time, ok bool)
// Value 返回与key关联的值,如果没有,返回 nil 。
Value(key interface{}) interface{}
}
(这里的描述是简要的; 详细请参考 godoc.)
方法 Done
返回一个充当取消信号的 channel 给代表 Context
上下文中运行的函数:当 channel 关闭,这些函数应该马上放弃工作并返回。方法 Err
返回一个错误,用于指出 Context
关闭的原因。 Pipelines and Cancelation 文章详细讨论了 Done
channel 惯用法。
Context
没有 Cancel
方法和 Done
仅仅是一个receive-only的 channel 的原因相同:接收cancelation signal的函数通常不是发送信号的函数。特别是,当父操作为子操作启动 goroutines 时,这些子操作不应该能够取消父操作。 相反,WithCancel
函数(下面讨论)提供了一个方法用来取消一个新的 Context
值。
Context
对于多个 goroutines 同时使用是安全的。代码能够传递单个 Context
到任何数量的 goroutines 中,并且可以把取消这个 Context
的信号发送给所有的goroutines。
Deadline
方法允许函数判断它们是否开始工作:如果剩余时间太少,那可能不值得。代码也可以使用 deadline 来设置 I/O 超时。
Value
允许一个 Context
来携带request-scoped 数据。这个数据对于多个 goroutines 同时使用必须是安全的。
派生的 contexts
context
package 提供了一些从现有 Context
值创建 派生 Context
值的函数。这些值行成一个树状结构:当一个 Context
被取消,所有从它派生的 Context
也都被取消。
Background
是所有 Context
树的根; 它从不取消:
// Background 返回一个空的Context. 它永远不会取消, 也没有deadline, 并且也没有值。
// ackground 经常用于 main, init, 和 tests里, 对于进来的请求来说是最顶层的 Context 。
func Background() Context
WithCancel
和 WithTimeout
返回派生的 Context
值,这些值在它们的 parent Context
之前取消. 这些Context
会关联一个进来的请求,它们通常在请求处理程序返回时取消。WithCancel
在使用多个副本取消冗余时很有用。WithTimeout
用于在请求后端服务器时设置deadline:
// WithCancel 返回一个 parent的副本,当 parent.Done 关闭或者cancel被调用时,它的 Done channel 也马上关闭。
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
// CancelFunc 取消一个 Context。
type CancelFunc func()
// WithTimeout 返回一个 parent的副本,当它的 parent.Done 关闭,cancel 被调用或者发生超时时,它的 Done channel 马上关闭。
// 返回新的 Context's deadline 是now+timeout 和它的 parent 的deadline的较早者。如果计时器仍在运行,则 cancel function 将释放其资源。
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
WithValue
提供了一个方法把 request-scoped values和一个 Context
相关联:
// WithValue 返回一个 parent的副本,它的 Value 方法返回关于 key 的值.
func WithValue(parent Context, key interface{}, val interface{}) Context
使用 context
package 最好的方式是看看下面的例子是如何使用的:
例子: Google 网页搜索
我们的例子是一个HTTP 服务器,用来处理类似/search?q=golang&timeout=1s
的URLs。它依靠转发 "golang" 的查询到Google Web Search API 并渲染返回的结果。timeout
参数告诉服务器一段时间后取消此请求。
代码分为3个package:
- server 提供了
main
函数和/search
处理程序. - userip 提供的函数从请求中提取用户的 IP 地址并和一个
Context
相关联. - google 提供了
Search
函数用来发送一个查询到 Google.
The server program
server 程序通过提供前几个 golang
的Google 搜索结果来处理类似 /search?q=golang
之类的请求。它注册handleSearch
来处理 /search
端点。这个处理程序创建一个称为 ctx
的 Context
。并安排在处理程序返回时将其取消。 如果请求中包含 timeout
URL参数,则在超时后会自动取消Context
:
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
package来提取客户端的 IP 地址。客户端的 IP 地址是后端请求的需要,所以 handleSearch
会附加到 ctx
:
// 检查搜索查询.
query := req.FormValue("q")
if query == "" {
http.Error(w, "no query", http.StatusBadRequest)
return
}
// 把用户 IP 存储在 ctx 中,给其他package代码使用。
userIP, err := userip.FromRequest(req)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
ctx = userip.NewContext(ctx, userIP)
这个处理程序使用 ctx
和 query
调用 google.Search
:
// 运行Google search 并打印结果。
start := time.Now()
results, err := google.Search(ctx, query)
elapsed := time.Since(start)
如果查询成功,处理程序渲染结果:
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
}
Package userip
userip package 提供从请求中提取用户 IP 地址并关联一个 Context
的函数。Context
提供了key-value映射,并且key 和 value 都是 interface{}
类型。key 类型必须支持比较,values 必须在多个 goroutines 中同时使用是安全的。userip
package 隐藏了映射的细节并提供了强类型(strongly-typed)来访问特殊的 Context
值。
为了避免 key 冲突, userip
定义了一个非导出类型 key
并且使用一个这个类型的一个值作为 context key:
// 这个 key 类型是非导出的,是为了避免和其他packages 定义的context key 冲突。
type key int
// userIPkey 作为 用户 IP 地址的 context key 。其值为0是任意的,如果这个package
// 定义了其他context keys, 它们将有不同的整型值。
const userIPKey key = 0
FromRequest
从 http.Request
中提取 userIP
:
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
返回一个新的 Context
用来携带一个 userIP
的值:
func NewContext(ctx context.Context, userIP net.IP) context.Context {
return context.WithValue(ctx, userIPKey, userIP)
}
FromContext
从 Context
提取userIP
:
func FromContext(ctx context.Context) (net.IP, bool) {
// ctx.Value 返回 nil 如果 ctx 没有 相关 key 的值。
// net.IP 类型断言 ok=false 返回 nil。
userIP, ok := ctx.Value(userIPKey).(net.IP)
return userIP, ok
}
Package google
google.Search 函数向Google Web Search API 发出 HTTP 请求并解析 JSON 编码的结果。它接受一个 Context
参数 ctx
, 如果在请求过程中,ctx.Done
关闭,它将立即返回。
Google Web Search API 包含了搜索查询和用户 IP 地址作为查询参数:
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 request。如果当请求或者相应处理过程中 ctx.Done
关闭了,则取消该请求。Search
传递一个闭包给 httpDo
处理 HTTP 的响应:
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 我们提供的闭包返回,所以这里读取结果是安全的。
return results, err
httpDo
函数在一个新 goroutine 里执行 HTTP 请求并处理它的响应。 如果在 goroutine 退出之前 ctx.Done
关闭,它取消请求:
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
}
}
Contexts的适配代码
许多服务器框架为 request-scoped values提供了packages 和类型。我们能够定义 Context
接口一个新的实现来桥接使用现有框架的代码和期望使用 Context
参数的代码。
例如,Gorilla's github.com/gorilla/context package 允许处理程序通过提供从HTTP请求到键值对的映射来将数据与传入请求相关联。gorilla.go 中,我们提供了一个 Context
实现,它的 Value
方法返回与Gorilla package中的特定HTTP请求关联的值。
其他packages 提供了类似于 Context
的取消支持。例如 Tomb 提供了一个 Kill
方法,该方法通过关闭Dying
channel来发出取消信号。Tomb
还提供了等待 goroutine 退出的方法, 类似于 sync.WaitGroup
。在tomb.go中,我们提供了一个 Context
实现,当 parent Context
被取消或者提供了 Tomb
被杀死,实现被取消。
结论
在Google,我们要求Go程序员将 Context
作为调用路径上每个函数的第一个参数传递。这使许多不同团队开发的Go代码可以很好地进行互操作。它提供了对超时和取消的简单控制,并确保安全凭证之类的关键值正确地传递给Go程序。
希望基于 Context
构建的服务器框架应提供 Context
的实现,以在其程序包和需要 Context
参数的程序包之间架起桥梁。然后,他们的客户端库将从调用代码中接受 Context
。 通过为 request-scoped 的数据和取消操作建立公共接口,Context
使 package 程序开发人员更容易共享用于创建可伸缩服务的代码。
本译文仅用于学习和交流目的,转载请务必注明文章译者、出处、和本文链接
我们的翻译工作遵照 CC 协议,如果我们的工作有侵犯到您的权益,请及时联系我们。