Go 并发模式:Context
介绍
在 Go 的服务器中,每一个到来的请求都在其各自的 goroutine 中进行处理。请求处理程序通常启动额外的 goroutine 来访问后端,比如在访问数据库和 RPC 服务时。工作在一个请求上的 goroutine 集合通常需要用到请求中的特定值,比如最终用户的身份,授权令牌和请求的截止期限。当请求撤消或超时,工作在该请求上的所有 goroutine 应迅速退出,这样系统就能回收它们使用的资源。
Google 开发了一个 context 包,可以很容易的将请求域的值,取消信号,和跨 API 边界的截止期限传递给处理请求所涉及的所有 goroutine。本文介绍如何使用该包,并提供了完整的工作示例。
Context
context 包的核心是 Context 类型:
// A Context carries a deadline, cancelation 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{}
}
Done 方法给代表 Context 运行的函数返回一个作为取消信号的 channel:当通道关闭时,该函数应放弃它们的作业并返回。Err 方法返回一个 error,指示 Context 被取消的原因。
Context 不具有 Cancel 方法,因为同一原因,Done 通道只用于接收:接收取消信号的函数通常不是发送该信号的那一个。特别是,当父操作为子操作启动 goroutine时,这些子操作不应该能够取消父操作。作为替代,WithCancel 函数提供取消新 Context 值的方法。
Context 被多个 goroutine 同时使用是安全地。代码中可以将一个 Context 传递给任意数量的 goroutine,并且可以通过退出该 Context 来给所有使用它的 goroutine 发出退出信号。
Deadline 方法允许函数来决定它们是否应该开始工作;如果剩下的时间太短,这时开始工作可能不太划算。代码也可以使用一个 deadline 来为 I/O 操作设置超时。
Value 允许 Context 携带请求域的数据。该数据在由多个 goroutine 同时使用时必须是安全的。
衍生的 context
context 包提供了从现有值衍生出新的 Context 值的函数。这些值形成树状结构:当一个 Context 取消时,从它衍生出的所有 Context 也将被取消。
Background 是任何 Context 树的根,它永远不会退出:
// 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
WithCancel 和 WithTimeout 返回衍生出的 Context 值,它们可以比父 Context 更早取消。与传入的请求相关联的 Context 通常在请求处理程序返回时被取消。在使用多个副本时,WithCancel 对于取消多余的请求很有用。需要给发往后端服务的请求设置 deadline 时 WithCancel 很有用。
// 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)
// A CancelFunc cancels a Context.
type CancelFunc func()
// 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 提供了一种将请求域值与 Context 相关联的方法:
// WithValue returns a copy of parent whose Value method returns val for key.
func WithValue(parent Context, key interface{}, val interface{}) Context
工作实例是了解如何使用 context 包的最好方式。
实例:谷歌网络搜索
这个例子是一个 HTTP 服务器,它通过将查询 golang 转发到 Google Web Search API 并呈现结果,来处理类似 /search?q=golang&timeout=1s 这样的 URL 。timeout 参数告诉服务器在经过这段时间之后取消该请求。
代码被拆分为三个包:
- server 提供
main函数和/search的处理程序。 - userip 提供从请求中提取用户 IP 地址并将其与
Context相关联的函数。 - google 提供了向 Google 发送查询的
Search函数。
server 程序
server 程序通过提供 “golang” 的前几个 Google 搜索结果来处理类似 /search?q=golang 这样的请求。它注册 handleSearch 来处理 /search 端点。处理程序创建了一个叫做 ctx 的初始化 Context ,并安排在处理程序返回时取消它。如果请求包含 timeout URL 参数,则在超时后 Context 自动退出。
func handleSearch(w http.ResponseWriter, req *http.Request) {
// ctx is the Context for this handler. Calling cancel closes the
// ctx.Done channel, which is the cancellation signal for requests
// started by this handler.
var (
ctx context.Context
cancel context.CancelFunc
)
timeout, err := time.ParseDuration(req.FormValue("timeout"))
if err == nil {
// The request has a timeout, so create a context that is
// canceled automatically when the timeout expires.
ctx, cancel = context.WithTimeout(context.Background(), timeout)
} else {
ctx, cancel = context.WithCancel(context.Background())
}
defer cancel() // Cancel ctx as soon as handleSearch returns.
处理程序从请求中提取查询,并且通过调用 userip 包来提取客户端的 IP 地址。客户端的 IP 地址是后端请求所需要的,因此 handleSearch 将其附加到 ctx 上:
// Check the search query.
query := req.FormValue("q")
if query == "" {
http.Error(w, "no query", http.StatusBadRequest)
return
}
// Store the user IP in ctx for use by code in other packages.
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 :
// Run the Google search and print the results.
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
}
userip 包
userip 提供从请求中提取用户 IP 地址并将其与 Context 相关联的函数。一个 Context 提供一个键-值映射,键和值都是 interface{} 类型。键类型必须可比较,并且值供多个 goroutine 同时使用时必须是安全的。类似 userip 这样的包隐藏了此映射的细节,并且提供对特定 Context 值的强类型访问。
为了避免键冲突,userip 定义一个非输出类型的 key ,并且使用此类型的值作为 context 键:
// The key type is unexported to prevent collisions with context keys defined in
// other packages.
type key int
// userIPkey is the context key for the user IP address. Its value of zero is
// arbitrary. If this package defined other context keys, they would have
// different integer values.
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 返回一个新的包含提供的 userIP 值的 Context :
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 returns nil if ctx has no value for the key;
// the net.IP type assertion returns ok=false for nil.
userIP, ok := ctx.Value(userIPKey).(net.IP)
return userIP, ok
}
google 包
google.Search 函数生成一个到 Google Web Search API 的请求,并且解析 JSON 编码的结果。它接收 Context 参数 ctx ,并且如果 ctx.Done 在请求正在进行时关闭,则立即返回。
Google 网络搜索 API 请求包含搜索查询和用户 IP 作为查询参数:
func Search(ctx context.Context, query string) (Results, error) {
// Prepare the Google Search API request.
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)
// If ctx is carrying the user IP address, forward it to the server.
// Google APIs use the user IP to distinguish server-initiated requests
// from end-user requests.
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 响应:
var results Results
err = httpDo(ctx, req, func(resp *http.Response, err error) error {
if err != nil {
return err
}
defer resp.Body.Close()
// Parse the JSON search result.
// 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 waits for the closure we provided to return, so it's safe to
// read results here.
return results, err
httpDo 函数运行 HTTP 请求并在一个新的 goroutine 中处理它的应答。如果 ctx.Done 在 goroutine 退出之前关闭,它将取消请求:
func httpDo(ctx context.Context, req *http.Request, f func(*http.Response, error) error) error {
// Run the HTTP request in a goroutine and pass the response to f.
tr := &http.Transport{}
client := &http.Client{Transport: tr}
c := make(chan error, 1)
go func() { c <- f(client.Do(req)) }()
select {
case <-ctx.Done():
tr.CancelRequest(req)
<-c // Wait for f to return.
return ctx.Err()
case err := <-c:
return err
}
}
为 Contexts 适应代码
许多服务器框架为承载请求域的值提供包和类型。我们可以定义 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 被杀掉时,它也将退出。
总结
在 Google,我们要求 Go 程序员在输入和输出请求之间的调用路径上的每个函数中,将 Context 作为第一个参数来传递。这让不同团队之间的 Go 代码开发有良好的互操作性。它提供了对超时、退出还有确保对像安全凭据这样的临界值正确地传输 Go 程序的简单控制。
想基于 Context 建立的服务器框架应该提供 Context 的实现来对它们的包与那些期待 Context 参数的代码进行桥接。它们的客户端库随后将从调用代码中接收一个 Context。通过为请求域值和退出信号建立一个通用接口,Context 使包的开发者更容易共享用于创建可伸缩服务的代码。