Golang Context —— 基础使用篇

阅读:438

发布时间:2024年12月23日 20:49

golang
# 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)**。去掉注释后的接口定义如下: ```go 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 包提供了几个函数来创建各种内置上下文对象,以下是这些函数的签名: ```go 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 接口,所以我将其归为了一类。而从源码上来看,这两个东西其实是一模一样的: ```go 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()` 的注释基本上就解释了它俩的作用,这里各自摘取一部分翻译: ```go // 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 处于同一个上下文,以便统一控制。那么这个例子可以这么写: ```go 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 真实使用场景,本文后面的例子会简化它。 运行结果如下: ```go 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` 接口的两个方法: ```go type Context interface { // ... Done() <-chan struct{} Err() error } ``` 至少在上面的例子里我们可以看到,`Done()` 就是以关闭 `chan struct{}` 的方式,通知上下文已完成,`Err()` 则告知了完成的原因。 ### 2.2.2 WithCancelCause 这个东西和 Cancel 用法一样,唯一的区别是创建 Context 时返回的 Cancel 函数多了一个参数: ```go func WithCancelCause(parent Context) (ctx Context, cancel CancelCauseFunc) type CancelCauseFunc func(cause error) // 多了个 cause 参数,类型是 error ``` 同样用一个类似的例子来讲解: ```go 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 标注。结果如下: ```go 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 会被连带执行。下面是例子: ```go 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 函数,这么写是为了让本案例更清晰,实际上这样会使得编译器给你一个警告: ```go the cancel function returned by context.WithCancel should be called, not discarded, to avoid a context leak ``` 意思是,cancel 无论如何应该被调用,而不是抛弃,不然可能会触发上下文泄露。警告中给了一个官方链接:[https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/lostcancel](https://pkg.go.dev/golang.org/x/tools/go/analysis/passes/lostcancel),有兴趣的可以看看,这里不展开。总之生产上使用时,记得执行 cancel,可以像关闭连接一样用 `defer cancel()` 。 上面代码的输出: ```go 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 ``` 可以看到,当 `rootCtx` 的 `rootCancel` 被执行时,子节点和孙子节点的 Context 都收到了 `ctx.Done()` 信号,它们都被关闭了。这就是 Context 的一大特性:**父 Context 影响其下面的所有子 Context。但反过来不成立,即子 Context 的取消无法影响父 Context**,大家可以改一下上面的代码自己试一试。 ### 2.3.1 WithoutCancel 有了父 Context 的知识,就可以来看看这个奇怪的 `WithoutCancel` 了。 其实从前面的函数签名可以看到,`WithoutCancel()` 只返回了一个 Context,没有第二个 cancel 返回值。所以,这个东西它不能自主 cancel。当然这并不是 withoutCancel 的全部特性,它最大的特点是:**当父 Context cancel 时,它不受影响,不会执行 cancel**。 为什么?看看这个对象的两个方法定义: ```go 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 有两个创建函数 `WithDeadline` 和 `WithDeadlineCause` 。后者和前面 Cancel Context 的 Cause 一样,不重复讲了,这里只介绍 `WithDeadline` 的使用方式。 Deadline,通常翻译成截止时间、期限,意味着这个 Context 会有一个时间期限,达到这个期限就视为超时,然后触发取消动作,并被 `Done()` 感知到。我们依然先通过一个例子来了解它的用法,代码如下: ```go 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` 完成时触发。打印结果如下: ```go 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` 接口的方法: ```go 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` 的参数: ```go timeout := 3 * time.Second ctx, cancel := context.WithTimeout(context.Background(), timeout) ``` 创建函数的第二个参数不再接收一个时间点,而是一个 `time.Duration` 。这里传入了 3 秒,其效果和前面 Deadline 案例是一样的。 如果我们看一下 `WithTimeout` 的源码,就明白了: ```go 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 相对于上面那些,有些特殊,首先它只有一个创建函数: ```go func WithValue(parent Context, key, val any) Context ``` 而除了 parent 之外,这个函数还接收两个 `any` 类型的参数:`key, val` ,但返回值只有一个 Context。这就得分开说了。 首先,它叫 Value Context,和 Value 有关,所以接收一个 key 和 value,存着,供别人调用,通过 key 来获取 value。有没有想到什么?对了,就是 `Context` 接口的最后一个方法: ```go type Context interface { ... Value(key any) any } ``` Value Context 实现了这个 `Value` 方法,而这也是它实现的唯一接口方法,剩下的都默认使用 parent Context 的方法。来看下定义: ```go 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 存储介质。 来看一个例子: ```go 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 取值,并打印。结果如下: ```go 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 里是的): ```go 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,而应当使用用户自定的类型。譬如上面的代码我们这么改就没问题了: ```go 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 的底层原理。