在 Go 语言并发编程中,context 包扮演着至关重要的角色。Golang context 提供了一种在 goroutine 之间传递信号、截止日期和值的机制,简化了并发程序的管理和错误处理。 这篇文章将深入探讨 Go context 的概念、使用方法、最佳实践以及常见问题解答,帮助你更好地理解和运用这一强大的工具。

什么是 Go context?

Context,中文译为"上下文",在 Go 语言中代表 goroutine 的执行环境。 它可以携带截止日期、取消信号以及其他与请求相关的值,用于控制 goroutine 的生命周期和行为。

为什么需要 Context?

在并发程序中,经常会遇到以下情况:

  • 客户端取消请求: 例如,用户在浏览器中关闭页面或中断下载。
  • 超时控制: 防止 goroutine 无限期地阻塞,例如等待网络请求或数据库查询。
  • 跨 goroutine 传递信息: 例如,传递请求 ID、用户身份等信息。

如果没有 Context,处理这些情况会变得非常复杂。 Context 提供了一种优雅而统一的方式来管理这些挑战。

Golang context 的核心方法

context 包提供了几个核心方法:

  • context.Background(): 创建一个空的 Context,作为所有其他 Context 的根节点。
  • context.WithCancel(parent Context): 创建一个可取消的 Context。 返回一个新的 Context 和一个取消函数 cancel()。调用 cancel() 函数会向该 Context 及其子 Context 发送取消信号。
  • context.WithDeadline(parent Context, deadline time.Time): 创建一个带有截止日期的 Context。 当到达截止日期时,Context 会自动取消。
  • context.WithTimeout(parent Context, timeout time.Duration): 创建一个带有超时的 Context。 当超时时间到达时,Context 会自动取消。
  • context.WithValue(parent Context, key, value interface{}): 创建一个携带键值对的 Context。 用于在 goroutine 之间传递信息。

Context 在 Golang 编程中的使用场景

1. 通过 Context 上下文取消 Goroutine

使用 context.WithCancel() 创建可取消的 Context,在需要时调用 cancel() 函数取消 goroutine 的执行。

什么场景下会需要取消 Goroutine?

HTTP 服务器调用数据库并将查询的数据返回给客户端是一个常见业务场景,但如果客户端在中途取消请求会怎样?例如,如果客户端在请求中途关闭浏览器。此时,我们应该立即取消后续的执行处理,以防止我们的系统做不必要的工作。

上下文取消有两个方面:

  1. 监听取消事件
  2. 发出取消事件

Context 监听取消事件

Context 类型提供了一个 Done() 方法。每次上下文收到取消事件时,这个方法都会返回一个接收空 struct{} 类型的 channel。

因此,要监听取消事件,我们需要等待 <- ctx.Done()

例如,假设一个 HTTP 服务器需要两秒钟来处理一个事件。如果请求在此之前被取消,我们希望立即返回:

package main

import (
	"fmt"
	"net/http"
	"os"
	"time"
)

func main() {
	// 创建一个监听 8000 端口的 HTTP 服务器
	http.ListenAndServe(":8000", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()
		// 这会打印到 STDOUT 以表明处理已经开始
		fmt.Fprint(os.Stdout, "processing request\n")
		// 我们使用 select 来处理事件,事件取决于哪个通道先接收到消息
		select {
		case <-time.After(2 * time.Second):
			// 如果我们在2秒后收到消息,表示请求已经被处理
			w.Write([]byte("request processed"))
		case <-ctx.Done():
			// 如果请求被取消,将其记录到STDERR
			fmt.Fprint(os.Stderr, "request cancelled\n")
		}
	}))
}

你可以通过运行服务器并在浏览器上打开 https://localhost:8000 来对此进行测试。如果你在 2 秒之前关闭浏览器,你应该会在终端窗口上看到“request cancelled”字样。

发出 Context 取消事件

可以通过上下文发出取消事件完成取消操作,这可以通过使用 context 包中的 WithCancel 函数来完成,它返回一个上下文对象和一个函数。

ctx, fn := context.WithCancel(ctx)

返回的这个函数不接受任何参数,也不返回任何内容,当你需要取消上下文时就调用它。下面是一个示例

package main

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

func operation1(ctx context.Context) error {
	// 假设这个操作由于某种原因失败了返回 error
	// 我们使用 time.Sleep 来模拟资源密集型操作
	time.Sleep(100 * time.Millisecond)
	return errors.New("failed")
}

func operation2(ctx context.Context) {
	// 我们使用与前面示例中看到的 HTTP 服务器类似的模式
	select {
	case <-time.After(500 * time.Millisecond):
		fmt.Println("done")
	case <-ctx.Done():
		fmt.Println("halted operation2")
	}
}

func main() {
	// 创建一个新的上下文
	ctx := context.Background()
	// 创建一个新的上下文,它的取消函数来自于原来的上下文
	ctx, cancel := context.WithCancel(ctx)

	// 运行两个操作:一个在不同的 goroutine 中
	go func() {
		err := operation1(ctx)
		// 如果此操作返回错误,则取消使用此上下文的所有操作
		if err != nil {
			cancel()
		}
	}()

	// operation2使用与operation1相同的上下文
	operation2(ctx)
}

运行代码,输出结果:

halted operation2

2. 设置 Context 上下文超时

使用 context.WithTimeout() 为 HTTP 请求或数据库查询设置超时时间,防止 goroutine 无限期地阻塞。

API 与前面的示例几乎相同,只是增加了一些内容:

// 该上下文将在3秒后取消
// 如果需要提前取消,可以像前面一样使用' cancel '函数
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)

// 设置上下文 Deadline 与设置超时类似,只是指定了希望上下文取消的具体时间点,而不是指定时长。
// 此处,上下文将在2009-11-10 23:00:00被取消
ctx, cancel := context.WithDeadline(ctx, time.Date(2009, time.November, 10, 23, 0, 0, 0, time.UTC))

例如,对外部的 HTTP API 进行调用时,如果耗时太长,最好提前失败并取消请求:

package main

import (
	"context"
	"fmt"
	"net/http"
	"time"
)

func main() {
    // 创建一个新的上下文
    // 截止时间为 100 毫秒
	ctx := context.Background()
	ctx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
	defer cancel()

    // 发出请求,调用 blog.axiaoxin.com 博客主页
	req, _ := http.NewRequest(http.MethodGet, "https://blog.axiaoxin.com", nil)
    // 将我们刚刚创建的可取消上下文关联到请求
	req = req.WithContext(ctx)

    // 创建一个新的 HTTP 客户端并执行请求
	client := &http.Client{}
	res, err := client.Do(req)
    // 如果请求失败,记录到 STDOUT
	if err != nil {
		fmt.Println("Request failed:", err)
		return
	}
    // 请求成功打印状态码
	fmt.Println("Response received, status code:", res.StatusCode)
}

根据博客主页对你请求的响应速度,你将收到:

Response received, status code: 200

Request failed: Get https://blog.axiaoxin.com: context deadline exceeded

3. 使用 Context 上下文传值

使用 context.WithValue() 传递请求 ID、用户身份等信息,方便在不同的 goroutine 中访问。

你可以使用上下文变量来传递通用的值。这是比在所有函数调用中将它们作为变量传递的更惯用方法。

例如,考虑一个具有多个函数调用的操作,使用一个公共 ID 来标识它以进行日志记录和监控。

实现这个的最简单的方法是为每个函数调用传递 ID:

func main() {
    // 创建一个随机整数作为 ID
	rand.Seed(time.Now().Unix())
	id := rand.Int63()
	operation1(id)
}

func operation1(id int64) {
	// do some work
	log.Println("operation1 for id:", id, " completed")
	operation2(id)
}

func operation2(id int64) {
	// do some work
	log.Println("operation2 for id:", id, " completed")
}

运行代码,输出:

2009/11/10 23:00:00 operation1 for id: 767100843235198854  completed
2009/11/10 23:00:00 operation2 for id: 767100843235198854  completed

为什么 Go 的 rand 包中返回一个 64 位整数时称为 Int63

Int63 方法从默认 Source 返回一个非负伪随机 63 位整数作为 int64。

int64 是 64 位有符号整数类型。它包含 1 个符号位和 63 个有效位。

因此任何返回非负 int64 都会产生 63 位数据(第 64 位,符号位,将始终具有相同的值)。

当你想要传递更多信息时,参数很快就会变得臃肿。

我们可以使用上下文实现相同的功能:

// 我们需要设置一个键来告诉我们数据存储在哪里
const keyID = "id"

func main() {
	rand.Seed(time.Now().Unix())
	ctx := context.WithValue(context.Background(), keyID, rand.Int63())
	operation1(ctx)
}

func operation1(ctx context.Context) {
	// do some work

    // 我们可以通过传入键从上下文中获取值
	log.Println("operation1 for id:", ctx.Value(keyID), " completed")
	operation2(ctx)
}

func operation2(ctx context.Context) {
	// do some work

    // 相同的 ID 从一个函数调用传递到下一个函数调用
	log.Println("operation2 for id:", ctx.Value(keyID), " completed")
}

使用上下文变量传递信息很有用,原因有很多:

  • 它是线程安全的:上下文键的值一旦设置就无法修改。为给定键设置另一个值的唯一方法是使用 context.WithValue 创建另一个上下文变量
  • 它是传统手艺:context 包在整个 Go 的官方库和应用程序中使用,以传递操作范围的数据。其他开发人员和库通常可以很好地使用这种模式。

陷阱和注意事项

使用 WithTimeoutWithCancel 包装一个可取消的上下文将会使代码中的多个位置可以取消上下文,应该避免这种情况。

Go context 最佳实践

  • Context 应该作为函数的第一个参数传递:这有助于确保 Context 在整个调用链中可用。
  • 不要将 Context 存储在结构体中:Context 应该作为参数传递,而不是存储在结构体中。
  • 使用 context.Background() 作为根 Context:所有其他 Context 都应该从 context.Background() 派生。
  • 不要使用 nil 作为 Context:如果函数需要 Context,则应始终传递一个有效的 Context。
  • 使用 context.WithValue() 时要注意键的类型:使用自定义类型作为键可以避免键冲突。
  • 避免在context中存储过多数据:只存储少量、请求范围的数据,如追踪 ID。
  • 优先使用常量作为 key:避免 key 冲突,确保代码的可读性。
  • 及时调用cancel函数:创建context后应及时调用cancel函数以释放资源。
  • 传递给涉及外部资源的函数:仅在可能阻塞或长时间运行的操作中使用context

Golang context 常见问题解答(FAQ)

在 Golang 中,context包主要用于管理请求的生命周期,特别是在处理并发任务、控制超时和取消操作时,context非常重要。下面整理了开发者在使用context时常见的问题和解答。

1. 什么是 Golang 中的context,它的主要用途是什么?

回答: Golang 中的context包提供了一个Context类型,用于在多个 Go 协程之间传递截止时间、取消信号和请求范围的数据。其主要用途包括:

  • 控制请求的生命周期(尤其适合 HTTP 请求的处理)。
  • 管理超时、取消操作,避免资源泄露。
  • 在 API 之间传递请求范围的元数据,如身份验证信息、追踪 ID 等。

2. context是如何避免 Goroutine 泄露的?

回答: context通过在请求结束或超时时自动触发取消信号,从而终止所有使用该上下文的 Goroutine,避免了资源泄露。例如,在数据库查询或 API 请求中设置context,当请求超时或用户取消时,相关的协程也会随之终止。

3. 如何创建一个带超时的context

回答: 可以使用context.WithTimeout函数来创建一个带超时的上下文。例如:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
// 使用ctx来控制请求,5秒后自动取消

在此例中,ctx将在 5 秒后自动取消,因此适用于一些耗时较长且需要控制的操作。

4. context.Background()context.TODO()有什么区别?

回答:

  • context.Background():通常作为根context使用,用于整个应用的初始化和顶层的请求处理。
  • context.TODO():多用于代码的占位符,表示开发者尚未确定具体的上下文或未来将添加适当的上下文。

5. 该如何选择context.WithCancelcontext.WithTimeoutcontext.WithDeadline

回答:

  • context.WithCancel:适用于可以手动取消的操作,通常与 API 或服务请求协作,取消请求会传播到所有子context
  • context.WithTimeout:适合需要在一定时间内完成的任务,会在超时后自动取消。
  • context.WithDeadline:设置一个具体的截止时间点,当时间到达后自动取消上下文,适合任务有精确截止时间的情况。

6. 在并发编程中如何使用context传递数据?

回答: context并不是为传递数据设计的,而是用来控制取消信号和超时。因此,context应仅用于传递少量、请求范围内的数据(如请求 ID),避免将大量数据放在context中。可以使用context.WithValue传递特定的值,例如:

ctx := context.WithValue(context.Background(), "userID", 1234)

应避免频繁使用context.WithValue传递复杂数据,因为这会降低代码的可读性。

7. context在多线程环境中是否安全?

回答: 是的,context是线程安全的。多个 Goroutine 可以安全地共享和传递同一个context。它的只读特性保证了在并发情况下不会发生竞态条件。

8. context是否支持嵌套?

回答: 支持。一个context可以衍生出多个子context,子context会继承父context的取消、超时和截止日期。嵌套结构的设计使得可以在不同的协程中控制上下文的生命周期。例如:

parentCtx := context.Background()
childCtx, cancel := context.WithCancel(parentCtx)
defer cancel()

在这个例子中,childCtx继承了parentCtx的属性。

9. 如何从context中提取数据?

回答: 可以使用ctx.Value(key)提取数据,其中key可以是任意类型的值。需要注意的是,不建议将context当作全局变量或全局状态来使用,只应传递少量与请求相关的数据。例如:

userID := ctx.Value("userID")

如果key不存在,返回的值将为nil

10. context是否应该传递到每一个函数?

回答: 不一定。对于简单的函数或不涉及外部资源的函数,不需要传递contextcontext更适合传递给涉及外部资源(如数据库、网络请求等)的函数中,以便在需要时可以控制超时或取消操作。

11. 如何检测一个context是否被取消?

回答: 使用context.Done()通道可以检测是否取消。如下所示:

select {
case <-ctx.Done():
    fmt.Println("Context cancelled")
default:
    fmt.Println("Context active")
}

Done()通道被关闭时表示context已取消或超时,Goroutine 可以安全退出。

总结

Go context 是 Go 语言并发编程中不可或缺的一部分。它提供了一种优雅的方式来管理 goroutine 的生命周期、传递信息和处理错误。通过理解 Context 的概念、使用方法和最佳实践,可以编写更健壮、更易于维护的并发程序。


也可以看看