开发人员有时会误解context.Context类型,尽管它是Go语言的关键概念之一,也是Go中并发代码的基础之一。接下来让我们看看这个概念,并确保我们理解为什么乃如何有效地使用它。
根据官方文档:
上下文(context)携带最后期限、取消信号和其他跨API边界的值。
下面让我们来看下这个定义,以及和上下文相关的概念。
1. 最后期限
最后期限(deadline)是指通过以下方式明确指定的时间点:
- 从当前开始的一个time.Duration
- 一个time.Time
最后期限的语义传达了如果到达此时间点则应停止当前的活动。例如,活动可以是一个I/O请求或者是一个等待从channel接收消息的goroutine。
让我们考虑一个应用程序,它每4秒就从雷达那里接收一次飞行位置。一旦收到一个位置,我们希望能与其他关心最新位置的应用程序共享它。我们在我们所掌握的逻辑中定义了一个publisher接口,它只包含一个方法:
type publisher interface {
Publish(ctx context.Context, position flight.Position) error
}
这个方法接收一个上下文参数和一个位置参数。我们假定这个具体的实现会调用一个函数来给代理(broker,就像使用Sarama库发布Kafka消息一样)发布消息。这个函数是上下文敏感的(context aware),也就是说,一旦上下文被取消,它就可以取消请求。
假定我们没有收到上游的上下文,那应该提供给Publish方法什么上下文呢?我们提到过,应用程序只对最新的位置感兴趣,所以我们自已构建的上下文应该传达4秒超时的信息,如果4秒后还没有发布新的飞行位置,那就应该停止调用Publish方法:
type publishHandler struct {
pub publisher
}
func (h publishHandler) publishPosition(position flight.Position) error {
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
defer cancel()
return h.pub.Publish(ctx, position)
}
这段代码使用context.WithTimeout函数创建了一个上下文对象,它接收一个超时参数和一个上下文参数,因为publishPosition没有接收一个已存在的上下文,所以我们使用context.Background凭空创建了一个。同时,context.WithTimeout返回两个变量:新创建的上下文和cancel函数对象,一旦调用上下文就可以取消上下文。将新创建的上下文传递给Publish方法可以让它在最长4秒内返回。
使用defer调用cancel函数是什么道理?在内部实现中,context.WithTimeout会创建一个goroutine,它会被在内存中保留最多4秒,或者直到cancel函数被调用。因此使用defer延迟调用cancel函数意味着当我们退出父函数时,上下文一定会被取消,创建的goroutine一定会被停止。这是一种保护,当调用函数返回时,不会将保留的对象留在内存中。
2.取消信号
Go上下文的另一个用例就是携带取消信号。想象一下,我们创建一个在goroutine中调用CreateFileWatcher(ctx context.Context, filename string)的应用程序。这个函数创建了一个特定的文件观察器,它不断地从文件中读取并捕获更新。当提供的上下文过期或者被取消时,此函数处理它以便关闭文件描述符。
最后,当main函数返回时,我们希望通过关闭这个文件描述符来优雅地处理退出,因此我们需要传播一个信号。
一个可能的方式就是使用context.WithCancel,它返回一个新的上下文(返回的第一个参数),以及一个cancel函数对象(第二个参数)。一旦调用了cancel函数,这个新的上下文就被取消了:
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
CreateFileWatcher(ctx, "foo.txt")
}()
/ ...
}
当main函数返回时,它会调用cancel函数去取消传递给CreateFileWatcher的上下文,这样文件描述符就能优雅地被关闭了。
3.上下文值
Go上下文的最后一个用例就是携带一个键值列表。在了解它背后的原理之前,让我们先看看如何使用它。
可以通过以下方式创建传递值的上下文:
ctx := context.WithValue(parentCtx, "key", "value")
就像context.WithTimeout、context.WithDeadline和context.WithCancel一样,context.WithValue是从父上下文中创建出来的(这里是parentCtx参数)。在这个例子中,我们创建了一个新的上下文,包含和原来的parentCtx相同的特征,同时还设置了一个新的键值对。
我们可以通过Value方法访问这个值:
ctx := context.WithValue(context.Background(), "key", "value")
fmt.Println(ctx.Value("key"))
value
提供的键和值的类型是any类型。确实,对于值,我们想使用any类型,但是为什么键不使用字符串类型而使用一个空的接口类型呢?原因是,如果键使用字符串类型,可能会导致冲突:来自不同的包的两个函数可以使用相同的字符串作为键,这样就会把其中的一个覆盖掉了,因此处理上下文的键的最佳实践就是创建一个未导出的自定义类型:
package provider
type key string
const myCustomKey key = "key"
func f(ctx context.Context) {
ctx = context.WithValue(ctx, myCustomKey, "foo")
/ ...
}
因为myCustomKey是未导出的,所以没有什么风险,使用此上下文其他的包不可能覆盖这个键已设置的值。即使另一个包中也基于键的类型定义了一个myCustomKey,那它也是一个不同的键。
那么让上下文携带一个键值列表有什么意义呢?因为Go上下文是通用和主流的,所以现实中有无数的用例。
例如,如果我们使用跟踪技术,那可能希望不同的子函数共享相同的关联ID。一些开发人员可能认为此ID太具有侵入性,不适合作为函数签名的一部分。在这种情况下,我们可以将其作为提供的上下文的一部分。
另一个例子是,如果我们想实现一个HTTP中间件。如果你不熟悉中间件这个概念,可以将其理解为是在服务请求之前执行中间函数。例如,在下图中,我们配置了两个中间件,它们必须在执行处理程序本身之前被执行。如果我们希望中间件能进行通信,那它们必须经过在*http.Request中处理的上下文。
我们写一个标记源主机是否有效的中间件的例子:
type key string
const isValidHostKey key = "isValidHost"
func checkValid(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
validHost := r.Host == "acme"
ctx := context.WithValue(r.Context(), isValidHostKey, validHost)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
首先,我们定义一个特定的上下文的键,isValidHostKey。然后,checkValid中间件检查源主机是否有效。这个信息会通过新的上下文传递,通过调用 next.ServeHTTP,将信息传递给下一个HTTP步骤(下一个步骤可以是另一个中间件或者最终的HTTP处理程序)。
4.感知上下文的取消信号
context.Context类型包含一个导出方法Done,它返回一个只能接收通知的channel:<-chan struct{ }。当和上下文相关的工作被取消时,这个channel被关闭,例如:
- 通过context.WithCancel创建的上下文关联的Done channel在cancel函数被调用时被关闭。
- 通过context.WithDeadline创建的上下文关联的Done channel在超过最后期限时被关闭。
需要注意的一点是,当上下文被取消或已达到最后期限时,内部channel应该被关闭,而不是在它收到特定值时被关闭,因为channel的关闭是所有消费者goroutine收到的唯一channel操作。只有这样,当上下文被取消或达到最后期限时,所有消费者才都会收到通知。
此外,context.Context还有一个Err导出方法,如果Done channel还没有被关闭,这个方法返回nil,否则它返回一个非nil的错误,这个错误解释了为什么这个channel会被关闭,例如:
- 如果channel被取消,返回context.Canceled错误。
- 如果上下文超过最后期限。返回context.DeadlineExceeded错误。
让我们看一个具体的例子.在这个例子中,我们想要持续地从一个channel接收消息 。同时,我们的实现应该是上下文感知的,并在提供的上下文完成后返回:
func handler(ctx context.Context, ch chan Message) error {
for {
select {
case msg := <-ch:
/ Do something with msg
case <-ctx.Done():
return ctx.Err()
}
}
}
我们创建一个for循环和使用select处理这两种情况:从ch接收消息或者接收上下文完成的信号,然后我们必须停止工作。在处理channel时,这是一个如何使函数上下文感知的示例。
实现一个接收上下文的函数 在接收携带可能取消或超时的上下文的函数中,将消息接收或发送到channel的操作不应以阻塞的方式完成。例如在以下函数中,我们向一个channel发送消息并从另一个channel接收消息: func f(ctx context.Context) error { / ... ch1 <- struct{}{} v := <-ch2 / ... } 这个函数的问题是,即使上下文被取消或者超时,我们可能也不得不等到收到一条消息或者发送一条消息,这样处理没什么好处。我们应该使用select等待channel的操作完成或者上下文被取消: func f(ctx context.Context) error { 在这个新版本的实现中,如果ctx被取消或者超时,我们会立即返回,不会被那个channel的接收和发送操作所阻塞。 |
总之,要成为一名熟练的Go开发人员,必须了解上下文是什么以及如何使用它。在Go标准库和外部第三方库中,context.Context无处不在。正如我们所提到的,上下文允许携带最后期限、取消信号和/或键值列表。需要用户等待的函数应该有一个上下文参数,因为这样允许上游调用者决定何时中止对这个函数的调用。
当对使用哪个上下文有疑问时,应该使用context.TODO( )而不是传递一个context.Background返回空的上下文。虽然context.TODO( )也是返回一个空的上下文,但是从语义上讲,它表示要使用的上下文不清楚或尚不可用(例如,尚未由父级传播)。