logo

深入理解 Go 的 Context 包

Published on

Context 包的开发目的是为了让在 API 边界和进程之间传递请求域的值、截止时间和取消信号变得简单。如果我们先从非技术角度理解 Context("形成事件、陈述或想法的环境背景,并据此可以完全理解它"),可能会帮助我们更好地理解技术层面的 Context。Context 包在处理服务器、发出 HTTP 请求以及许多其他涉及 goroutine 的活动时非常有用。本文旨在深入解释 Go 提供的 context 包。在接下来的章节中,我们将深入探讨 context 包。

Context 类型

context 包的主要组件是 Context 类型。我们可以从Go 文档中了解更多关于这个类型的信息。仔细观察可以发现,Context 类型是一个实现了四个函数的接口:

  • deadline 函数
  • done 函数
  • error 函数
  • value 函数

这些函数是我们使用 context 包进行大多数操作的基础,我们稍后会看到它们的应用。重要的是要知道,context 包为我们提供了作为 context 包构建块的 Context 类型。context 包中的第二个类型是 CancelFunc 类型,它是一个函数。CancelFunc 用于取消操作,它不会等待这些操作停止后再取消,而是告诉操作立即放弃它们正在做的事情。

Context 实战

context 包允许我们使用它提供的几个函数从已存在的 context 值创建新的 context 值!这些函数包括:

  • WithCancel
  • WithTimeout
  • WithDeadline

它们都返回两个值,分别是 Context 类型和 cancelFunc 类型。这些函数返回的值称为派生值,它们通常形成一个以 Background 函数提供的 context 为根的树。当一个 context 被取消时,从它派生的所有值都会自动被取消。 Background 函数通常用于初始化目的,因为它返回一个非空的空 context,并且永远不会被取消,即使 WithCancelWithTimeoutWithDeadline 函数返回的子 context 可以被取消并删除其对 Background context 的引用。

Maple

这些派生 context 值形成的树允许取消传播,即如果一个 context 被取消,从它派生的所有 context 也会被取消。这一点很重要,因为取消是 context 包最常见的用途之一。

在使用 context 时有一个重要原则:避免将其存储在结构体中,因为这可能会影响程序的同步性质。将 context 存储在结构体中意味着该结构体的所有方法都使用该 context,当这些方法从具有不同 context 的函数调用时,可能会导致混乱。记住,context 值应该是请求域的。

确保显式地将 Context 作为参数传递给接受它的函数,传递给函数的 context 通常应该是第一个参数,并且不为 nil。**不应该向函数传递 nil context。**如果你不知道当前 context 的值,可以传递 ToDo 函数的值。与 Background 函数类似,ToDo 函数返回一个非 nil 的空 context,当不确定使用什么 context 时或作为占位符时使用,直到周围的函数收到 context。BackgroundToDo 函数为派生更多 Context 值提供了基础。

WithValue 函数

WithValue 函数顾名思义是用于处理值的,这些值通常是请求域的,典型的用于在进程和 API 之间传递的数据。WithValue 函数不应该用于向函数传递参数。

WithValue 函数定义如下:

func WithValue(parent Context, key, val interface{}) Context

它接收一个父/根 context、一个键和要与键关联的值。它返回一个包含该键值的 Context,其工作方式类似于键值对模型。重要的是要注意键不应该是任何内置类型,它应该是一个自定义类型,这是为了避免使用 context 的包之间的冲突。

让我们看一个例子:

package main

import (
    "fmt"
    "context"
)

type keyType string

func main() {
    key := keyType("Name")
    ctx := context.WithValue(context.Background(), key, "Tobyy")
    exampleContext(ctx, key)
}

func exampleContext(ctx context.Context, k keyType){
    value := ctx.Value(k)
    if value != nil {
        fmt.Print("The context value is :", value)
        return
    }
    fmt.Print("Ooooops, unable to find the context value")
}

上面的示例是一个简单的程序,包含两个函数:默认的 main 函数和第二个自定义函数 exampleContextexampleContext 函数期望一个 context 作为其第一个参数,以及 context 值的键。请注意,context 值的类型不是内置类型,正如我们之前讨论的那样。exampleContext 函数的目的是检查提供的 context 中是否存在与提供的 key 相对应的值。如果值存在,我们就打印出这个值;否则,我们向终端打印一条消息说明找不到 context 值。很简单对吧?

真正有趣的部分发生在 main 函数中。在 main 函数中,我们创建了一个类型为 keyType 的变量(keyType 是我们创建的自定义类型)。在这个例子中,我为了清晰起见将变量命名为 key,但你可以根据需要为它取任何名字,这个变量随后会被传递给 exampleContext 函数。

exampleContext 函数期望的第二个参数是 Context 类型的 context。我们在 main 函数的第二行创建了这个 context。这个 context 是使用 WithValue 函数创建的,因为我们要处理的是值。如果你还记得前面提到的,WithValue 函数需要一个父 Context 作为其参数之一,所以我们将从 Background 函数获取的 background context 作为第一个参数传递给 exampleContext

我们可以将对 context.Background 的调用抽象成一个变量并将其传递给 WithValue 函数,但为了简洁起见,我们直接在参数中调用它。WithValue 函数期望的下一个参数是 key,所以我们将上面创建的 key 作为参数传入。WithValue 函数期望的另一个参数是我们想要附加到 key 上的值本身。在这个简单的例子中,我传入字符串 "Toby" 作为值。

最后,我们将创建的 context 作为参数传递给 exampleContext 函数,然后就可以运行它了。

The context value is: Toby

如果我们运行程序,会得到输出值!WithValue 函数对于传递值非常重要,特别是请求域的值。 WithValue 函数最常见的用例之一是在发出 HTTP 请求时。Go 的 net/Http 包在底层有机制为每个请求创建一个 context,可以通过 Context() 方法访问,所以通常会看到 r.Context(),其中 rhttp.Request 类型。

WithCancel 函数

获取派生 context 的另一种方法是使用 WithCancel 函数。WithCancel 函数就像其他用于派生 context 的函数一样,返回一个子 context 和一个取消函数。但是,WithCancel 函数返回的子 context 带有一个新的 Done() 通道。记住我们之前讨论过 Context 接口,看到它有一个 Done() 函数?返回的 context 的 Done 通道在返回的 cancel 函数被调用或其父 context 的 Done 通道关闭时关闭,具体取决于哪个先发生。每当 Done 通道关闭时,context.Err 函数会返回一个错误消息 "Context Cancelled"。

让我们看一个例子:

package main

import (
    "fmt"
    "context"
    "time"
)

func main() {
    ctx := context.Background()
    ctx, cancel := context.WithCancel(ctx)

    time.AfterFunc(2*time.Second, cancel)

    sayMyName(ctx, 5*time.Second, "Toby")
}

func sayMyName(ctx context.Context, d time.Duration, name string){
    select {
    case <- time.After(d):
        fmt.Print("Your name is ", name)
    case <-ctx.Done():
        err := ctx.Err()
        fmt.Print(err)
    }
}

上述示例是一个常用来解释 Go 中 Context 概念的例子,其中包含两个函数:

  1. 第一个是默认的 main 函数。

  2. 第二个是自定义函数

    sayMyName
    

    它接收三个参数:

    • 类型为 Context 的 Context 参数
    • 类型为 time.Duration 的持续时间参数
    • 类型为 string 的名字参数

sayMyName 函数的目标是:在提供的持续时间后打印出您的名字,前提是 Context 未被取消。在 sayMyName 函数中,使用了一个 select 语句。在 Go 中,select 用于让 goroutine 等待多个通信操作。select 会阻塞,直到其中一个 case 可以执行,然后执行该 case。

在上述示例中,select 语句检查两个 case:

  1. 第一个 case:检查提供的持续时间是否已过。为此,使用 time.After 函数,该函数接收一个持续时间参数,等待时间过去后,会在返回的通道中发送一条消息。
  2. 第二个 case:检查 Done 函数是否返回了一个关闭的通道。记住我们之前讨论的,当通过 WithCancel 函数返回的 cancel 被调用时,Done 通道会被关闭。

只要两个 case 中的任何一个能够运行,select 就会执行相应的 case。

在回到 main 函数时,我们调用了 sayMyName 函数,而它的一个参数是 Context。因此,我们使用 WithCancel 函数并传入 Background Context 作为其根 Context。WithCancel 函数返回了一个新的 Context 和一个 cancel,于是将它们存储到变量中。

接下来,我们将派生的 Context 与 5 秒的持续时间和字符串 "Toby" 一起传递给 sayMyName 函数。

最后,我们需要调用 WithCancel 函数返回的 cancel,为此使用了 time 包提供的 AfterFunc 函数。

AfterFunc 函数的作用是:在指定的时间过去后调用所传入的函数。在这个例子中,我们设置了 2 秒的延迟来模拟取消操作。

至此,我们完成了函数的设置,可以运行代码。运行后输出:

context canceled

运行结果如预期那样,传入的字符串并没有被打印出来!这是因为我们在 2 秒后取消了该函数,而名字的打印需要等待 5 秒。很神奇对吧?这就是 context 包赋予我们的强大功能:能够实现任务的取消和取消传播

这种能力在处理服务器请求和网络通信时尤其重要。例如,用户可以在收到响应之前决定取消其请求,而通过 context 的取消机制,这个过程将会优雅地完成,因为取消 Context 会释放与之相关的资源

WithTimeout 函数可以被看作是 增强版的 WithCancel 函数。我们将在下一节中详细探讨它的用法。

WithTimeout 函数

WithTimeout 基本上是加强版的 WithCancel 函数。让我们看一个例子:

package main

import (
    "fmt"
    "context"
    "time"
)

func main() {
    ctx := context.Background()
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()

    sayMyName(ctx, 5*time.Second, "Toby")
}

func sayMyName(ctx context.Context, d time.Duration, name string){
    select {
    case <- time.After(d):
        fmt.Print("Your name is ", name)
    case <-ctx.Done():
        err := ctx.Err()
        fmt.Print(err)
    }
}

上面的示例与前一部分的例子非常相似。然而,在这个示例中,我们使用的是 WithTimeout 函数

WithTimeout 函数 通常用于向服务器发送 HTTP 请求或执行类似操作。它接收一个超时时间作为参数,在超时时间结束后,done 通道会被关闭,Context 也会被取消。它返回一个新的子 Context 和一个 cancel 函数,如果需要在超时之前取消 Context,可以调用该函数。

main 函数的第二行中,我们通过传入 Background Context 和持续时间(此处为 2 秒)派生了一个新的 Context。在下一行中,我们使用 defer 语句调用 cancel 函数!

您可能注意到,在这个示例中,AfterFunc 函数消失了,这是因为 WithTimeout 函数已经优雅地替代了 AfterFunc 的功能。如果我们运行这个示例,将会得到如下输出:

context deadline exceeded

注意:错误信息发生了变化!这个错误与我们之前提到的 context 接口中的 Deadline 函数相关联。

总结

context 包为 Go 程序提供了许多重要功能。它帮助我们处理超时、取消传播等复杂操作。