Golang Context —— 基础使用篇

368 阅读18分钟

0 前言

作为 Golang 中最常见的并发控制组件之一,Context 也是我在日常工作中经常的工具。而 Context 对于许多初次接触 Golang 的开发者来说,并没有类似协程、指针、接口等更为广泛常规的概念那样容易理解和上手。

本文将简单描述 Context 的概念和功能,并讲解其基本使用方式,旨在让读者能通过这篇文章认识 Context,掌握 Context 的正确使用方式。因此,本文不会涉及深层次的原理等高级内容。

1 概念

Context,中文通常译为 上下文 。这个中文词汇在读书时代的语文、英语课中经常见到,意思是某个词或句子在文章中所在周围的语境、内容。计算机操作系统里也有上下文的概念,譬如 CPU 在切换线程时会保留线程的上下文环境,指的是某线程自身的状态、内存数据等环境信息。

以此类推,Golang 里的 Context 功能和含义也类似:

Context 本身就是那个包含了数据、信息的 上下文 ,开发者可以通过它,关联起不同的 goroutine,使这些 goroutine 共享同一个由用户设定的上下文,从而达到统一控制这些 goroutine 的目的

记住这个概念,它是 Context 的核心,也是贯穿整篇文章的核心。

所以,没错,Context 实际上就是一种用于协程间状态同步的工具。具体使用方式,后文会详解,这里先简单说一下 Context 在 Golang 中的表现形式是怎么样的。

在 Golang 标准库的 context 包中,定义了一个 Context 接口。多说一句,其实按照 Golang 官方文档的说法,这个应该叫做类型(type),但为了便于理解,本文还是习惯称之为接口(interface)。去掉注释后的接口定义如下:

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}

这个接口定义并不复杂,实现它只需要实现这四个方法即可。所以广义上讲,所有实现了 context.Context 接口的对象都可以称之为 Context,这一点也比较符合 Golang 面向接口的设计哲学。

Context 接口各个方法的含义和作用会在后文案例中同步解释,这里先按下不表

本文介绍的是 Context 的基本使用,因此只会介绍 context 标准库中已经实现的那些上下文结构体对象,不涉及自定义的,或第三方库的 Context。

下面,就来看看标准库中的上下文对象要如何使用。

2 使用

context 包提供了几个函数来创建各种内置上下文对象,以下是这些函数的签名:

package context

func Background() Context
func TODO() Context
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithCancelCause(parent Context) (ctx Context, cancel CancelCauseFunc)
func WithoutCancel(parent Context) Context
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
func WithDeadlineCause(parent Context, d time.Time, cause error) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithTimeoutCause(parent Context, timeout time.Duration, cause error) (Context, CancelFunc)
func WithValue(parent Context, key, val any) Context

看着不少,但归一下类其实也不多,譬如带 Cancel 的是一类,带 Deadline 的是一类,还有 Timeout 和 Value 的。

下面就对其中的重点和常用的几个进行讲解。

2.1 Background, TODO

这两个都不带 With,函数也没有参数,返回值都为 Context 接口,所以我将其归为了一类。而从源码上来看,这两个东西其实是一模一样的:

type backgroundCtx struct{ emptyCtx } // 由 Backgound() 返回
type todoCtx struct{ emptyCtx } // 由 TODO() 返回

type emptyCtx struct{}

func (emptyCtx) Deadline() (deadline time.Time, ok bool) {
	return
}

func (emptyCtx) Done() <-chan struct{} {
	return nil
}

func (emptyCtx) Err() error {
	return nil
}

func (emptyCtx) Value(key any) any {
	return nil
}

它俩都组合了一个叫 emptyCtx 的,空的,没有实现任何功能的上下文对象。这很奇怪,既然都是空的,那么它们的作用是什么?为什么要搞两个?

其实 Background()TODO() 的注释基本上就解释了它俩的作用,这里各自摘取一部分翻译:

// Background ...
// 它通用于 main 函数、初始化操作和测试,
// 并且作为后续传入请求的顶级(top-level) Context 对象。
func Background() Context

// TODO ...
// 当不清楚应该使用哪个 Context,或者 Context 尚不可用时
//(因为周围的函数还没有被扩展以接受一个 Context 参数),代码应该使用 context.TODO。
func TODO() Context

所以 TODO 其实是一个占位符,就和我们代码注释里的 // todo 一样,不知道用什么 Context 的时候就先用 TODO 占着,保证代码能运行,并且也体型开发者后续更换成真正需要的 Context。而 Background,当然也有一部分占位符的作用,但更多则是作为顶级 Context 对象存在。

什么叫顶级(top-level) Context?

回看上面所有创建上下文对象的函数签名,后面那一堆 With 开头的,都有一个参数 parent Context 。parent,顾名思义,就是父上下文。是否想到了树形结构的父节点?对了,实际使用时,Context 通常也是以这种树形结构串联而存在的。除了 Backgound 和 TODO,后面那些上下文对象都必须有一个父 Context。所以,当开发者创建一个有功能的 Context 对象,并且不需要有功能的父 Context 时,就会使用 Background 来作为这个对象的“背景”,这就是 Background 的意义。

那么这里的父 Context 到底是做什么的呢?别急,后文会有详细解释。

2.2 Cancel Context

紧接着 Background 和 TODO 的是三个 Cancel 相关的对象。Cancel,取消,意味着……这个上下文可以取消!哈哈,多么美妙的废话,来看看下面的几个例子就明白了。

这里会先介绍 WithCancel 和 WithCancelCause,至于 WithoutCancel,会放到下一节再来讲。

2.2.1 WithCancel

回顾一下文章开头对 Context 概念的解释,这个东西可以关联起别的 goroutine,使 goroutine 处于同一个上下文,以便统一控制。那么这个例子可以这么写:

func cancelContext() {
	ctx, cancel := context.WithCancel(context.Background())
	go cancelFoo(ctx)
	time.Sleep(3 * time.Second)
	cancel()
	// 最后 sleep 1 秒以保证 cancelFoo 能正常输出日志
	time.Sleep(1 * time.Second)
}

func cancelFoo(ctx context.Context) {
	cnt := 0 // 用于计数
	for {
		select {
		case done, open := <-ctx.Done(): // 上下文完成信号
			log.Printf("cancelFoo done: %v, done: %v, opened: %v", ctx.Err(), done, open)
		default:
			log.Printf("cancelFoo cnt: %d", cnt)
			cnt++
		}
		time.Sleep(1 * time.Second)
	}
}

代码很简单,cancelContext 函数作为主函数,使用 context.WithCancel 创建了一个上下文对象 ctx 和一个函数对象 cancel 。接着起一个协程 cancelFoo ,并将 ctx 作为参数传入。然后等待 3 秒后执行 cancel() 取消。

cancelFoo 里搞了个无限循环,然后在 select 中接收 ctx.Done() 信号,设置 default 分支以无限循环不阻塞,打印一下过了几秒(以 cnt 和休眠 1 秒的方式)。

设置无限循环的目的是为了模拟 Context 真实使用场景,本文后面的例子会简化它。

运行结果如下:

2024/12/19 14:40:52 cancelFoo cnt: 0
2024/12/19 14:40:53 cancelFoo cnt: 1
2024/12/19 14:40:54 cancelFoo cnt: 2
2024/12/19 14:40:55 cancelFoo done: context canceled, done: {}, opened: false

cancelFoo 循环了 3 秒,然后收到了 ctx.Done() 信号,打印了 ctx.Err() ,是 context canceled 。这个结果完美符合了这段代码的逻辑意义,即主函数休眠了 3 秒后,主动 cancel() ,于是子 goroutine 收到了这个取消的信号。这同样符合文章开头介绍的,Context 的作用:goroutine 的同步工具。

好了,这段代码其实也顺道解释了 Context 接口的两个方法:

type Context interface {
	//	...
	Done() <-chan struct{}
	Err() error
}

至少在上面的例子里我们可以看到,Done() 就是以关闭 chan struct{} 的方式,通知上下文已完成,Err() 则告知了完成的原因。

2.2.2 WithCancelCause

这个东西和 Cancel 用法一样,唯一的区别是创建 Context 时返回的 Cancel 函数多了一个参数:

func WithCancelCause(parent Context) (ctx Context, cancel CancelCauseFunc)
type CancelCauseFunc func(cause error) // 多了个 cause 参数,类型是 error

同样用一个类似的例子来讲解:

func cancelCauseContext() {
	ctx, cancel := context.WithCancelCause(context.Background())
	go cancelCauseFoo(ctx)
	time.Sleep(3 * time.Second)
	cancel(errors.New("cancel for some reason")) // @1 cancel 传入 error 参数
	time.Sleep(1 * time.Second)
}

func cancelCauseFoo(ctx context.Context) {
	cnt := 0
	for {
		select {
		case done, open := <-ctx.Done():
			// @2 通过 context.Cause(ctx) 打印 cause 内容
			log.Printf("cancelFoo done: %v, cause: %v, done: %v, opened : %v",
				ctx.Err(), context.Cause(ctx), done, open) 
		default:
			log.Printf("cancelFoo cnt: %d", cnt)
			cnt++
		}
		time.Sleep(1 * time.Second)
	}
}

这段代码的区别有两个,已在代码中用 @1 和 @2 标注。结果如下:

2024/12/19 15:39:29 cancelFoo cnt: 0
2024/12/19 15:39:30 cancelFoo cnt: 1
2024/12/19 15:39:31 cancelFoo cnt: 2
2024/12/19 15:39:32 cancelFoo done: context canceled, cause: cancel for some reason, done: {}, opened : false

可以看到,通过 context.Cause(ctx) ,打印出了主函数里 cancel 时传入的 error 内容。

2.3 父 Context

借着 Cancel Context,讲一下前面提到的父 Context。

Golang 中的 Context 支持一种树形结构,由子节点关联到父节点,也就是 WithXXX 函数的 parent 参数。而一般情况下,父 Context 的完成行为会同时影响子 Context,使它们做出同样的行为。以上文 Cancel 为例,父 Context 执行了 cancel 后,子 Context 的 cancel 会被连带执行。下面是例子:

func cancelParentContext() {
	// 根节点 Context
	rootCtx, rootCancel := context.WithCancel(context.Background())
	// 子节点 Context,继承根节点 Context,不获取 cancel 函数
	ctx, _ := context.WithCancel(rootCtx)
	// 孙子节点 Context,继承子节点 Context,不获取 cancel 函数
	ctx2, _ := context.WithCancel(ctx)
	// 将子节点和孙子节点的 ctx 传入 goroutine 函数中
	go cancelParentFoo(ctx, 1)
	go cancelParentFoo(ctx2, 2)
	time.Sleep(3 * time.Second)
	rootCancel()
	time.Sleep(1 * time.Second)
}

func cancelParentFoo(ctx context.Context, i int) {
	// 这个函数简化了,阻塞等待,然后打印信息,本质上和前面那些是一样的
	done, open := <-ctx.Done()
	log.Printf("cancelFoo %d done: %v, done: %v, opened : %v", i, ctx.Err(), done, open)
}

cancelParentFoo 函数我做了简化,不再循环等待了,节省一些篇幅。另外,子节点和孙子节点 Context 创建时,都没有获取 cancel 函数,这么写是为了让本案例更清晰,实际上这样会使得编译器给你一个警告:

the cancel function returned by context.WithCancel should be called, not discarded, to avoid a context leak

意思是,cancel 无论如何应该被调用,而不是抛弃,不然可能会触发上下文泄露。警告中给了一个官方链接:pkg.go.dev/golang.org/…,有兴趣的可以看看,这里不展开。总之生产上使用时,记得执行 cancel,可以像关闭连接一样用 defer cancel()

上面代码的输出:

2024/12/19 15:57:39 cancelFoo 2 done: context canceled, done: {}, opened : false
2024/12/19 15:57:39 cancelFoo 1 done: context canceled, done: {}, opened : false

可以看到,当 rootCtxrootCancel 被执行时,子节点和孙子节点的 Context 都收到了 ctx.Done() 信号,它们都被关闭了。这就是 Context 的一大特性:父 Context 影响其下面的所有子 Context。但反过来不成立,即子 Context 的取消无法影响父 Context,大家可以改一下上面的代码自己试一试。

2.3.1 WithoutCancel

有了父 Context 的知识,就可以来看看这个奇怪的 WithoutCancel 了。

其实从前面的函数签名可以看到,WithoutCancel() 只返回了一个 Context,没有第二个 cancel 返回值。所以,这个东西它不能自主 cancel。当然这并不是 withoutCancel 的全部特性,它最大的特点是:当父 Context cancel 时,它不受影响,不会执行 cancel

为什么?看看这个对象的两个方法定义:

type withoutCancelCtx struct {
	c Context
}

func (withoutCancelCtx) Done() <-chan struct{} {
	return nil
}

func (withoutCancelCtx) Err() error {
	return nil
}

看吧,这俩方法它就没有实现!所以无论父 Context 们怎么 cancel,这个对象都不会给使用者任何反馈。

2.4 Deadline Context

Deadline Context 有两个创建函数 WithDeadlineWithDeadlineCause 。后者和前面 Cancel Context 的 Cause 一样,不重复讲了,这里只介绍 WithDeadline 的使用方式。

Deadline,通常翻译成截止时间、期限,意味着这个 Context 会有一个时间期限,达到这个期限就视为超时,然后触发取消动作,并被 Done() 感知到。我们依然先通过一个例子来了解它的用法,代码如下:

func deadlineContext() {
	// 3 秒后超时
	deadline := time.Now().Add(3 * time.Second)
	// 第二个参数同样会返回 cancel,这里为了减少干扰项,没有获取和调用它,
	// 生产代码中记得同样要 defer cancel() 一下。
	ctx, _ := context.WithDeadline(context.Background(), deadline)

	log.Println("Start goroutine")
	go deadlineContextFoo(ctx)
	// 等待 4 秒,和 time.Sleep(4 * time.Second) 效果相同
	<-time.After(4 * time.Second)
	log.Println("Timeout")
}

func deadlineContextFoo(ctx context.Context) {
	select {
	case <-time.After(5 * time.Second): // 5 秒后触发这个 case
		log.Println("Foo after 5 seconds")
	case <-ctx.Done(): // Context 完成
		log.Printf("Foo done: %v", ctx.Err())
		// 打印一下 ctx 的 deadline 信息
		deadline, ok := ctx.Deadline()
		log.Printf("Deadline: %v, ok: %v", deadline, ok)
	}
}

WithDeadline 函数相比前面的 WithCancel ,多传入了一个 time.Time 类型的参数,作为“期限”。注意了,time.Time 是一个时间点,而不是 time.Duration 时间段。所以上面代码里使用了当前时间加上 3 秒,也就是 3 秒后的 time.Time,即这个期限只有 3 秒,程序运行至 3 秒后的时间点时,就达到了 deadline,判定超时过期。

主函数调用了子 goroutine deadlineContextFoo 后,等待 4 秒,deadlineContextFoo 自身用 select 匹配两个分支,一个是 5 秒后触发,一个是 ctx 完成时触发。打印结果如下:

2024/12/19 17:26:20 Start goroutine
2024/12/19 17:26:23 Foo done: context deadline exceeded
2024/12/19 17:26:23 Deadline: 2024-12-19 17:26:23.4388756 +0800 CST m=+3.003204201, ok: true
2024/12/19 17:26:24 Timeout

可以看到,deadlineContextFoo 中,Start goroutine 打印 3 秒后,select 匹配到了上下文 done 的事件。因为主函数等待了 4 秒后才打印 Timeout,所以日志上能看到 Timeout 晚了 Foo done 这行日志 1 秒。这符合这段代码的行为逻辑。

打印了 Foo done 后,代码通过 ctx.Deadline 取出了在主函数中设置的 deadline 信息,所以,这里我们又解锁了一个 Context 接口的方法:

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	...
}

它返回了两个值:截止时间点,是否设置了 deadline

最后有一点要多解释一嘴,WithDeadline 依然会返回一个 cancel 函数作为第二个返回值,并且这个 cancel 依然有用。也就是说,如果我们在 deadline 之前就 cancel 了,那么子函数中的 ctx.Done() 同样会触发,但此时打印的 ctx.Err() 就是 context canceled 了,和前面的 Cancel Context 一模一样。各位有兴趣的可以试一试。所以我们其实可以将 Deadline Context 看作是一个带截止日期的特殊的 Cancel Context,而那个 ctx.Done() 则可以完全理解为 Context 取消的信号。

2.5 Timeout Context

和 Deadline 一样,Timeout Context 也是超时取消,这点从名字上就能看出来。并且相比于 Deadline,Timeout Context 可能更加常见,也更常用,毕竟 timeout 的使用场景相对会更多一些。

就不给完整例子了,因为用法上 Timeout 和 Deadline 几乎完全一样,唯一的区别在于 WithTimeout 的参数:

timeout := 3 * time.Second
ctx, cancel := context.WithTimeout(context.Background(), timeout)

创建函数的第二个参数不再接收一个时间点,而是一个 time.Duration 。这里传入了 3 秒,其效果和前面 Deadline 案例是一样的。

如果我们看一下 WithTimeout 的源码,就明白了:

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
	return WithDeadline(parent, time.Now().Add(timeout))
}

嗯,根本没有什么 WithTimeout ,它只是一个 WithDeadline 的封装入口,或是语法糖。Golang 之所以专门写一个这样的函数,可能也正如前文所说,Timeout 的使用场景会更多,这种写法也更直观,可读性更高一些。

2.6 Value Context

Value Context 相对于上面那些,有些特殊,首先它只有一个创建函数:

func WithValue(parent Context, key, val any) Context

而除了 parent 之外,这个函数还接收两个 any 类型的参数:key, val ,但返回值只有一个 Context。这就得分开说了。

首先,它叫 Value Context,和 Value 有关,所以接收一个 key 和 value,存着,供别人调用,通过 key 来获取 value。有没有想到什么?对了,就是 Context 接口的最后一个方法:

type Context interface {
	...
	Value(key any) any
}

Value Context 实现了这个 Value 方法,而这也是它实现的唯一接口方法,剩下的都默认使用 parent Context 的方法。来看下定义:

type valueCtx struct {
	Context
	key, val any
}

func (c *valueCtx) Value(key any) any {
	if c.key == key {
		return c.val
	}
	// ...skip
}

对吧,非常清晰了,Value Context 就是干这个的!

由于它没有实现其他任何 Context 接口的方法,所以它无法主动取消,创建时也就没有返回第二个 cancel 函数,取消之类的功能完全依赖于父 Context,Value Context 本身就是个 k-v 存储介质。

来看一个例子:

func valueContext() {
	// 根节点 Context,设置了一个 hello=world 的键值对
	ctx := context.WithValue(context.Background(), "hello", "world")
	val := ctx.Value("hello")
	log.Printf("hello: %+v", val)

	// 子节点 Context,继承根节点 Context,设置了一个 hello=world2 的键值对
	ctx2 := context.WithValue(ctx, "hello", "world2")
	val2 := ctx2.Value("hello")
	log.Printf("hello in parent: %+v, hello in self: %+v", val, val2)

	// 孙子节点 Context,继承子节点 Context,设置了一个 hello3=world3 的键值对
	ctx3 := context.WithValue(ctx2, "hello3", "world3")
	// 从孙子节点 Context 中获取 hello 和 hello3 的值
	val = ctx3.Value("hello")
	val3 := ctx3.Value("hello3")
	log.Printf("hello: %+v, hello3: %+v", val, val3)
}

这段代码创建了三个 Value Context,逐级继承,然后通过 Value 取值,并打印。结果如下:

2024/12/19 18:55:04 hello: world
2024/12/19 18:55:04 hello in parent: world, hello in self: world2
2024/12/19 18:55:04 hello: world2, hello3: world3

这里我们可以注意到几个点:

  1. 子 Context 通过 Value 获取一个 key 的 val 时,如果自己没有,则会去向上(父级)查找;
  2. 子节点如果创建时使用了和父 Context 一样的 key,则在子 Context 中的 key 对应的值会覆盖父 Context 的值;
  3. 子 Context 覆盖值时,父 Context 自己的值不受影响;

Value Context 的用法基本就是这样。

但还没完。

2.6.1 key 碰撞问题

如果你在编译器里 copy 了上面的代码,你可能会发现编译器又给你警告了(在我的 vscode 里是的):

should not use built-in type string as key for value; define your own type to avoid collisions (SA1029)

不应使用内置字符串类型作为值的键;应定义自己的类型以避免碰撞(SA1029)

这又是啥?

这是 Staticcheck 关于 Golang 的一个规范。这里它建议我们不要使用 Golang 内置类型作为 Value Context 的 key,而应当使用用户自定的类型。譬如上面的代码我们这么改就没问题了:

type myString string

func valueContext() {
	// 根节点 Context,设置了一个 hello=world 的键值对
	ctx := context.WithValue(context.Background(), myString("hello"), "world")
	val := ctx.Value(myString("hello"))
	log.Printf("hello: %+v", val)
	
	// ...skip
}

这里定义了一个自定义类型 myString 作为 string 的别名,然后用 myString("hello") 当作 key 传给 WithValue ,编译器就不会告警了。

为什么?

其实这样做的目的是为了避免同一个 key 的 val 被覆盖掉的情况。我个人的理解是,比如在一个大型项目中,有一个公共模块,它提供了这么一个 Value Context 对象,而这个对象可能会被很多业务模块传递,使用。如果此时,不同的业务模块因为各自的逻辑,为 Value Context 对象设置了同一个 key,并且都使用了同样的基础类型,会使得该对象在不同模块间流转时这个 key 的 val 一直在变,但业务模块本身其实不知道其他业务模块会怎么使用这个 key,这样一来,就可能会导致一些不可预知的问题。譬如你的业务逻辑因为覆盖了上游一个业务设置的 key,导致下游的某个业务模块发生了异常,因为它需要上游的那个 key 的 val。而如果每个业务都使用各自自定义的类型,则可以很大程度避免上述问题。

在前面的例子里,如果我们使用了 myString("hello") ,那么 myString("hello") 作为 key 就不会覆盖掉 "hello" 的值,因为 myString("hello")"hello" 会被视为不同的类型。当然,取值也得用 myString("hello") 。代码就不贴了,大家可以自己试试看。

3 结语

本文篇幅较长,详细介绍了几乎所有 context 包中各种内置 Context 对象的基本用法,并循序渐进地解释了 Context 接口的四个方法的含义和作用。

本来一开始没有打算贴 context 相关的源码,但一些地方为了便于大家理解,还是贴了点浅显易懂的。

接下来,我会在下一篇文章中深入 context 的源码,为大家讲解 context 的底层原理。