逻辑清晰地梳理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{}{}
}

参考

相关推荐
用户685453759776934 分钟前
同步成本换并行度:多线程、协程、分片、MapReduce 怎么选才不踩坑
后端
javaTodo41 分钟前
Claude Code 记忆机制详解:从 CLAUDE.md 到 Auto Memory,六层体系全拆解
后端
LSTM971 小时前
使用 C# 和 Spire.PDF 从 HTML 模板生成 PDF 的实用指南
后端
JaguarJack1 小时前
为什么 PHP 闭包要加 static?
后端·php·服务端
BingoGo1 小时前
为什么 PHP 闭包要加 static?
后端
是糖糖啊2 小时前
OpenClaw 从零到一实战指南(飞书接入)
前端·人工智能·后端
百度Geek说2 小时前
基于Spark的配置化离线反作弊系统
后端
Java编程爱好者2 小时前
虚拟线程深度解析:轻量并发编程的未来趋势
后端
苏三说技术2 小时前
Spring AI 和 LangChain4j ,哪个更好?
后端