逻辑清晰地梳理Golang Context

推荐转到语雀阅读 www.yuque.com/anthonyzhao...

🔖Go版本:go1.25.3 windows/amd64

👉源码路径:src\context\context.go

写作背景

从学习golang开始就打算完整梳理一下上下文的设计,但是从网上看到的文章感觉写的很没有逻辑(应该是我没看到条理清晰的)。

陪产假期间,也就是本文撰写期间,leader 分享了一本Golang书籍给我,想着阅读后总要有些产出才是,于是参考书中的Context部分以及源码,写了本文,自认为逻辑算是清晰。

本文的出发点在于理清Context的设计及使用案例,重心不在实现细节。

Context 接口

context接口设计非常简单,仅关注上下文本身的功能:

  • Deadline()你可以理解为上下文具有生效时间,而Deadline返回的是上下文失效的时间。
  • Done() 是用来阻塞等待上文失效的,当上下文失效后,可立刻感知到。无需采用轮询的方式。
  • Err() 获取上下文失效的原因。
  • Value()获取上下文携带的kv值。

接口不麻烦,宏观上看,其实上下文就提供了两个功能

  • 支持失效,包括随时失效和定时失效;失效原因相关功能。
  • 在传递路径上携带kv值

每个功能都是派生实现的。

最基础的Ctx

源码中对于context接口的直接实现只有 emtpyCtx, 我们常用的 backgroundCtx 和 todoCtx 则是组合了 emptyCtx。由于Golang没有集成,这对于Golang来说也算是间接实现了 context 接口。

从上图来看,backgroundCtx 和 todoCtx 似乎没有任何区别,完全与 emptyCtx 等价,但其使用场景是不同的

  • todoCtx: 常用于用作待办,即当下是想传递上下文的,但是不明确传递哪个上下文更好,也可以理解为是占位用的。
  • backgroundCtx:通常用作根上下文使用(上下文是一个树形结构,后边会详细解释)

这里贴一下 emptyCtx 的实现

go 复制代码
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 虽然实现了 context 接口,但是接口中定义的功能完全没有实现:不具有失效时间、不会失效、不能传递值。

事实上 Golang 上下文的完整功能是通过不同的 结构体来实现的,我们接下来慢慢了解。

valueCtx

从类图角度valueCtx组合了context接口。Golang为valueCtx单独提供了一个构造函数

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

调用该构造函数后会返回一个新的context实例,新的实例包含父context实例。

从类图中可以看到,valueCtx 相当于是重写了 Value() 方法,根据Go语言特性,调用 Value() 方法时,真正使用的是 valueCtx 的实现;而调用其他三个方法时则会使用 组合context 的实现(即,父context实例的实现)

go 复制代码
package main

import (
	"context"
	"fmt"
)

func main() {
	ctx := context.Background()
	ctx = context.WithValue(ctx, "key", "anthonyzhao")
	fmt.Println(ctx.Value("key"))
}

树形结构的context

构造 ValueCtx 时,Golang提供了一个构造器,生成一个新的实例。事实上,对于上下文的每一个功能,Golang都提供了构造器。构造器中都会传入父context实例。还是以 ValueCtx 为例,如果多次调用,就会形成一颗context树,但是是指向父节点的树。树种的节点可以是各种直接、间接实现ctx的实例,包括但不限于 backgroundCtx、ValueCtx。

思考一下:Context 是如何获取上游实例设置的KV值的

当然是通过对树的遍历啦,每个节点都持有对父节点的指针,查找kv值时向上遍历即可。

cancelCtx

cancelCtx 结构

这里首先给出 cancelCtx 的代码结构,与以往类似都组合了 context 接口,可以理解为树形结构中的父节点。

还可以看到,cancelCtx 中还包含了 children,从字面理解应该是一个 context 树中当前节点的子节点集合。但是,奇怪的是为什么不是context的集合,而是 canceler 的集合。这就要从 cancelCtx 的设计本意来理解了。

go 复制代码
// A cancelCtx can be canceled. When canceled, it also cancels any children
// that implement canceler.
type cancelCtx struct {
	Context

	mu       sync.Mutex            // 保护如下的字段,考虑并发的场景
	done     atomic.Value          // chan struct{}, 懒创建,首次调用cancel()时关闭
	children map[canceler]struct{} // 调用 cancel() 后设置为 nil
	err      atomic.Value          // 调用 cancel() 后设置具体值
	cause    error                 // 调用 cancel() 后设置具体值
}

注意:注释中说当前上下文实例被取消时,其子节点实例均会被取消。

通过接口的设计可以分析出上下文是支持失效的,cancelCtx的设计目的是让上下文具有**随时可以失效**的能力。这种能力被封装为了一个接口,就是 canceler。

go 复制代码
// A canceler is a context type that can be canceled directly. The
// implementations are *cancelCtx and *timerCtx.
type canceler interface {
	cancel(removeFromParent bool, err, cause error)
	Done() <-chan struct{}
}

那为什么要单独搞一个接口呢?而不是让context接口也具有随时取消的能力?

除了设计上更加灵活以外,我理解还应该是从安全的角度考虑,存在某些场景不应该支持上下文随时取消。

取消后下游如何感知

考虑一个问题,当上游取消时,下游是如何立刻感知的呢?这就是 Done() <-chan struct{} 提供能的能力。

下游可以监听 Done() 返回的channel,当 上下文取消时 该 channel 会被关闭。channel未关闭则会阻塞协程,关闭后则能够读取值,不会阻塞协程,这是goalng channel 的特性。

本文的重点不在于取消的细节,你大致可以理解为Done的实现如下,真正的实现包含设计思想在里边,你可以自己研究下。

go 复制代码
type cancelCtx struct {
	Context

	mu       sync.Mutex            // 保护如下的字段,考虑并发的场景
	done     atomic.Value          // chan struct{}, 懒创建,首次调用cancel()时关闭
	children map[canceler]struct{} // 调用 cancel() 后设置为 nil
	err      atomic.Value          // 调用 cancel() 后设置具体值
	cause    error                 // 调用 cancel() 后设置具体值
}

func (c *cancelCtx) Done() <-chan struct{} {
	d = c.done.Load() // 返回的是结构体中的done字段
	return d.(chan struct{})
}

如下是一个示例,实现的是超时自动取消功能。你可以多尝试几次,会有不同的输出。

go 复制代码
package main

import (
	"context"
	"fmt"
	"math/rand"
	"time"
)

func HandelRequest(ctx context.Context) {
	finish := make(chan struct{})
	go WriteDatabase(ctx, finish)
	for {
		select {
		case <-ctx.Done():
			fmt.Println("HandelRequest canceled.")
			return
		case <-finish:
			fmt.Println("WriteDatabase Done.")
			return
		default:
			fmt.Println("HandelRequest running")
			time.Sleep(500 * time.Millisecond)
		}
	}
}

func WriteDatabase(ctx context.Context, finish chan struct{}) {
	// 随机休眠2s-6s左右
	time.Sleep(time.Second * time.Duration(rand.Intn(4)+4))
	finish <- struct{}{}
}

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

	time.Sleep(5 * time.Second)
	cancel()

	//Just for test whether sub goroutines exit or not
	time.Sleep(5 * time.Second)
}

cancelCtx 对象使用 func WithCancel(parent Context) (ctx Context, cancel CancelFunc),与之类似的还有一个 WithCancelCause,在执行cancel()时可以传入取消的原因。

go 复制代码
func WithCancelCause(parent Context) (ctx Context, cancel CancelCauseFunc) {
	c := withCancel(parent)
	return c, func(cause error) { c.cancel(true, Canceled, cause) }
}

timerCtx

上边说的cancelCtx是支持随时取消,而timerCtx则是定时取消,就是到达Deadline时取消。

其对应的构造函数主要有两个,含义也非常简单

go 复制代码
// 指定失效的时间点
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
	return WithDeadlineCause(parent, d, nil)
}

// 指定多久后失效
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
	return WithDeadline(parent, time.Now().Add(timeout))
}

timerCtx 的底层实现其实复用的 cancelCtx, 这完全说的通。只不过是 cancelCtx 的 cancel() 方法是通过代码编排在某个case下调用,而timerCtx 是通过 golang timer 的能力来定时调用cancel()。

现在,上边请求超时的例子main函数就可以改为如下的样子

go 复制代码
package main

import (
	"context"
	"fmt"
	"math/rand"
	"time"
)


func main() {
	ctx, _ := context.WithTimeout(context.Background(), 2*time.Second)
	go HandelRequest(ctx)

	//Just for test whether sub goroutines exit or not
	time.Sleep(10 * time.Second)
}

func HandelRequest(ctx context.Context) {
	finish := make(chan struct{})
	go WriteDatabase(ctx, finish)
	for {
		select {
		case <-ctx.Done():
			fmt.Println("HandelRequest canceled.")
			return
		case <-finish:
			fmt.Println("WriteDatabase Done.")
			return
		default:
			fmt.Println("HandelRequest running")
			time.Sleep(500 * time.Millisecond)
		}
	}
}

func WriteDatabase(ctx context.Context, finish chan struct{}) {
	// 随机休眠4s-6s左右
	time.Sleep(time.Second * time.Duration(rand.Intn(4)+2))
	finish <- struct{}{}
}

参考

相关推荐
Github项目推荐3 小时前
你的错误处理一团糟-是时候修复它了-🛠️
前端·后端
进击的圆儿3 小时前
高并发内存池项目开发记录01
后端
左灯右行的爱情3 小时前
4-Spring SPI机制解读
java·后端·spring
用户68545375977693 小时前
🎯 Class文件结构大揭秘:打开Java的"身份证" 🪪
后端
sp423 小时前
一套清晰、简洁的 Java AES/DES/RSA 加密解密 API
java·后端
用户68545375977693 小时前
💥 栈溢出 VS 内存溢出:别再傻傻分不清楚!
后端
王嘉祥3 小时前
Pangolin:基于零信任理念的反向代理
后端·架构
Yimin3 小时前
2. 这才是你要看的 网络I/O模型
后端
野犬寒鸦3 小时前
从零起步学习MySQL || 第五章:select语句的执行过程是怎么样的?(结合源码深度解析)
java·服务器·数据库·后端·mysql·adb