理解Go语言中上下文

本文详细介绍了Go语言中context.Context的重要性和用法,包括最后期限、取消信号和携带值的功能。通过具体示例,解释了如何使用WithTimeout、WithDeadline、WithCancel创建带有特定条件的上下文,以及如何通过Done和Err方法感知上下文的取消状态。同时,讨论了使用自定义键值避免冲突的问题,以及在HTTP中间件中应用context的场景。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

        开发人员有时会误解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 {
    / ...
    select {
    case <-ctx.Done():
        return ctx.Err()
    case ch1 <- struct{}{}:
    }
 
    select {
    case <-ctx.Done():
        return ctx.Err()
    case v := <-ch2:
        / ...
    }
}

在这个新版本的实现中,如果ctx被取消或者超时,我们会立即返回,不会被那个channel的接收和发送操作所阻塞。

        总之,要成为一名熟练的Go开发人员,必须了解上下文是什么以及如何使用它。在Go标准库和外部第三方库中,context.Context无处不在。正如我们所提到的,上下文允许携带最后期限、取消信号和/或键值列表。需要用户等待的函数应该有一个上下文参数,因为这样允许上游调用者决定何时中止对这个函数的调用。

        当对使用哪个上下文有疑问时,应该使用context.TODO( )而不是传递一个context.Background返回空的上下文。虽然context.TODO( )也是返回一个空的上下文,但是从语义上讲,它表示要使用的上下文不清楚或尚不可用(例如,尚未由父级传播)。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Mindfulness code

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值