go context 原理

原文 https://go.dev/blog/context

https://go.dev/blog/pipelines

Introduction

在 Go 服务器中,每个传入的请求都在其自己的 goroutine 中处理。

请求处理程序通常会启动额外的 goroutine 来访问后台,比如数据库和 RPC 服务等。

处理请求的 goroutines 集通常需要访问特定于请求的值,例如最终用户的身份、授权令牌和请求的截止日期。

当请求被取消或超时时,所有处理该请求的 goroutines 都应该快速退出,以便系统可以回收它们正在使用的任何资源。

Google开发了一个context包,可以轻松地将请求范围的值、取消信号和截止日期跨 API 边界传递给处理请求所涉及的所有 goroutine。context包公开可用 。本文介绍了如何使用该包并提供了一个完整的工作示例。

Context

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// A Context carries a deadline, cancellation 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:当通道关闭时,函数应该放弃它们的工作并返回。Err方法返回一个错误,指示Context取消的原因。

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

一个 Context 对于多个 goroutine 同时使用是安全的。 代码可以将单个 Context 传递给任意数量的 goroutine,并取消该 Context 以向所有 goroutine 发出信号。

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

Value 允许上下文携带请求范围的数据。 该数据必须是安全的,以便多个 goroutine 同时使用。

Derived contexts

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

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

1
2
3
4
// 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 更快地取消。 当请求处理程序返回时,通常会取消与传入请求关联的上下文。 WithCancel 对于在使用多个副本时取消冗余请求也很有用。 WithTimeout 对于设置对后端服务器的请求的截止日期很有用:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 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 相关联的方法:

1
2
// WithValue returns a copy of parent whose Value method returns val for key.
func WithValue(parent Context, key interface{}, val interface{}) Context

Example

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

代码分为三个包:

server 为 /search 提供主要功能和处理程序。
userip 提供了从请求中提取用户 IP 地址并将其与 Context 相关联的功能。
google 提供了用于向 Google 发送查询的搜索功能。

The server program

server 通过为 golang 提供前几个 Google 搜索结果来处理像 /search?q=golang 这样的请求。 它注册 handleSearch 来处理 /search 端点。 处理程序创建一个名为 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 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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 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)

The handler calls google.Search with ctx and the query:

1
2
3
4
// Run the Google search and print the results.
start := time.Now()
results, err := google.Search(ctx, query)
elapsed := time.Since(start)

If the search succeeds, the handler renders the results:

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
}

Package userip

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

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

1
2
3
4
5
6
7
8
// 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 值:

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 值的新 Context:

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

FromContext 从 Context 中提取用户 IP:

1
2
3
4
5
6
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
}

Package google

google.Search 函数向 Google Web Search API 发出 HTTP 请求并解析 JSON 编码的结果。 它接受 Context 参数 ctx 并在请求运行时如果 ctx.Done 关闭则立即返回。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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 关闭,则将其取消。 搜索传递一个闭包给 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
28
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 退出之前关闭,它将取消请求:

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 {
// Run the HTTP request in a goroutine and pass the response to f.
c := make(chan error, 1)
req = req.WithContext(ctx)
go func() { c <- f(http.DefaultClient.Do(req)) }()
select {
case <-ctx.Done():
<-c // Wait for f to return.
return ctx.Err()
case err := <-c:
return err
}
}

Adapting code for Contexts

许多服务器框架提供包和类型来承载请求范围的值。 我们可以定义 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 被杀死时,该实现被取消。

Conclusion

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

想要在 Context 上构建的服务器框架应该提供 Context 的实现,以便在它们的包和那些需要 Context 参数的包之间架起一座桥梁。 然后,他们的客户端库将接受来自调用代码的上下文。 通过为请求范围的数据和取消建立一个通用接口,Context 使包开发人员更容易共享代码以创建可扩展的服务。