前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Go语言中常见100问题-#60 Misunderstanding Go contexts

Go语言中常见100问题-#60 Misunderstanding Go contexts

作者头像
数据小冰
发布2022-08-15 15:22:50
7710
发布2022-08-15 15:22:50
举报
文章被收录于专栏:数据小冰
对Go中Context使用有误解

尽管context.Context是Go语言中一个非常重要的概念,也是Go中并发代码的基石,但开发人员有时会对它的使用有误解。根据官方文档的定义,Context会携带一个截止日期,一个取消信号和跨越API边界的值。现在让我们深入研究这个定义并理解与上下文(Context)所有的相关概念。

截止日期

截止日期是指通过下面的方式确定的特定时间点:

  • time.Duration:从现在开始持续的时间值,例如250毫秒
  • time.Time:一个具体的日期时间,例如 2023-02-07 00:00:00 UTC

截止日期(deadline)想表达的语义是如果到了该截止日期,则应该停止正在进行的活动。例如,一个I/O请求,或是一个等待从channel中接收消息的goroutine.

现在有这样一个应用程序,它每隔4秒从雷达接收一次飞行位置,一旦收到位置信息,会将位置信息共享给对飞机最新位置感兴趣的应用程序。这个应用程序有一个接口,接口中包含一个Publish方法,代码如下:

代码语言:javascript
复制
type publisher interface {
        Publish(ctx context.Context, position flight.Position) error
}

Publish函数有上下文context和位置position两个参数,假设具体实现将调用一个函数来向代理发布消息,例如使用Sarama发布到Kafka。这个函数是上下文感知的,也就是说一旦上下文被取消,它就会取消请求,完整代码见https://github.com/ThomasMing0915/100-go-mistakes-code/tree/main/60。

假设现在我们没有已有的context上下文对象,那怎么构造一个context传递给Publish方法呢?前面已经提到,所有应用程序只对最新位置感兴趣,因此,构造的上下文context应该能够表达,四秒后如果我们不能发布新位置,应该停止它。

代码语言:javascript
复制
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函数创建一个context对象,该函数接收一个超时时间和一个context对象。由于publishPosition没有收到现有的上下文context,即它的入参没有context,只有一个position变量。我们使用context.Background从一个空的上下文创建一个,同时,context.WithTimeout返回两个变量,创建的上下文和一个取消func()函数,调用取消函数后将取消上下文,创建的上下文ctx传递给h.pub.Publish之后,使得Publish方法最长在4秒内返回。

为什么通过defer调用cancel函数,context.WithTimeout内部创建了一个goroutine, 这个goroutine将存活4秒中或者被调用取消。因此通过defer调用cancel意味着当父函数退出时,上下文被取消,创建的goroutine将被销毁,这是一种将无效垃圾对象不留在内存中的保护措施。

取消信号

context的另一个使用场景是携带一个取消的信号。现在假设我们需要创建一个应用程序,该应用程序在一个goroutine内部调用CreateFileWatcher(ctx context.Context, filename string).该函数将创建一个对某个文件的监听器,当文件有更新时,能够及时获取到最新数据。当提供的上下文过期或者取消时,会关闭对应的文件描述符。最后一点是,当main函数返回时,希望优雅地关闭文件描述符,因此需要传递一个信号。一种可能的实现方案是使用context.WithCancel,它的第一个返回值是一个上下文,第二个返回值是一个取消函数,一旦调用取消函数就会取消。

代码语言:javascript
复制
func main() {
        ctx, cancel := context.WithCancel(context.Background())
        defer cancel()

        go func() {
                CreateFileWatcher(ctx, "foo.txt")
        }()

        // ...
}

当main函数返回的时候,cancel函数将会被调用,会将取消的上下文信息传递给CreateFileWatcher函数,这样打开的文件描述符会被优雅的关闭。

传递values

context的最后一个场景传递key/value键值对。在了解背后的原理之前,先来看一个如何使用的例子。传递key/value的context可以通过context.WithValue创建。

代码语言:javascript
复制
ctx := context.WithValue(parentCtx, "key", "value")

像context.WithTimeout,context.WithDeadline和contet.WithCancel一样,context.WithValue是从父上下文创建的。上面的代码创建了一个新的上下文ctx,包含了parentCtx信息外,还携带了一个键和一个值。可以通过下面的操作获取ctx中key对应的value信息。下面的代码将输出value

代码语言:javascript
复制
ctx := context.WithValue(context.Background(), "key", "value")
fmt.Println(ctx.Value("key"))

键值对中的key和value可以是任何类型,对于value,我们希望可以传递任何类型,但是对于key为什么不定义为string类型而是interface{}类型呢?因为在有些情况下,可能会导致碰撞冲突。实际中,来自不同包的两个函数可以使用相同的字符串值作为key,会导致后者覆盖前者的值。因此,处理上下文键的最佳实践是创建一个未导出的自定义类型。

代码语言:javascript
复制
package provider

type key string

const myCustomKey key = "key"

func f(ctx context.Context) {
        ctx = context.WithValue(ctx, myCustomKey, "foo")
        // ...
}

上述程序中,myCustomKey常量是未导出的,因此,使用相同上下文的另一个包不会覆盖已设置的值。即使另一个包也基于键类型创建了相同的myCustomKey,它也是不同的键。

让上下文携带key/value信息有什么收益吗?由于Go的context上下文是标准库提供的,具有通用性,使用性广泛。所以在很多场景下都可以使用它。例如,在链路追踪的时候,我们可能希望不同的子函数共享相同的关联ID.但是决定直接使用ID具有侵入性而不能成为函数签名的一部分,好的做法是放在上下文context中传递。另一个例子是HTTP中间件,中间件就是在服务请求之前执行的中间函数。如下图所示。

在上图中,请求在到达处理handler之前需要经过两个中间件Middleware1和Middleware2的处理。如果我们想在这两个中间件之间做些通信,必须通过*http.Request中的上下文携带信息。下面是程序实例,标注请求ip是否是一个合法的ip,并传递给下一个中间件。

代码语言:javascript
复制
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))
        })
}

在上面的程序中,定义了一个特定的上下文键isValidHost,然后在checkValid中间件检查源主机是否有效,此信息将在新的上下文中传递,使用next.ServerHTTP传递到下一个HTTP处理步骤中(下一个操作步骤可以是另一个HTTP中间件或是HTTP处理程序)。这个示例展示了如何在具体的Go应用程序中使用带值的上下文。

通过前面的介绍,我们已知道如何创建一个上下文来携带截止日期,取消信号以及键值信息。我们可以将这个上下文传递给其他带有context参数的库。现在,我们来考虑另一种情况,现需要创建一个库,希望调用方可以取消上下文,怎么做呢?请看下面的介绍。

捕获上下文取消信号

context.Context类型有一个可导出方法Done.该方法返回一个只接收通知通道:<- chan struct{},当应取消与上下文关联的工作时,该通道将关闭。与Done通道相关的context可以通过下面两种方法创建:

  • context.WithCancel创建的上下文通道将被close,当取消操作cancel函数被调用后
  • context.WithDeadline创建的上下文通道将被close,当截止时间过期后

有一点需要注意,当上下文被取消或超过截止日期之后,为什么进行close操作,而不是通过向通道发送一条消息的方式通知接收者?因为关闭通道后,所有的消费者goroutine都将收到唯一的通道动作,这样,一旦上下文被取消或是到的最后截止时间,所有消费者都会收到通知,close通道操作像广播通知,而向通道发送消息,只有一个消费者能够捕获到通知。

context.Context对象对外暴露有一个Err方法,当通道没有被关闭的时候,调用Err方法将返回nil. 当通道被关闭时,调用它会返回一个error值,描述了Done通道被关闭的原因。例如:

  • 当通道被取消之后,则会出现context.Canceled错误
  • 当上下文超过截止时间之后,则会出现contet.DeadlineExceeded错误

现在来看一个具体的例子,下面的handler函数从通道ch中持续接收消息,还有一个参数context表明该handler是上下文感知的,当上下文结束时直接返回。

代码语言:javascript
复制
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接收到消息后,处理这条消息,当收到上下文停止工作的信号时,即走到ctx.Done逻辑,直接终止处理。

NOTE:在需要处理上下文被取消或是超时的函数时,接收或发送消息到通道的操作不应该以阻塞的方式来完成。例如下面的函数中,先从一个通道接收信息,并将消息发送给另一个通道。

代码语言:javascript
复制
func f(ctx context.Context) error {
        // ...
        ch1 <- struct{}{}

        v := <-ch2
        // ...
}

上述函数存在的问题是,如果上下文被取消或超时,我们可能不得不等待消息发送或接收,相反,应该使用select来等待通道操作完成或等待上下文取消。示例程序如下,下面的程序如果ctx被取消或是超过截止时间,程序能够立即返回,而不是阻塞在通道的收发操作上。

代码语言:javascript
复制
func f(ctx context.Context) error {
        // ...
        select {
        case <-ctx.Done():
                return ctx.Err()
        case ch1 <- struct{}{}:
        }

        select {
        case <-ctx.Done():
                return ctx.Err()
        case v := <-ch2:
                // ...
        }
}

总结,如果想成为一名专业的Go程序员,必须了解上下文是什么以及如何使用它。在实际Go程序中,context.Context无处不在,无论是标准库还是在第三方库中,均有它的身影。正如前面提到的,上下文可以携带截止日期、取消信号和键值信息。通常来说,需要调用方等待的函数应该使用上下文,这样调用者可以决定何时终止操作。当不确定要使用哪个上下文时,我们应该使用context.TODO()而不是使用context.Background传递一个空的上下文,实际上,context.TODO()也返回一个空的上下文,但是在语义上,它表示要使用的上下文要么不清楚,要么还不可用。最后注意一点,标准库中的上下文是goroutine并发安全的。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2022-06-06,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 数据小冰 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 对Go中Context使用有误解
    • 截止日期
      • 取消信号
        • 传递values
          • 捕获上下文取消信号
          相关产品与服务
          消息队列 TDMQ
          消息队列 TDMQ (Tencent Distributed Message Queue)是腾讯基于 Apache Pulsar 自研的一个云原生消息中间件系列,其中包含兼容Pulsar、RabbitMQ、RocketMQ 等协议的消息队列子产品,得益于其底层计算与存储分离的架构,TDMQ 具备良好的弹性伸缩以及故障恢复能力。
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档