Go 语言底层(四) : 深入 Context 上下文
引言
在并发编程中,协程之间的控制, 通信与请求链路追踪,都是实际项目中不可忽视的问题。Go 语言为此提供了一个标准解决方案 —— context
包。这个包最初是为了解决 HTTP 请求的上下文管理问题,但随着 Go 的发展,它逐渐演变为并发控制与跨 API 通信的核心工具。
1. 基本使用
Go 的 context 最强大的能力在于:控制协程生命周期、设置超时/截止时间,以及在调用链中传递轻量级数据。下面我们逐个介绍几种最常用的 Context 创建方法。
1.1 context.Background() 与 context.TODO()
这两个函数都用于创建根 Context,即没有父节点的起点。
ctx := context.Background()
ctx := context.TODO()
1.2 . context.WithCancel(parent)
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
- parent Context : 传入的父节点
Context
, 如果没有 , 可以通过上诉 1.1 的方法创建。 - ctx Context : 创建取消功能的子Context 并返回
- cancel CancelFunc : 当调用 cancel() 时,该 Context 及其派生出的所有子 Context 都会收到取消信号。
注意事项: 调用 WithCancel 后,一定要在合适的时机调用 cancel(),避免资源泄漏。
示例 :
ctx, cancel := context.WithCancel(context.Background())go func() {// 模拟一些耗时任务time.Sleep(2 * time.Second)cancel() // 主动取消 context
}()select {
case <-ctx.Done():fmt.Println("任务被取消:", ctx.Err())
}
1.3 . context.WithTimeout(parent, duration)
为 Context 设置一个超时时间,到时间后自动取消。这个方法的实现是基于WithCancel
的封装的
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
- timeout time.Duration : 设置过期时间 ,这里设置的时间段。
1.4. context.WithValue(parent, key, val)
用于在 Context 中携带请求作用域内的数据,例如用户信息、请求 ID 等。
type ctxKey stringconst userIDKey ctxKey = "userID"ctx := context.WithValue(context.Background(), userIDKey, "123456")func doSomething(ctx context.Context) {if uid, ok := ctx.Value(userIDKey).(string); ok {fmt.Println("当前用户ID:", uid)}
}
注意事项:
-
只用于轻量级、只读的数据
-
不建议传递业务数据、大对象、控制信号等
-
尽量使用自定义类型作为 key,避免 key 冲突
具体原因将在原理部分解释
2. 底层实现
2.1 Context 接口
context包的核心接口如下 , 它定义了一些方法,用于获取请求范围数据、取消请求和处理超时。
type Context interface {Deadline() (deadline time.Time, ok bool)Done() <-chan struct{}Err() errorValue(key any) any
}
- Deadline() 方法返回截止时间和一个布尔值,指示截止时间是否已经设置。
- Done() 方法返回一个只读的 channel,当请求被取消或超时时,该 channel 将被关闭。
- Err() 方法返回一个错误,指示为什么请求被取消。
- Value() 方法返回与给定key相关联的值,如果没有值,则返回 nil。
2.2 Context 树的起点
在 Go 的 context 包中,所有上下文的传播都源于某个“根 Context”,而 context.Background()
与 context.TODO()
就是我们最常用的两个起点。
它表示一个空上下文,没有取消信号、没有截止时间、也不携带任何值。你可以从它派生出带有取消、超时或值的子 Context。
var (background = new(emptyCtx)todo = new(emptyCtx)
)func Background() Context {return background
}func TODO() Context {return todo
}
返回的均是 emptyCtx
类型的一个实例.
type emptyCtx intfunc (*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
}
如上 , emptyCtx
的实现是一个非常“干净”的 Context 类型 , 这使得 emptyCtx 成为所有 Context 的基础类型 , 作为所有真正可操作 Context 的根源。
基于一个父Context可以随意衍生,其实这就是一个Context树,树的每个节点都可以有任意多个子节点,节点层级可以有任意多个。
2.3 WithCancel 方法的实现
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {if parent == nil {panic("cannot create context from nil parent")}c := newCancelCtx(parent)propagateCancel(parent, &c)return &c, func() { c.cancel(true, Canceled) }
}
这个函数执行步骤如下:
- 校验父 context 非空;
- 创建一个
cancelCtx
对象,作为子context - 然后调用
propagateCancel
构建父子context之间的关联关系,这样当父context被取消时,子context也会被取消。 - 将 cancelCtx 返回,连带返回一个用以终止该 cancelCtx 的闭包函数
cancel
我们来逐一介绍cancelCtx
对象, propagateCancel
方法和 闭包函数 cancel
2.3.1 cancelCtx
对象
type cancelCtx struct {Contextmu sync.Mutex // protects following fieldsdone atomic.Value // of chan struct{}, created lazily, closed by first cancel callchildren map[canceler]struct{} // set to nil by the first cancel callerr error // set to non-nil by the first cancel call
}type canceler interface {cancel(removeFromParent bool, err error)Done() <-chan struct{}
}
- 继承了一个 context 作为其父 context. 可见,cancelCtx 必然为某个 context 的子 context;
- 内置了一把锁,用以协调并发场景下的资源获取;例如对这里的
children
字段的操作 - done:实际类型为 chan struct{},即用来出传递context上下文的关闭信号;
- children:一个 set,指向 cancelCtx 的所有子 context;
- err:记录了当前 cancelCtx 的错误. 必然为某个 context 的子 context;
2.3.2 propagateCancel
方法
func propagateCancel(parent Context, child canceler) {// 如果返回nil,说明当前父`context`从来不会被取消,是一个空节点,直接返回即可。done := parent.Done()if done == nil {return // parent is never canceled}// 提前判断一个父context是否被取消,如果取消了也不需要构建关联了,把当前子节点取消掉并返回select {case <-done:// parent is already canceledchild.cancel(false, parent.Err())returndefault:}// 这里目的就是找到可以“挂”、“取消”的contextif p, ok := parentCancelCtx(parent); ok {p.mu.Lock()// 找到了可以“挂”、“取消”的context,但是已经被取消了,那么这个子节点也不需要继续挂靠了,取消即可if p.err != nil {child.cancel(false, p.err)} else {// 将当前节点挂到父节点的childrn map中,外面调用cancel时可以层层取消if p.children == nil {// 这里因为childer节点也会变成父节点,所以需要初始化map结构p.children = make(map[canceler]struct{})}p.children[child] = struct{}{}}p.mu.Unlock()} else {// 没有找到可“挂”,“取消”的父节点挂载,那么就开一个goroutineatomic.AddInt32(&goroutines, +1)go func() {select {case <-parent.Done():child.cancel(false, parent.Err())case <-child.Done():}}()}
}
propagateCancel 方法顾名思义,用以传递父子 context 之间的 cancel 事件:
-
倘若 parent 是不会被 cancel 的类型(如 emptyCtx),则直接返回;
-
倘若 parent 已经被 cancel,则直接终止子 context,并以 parent 的 err 作为子 context 的 err;
-
假如 parent 是 cancelCtx 的类型,则加锁,并将子 context 添加到 parent 的 children map 当中;
-
假如 parent 不是 cancelCtx 类型,但又存在 cancel 的能力(比如用户自定义实现的 context),则启动一个协程,通过多路复用的方式监控 parent 状态,倘若其终止,则同时终止子 context,并透传 parent 的 err.
2.3.2 cancelCtx.cancel
方法
这个方法会关闭上下文中的 Channel 并向所有的子上下文同步取消信号
func (c *cancelCtx) cancel(removeFromParent bool, err error) {// 取消时传入的error信息不能为nil, context定义了默认error:var Canceled = errors.New("context canceled")if err == nil {panic("context: internal error: missing cancel error")}// 已经有错误信息了,说明当前节点已经被取消过了c.mu.Lock()if c.err != nil {c.mu.Unlock()return // already canceled}c.err = err// 用来关闭channel,通知其他协程d, _ := c.done.Load().(chan struct{})if d == nil {c.done.Store(closedchan)} else {close(d)}// 当前节点向下取消,遍历它的所有子节点,然后取消for child := range c.children {// NOTE: acquiring the child's lock while holding parent's lock.child.cancel(false, err)}// 节点置空c.children = nilc.mu.Unlock()// 把当前节点从父节点中移除,只有在外部父节点调用时才会传true// 其他都是传false,内部调用都会因为c.children = nil被剔除出去if removeFromParent {removeChild(c.Context, c)}
}
该方法递归的遍历子树 , 主要保证每个节点
- c.done.Load().(chan struct{})
- removeFromParent
通过源码我们可以知道cancel方法可以被重复调用,是幂等的。
结论 :
自此 , 我们便能够清晰的明白 , WithCancel 方法
通过创建子context
并存储到 children
字段中 , 实现了一个 context
树 . 并通过递归方法传递管道 done
字段传递关闭信号。
2.4 withDeadline、WithTimeout的实现
WithTimeout方法
,它内部就是调用的WithDeadline方法
,两者的区别 ,一个是时间点取消 , 一个是时间段取消
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {return WithDeadline(parent, time.Now().Add(timeout))
}func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {// 不能为空`context`创建衍生contextif parent == nil {panic("cannot create context from nil parent")}// 当父context的结束时间早于要设置的时间,则不需要再去单独处理子节点的定时器了if cur, ok := parent.Deadline(); ok && cur.Before(d) {// The current deadline is already sooner than the new one.return WithCancel(parent)}// 创建一个timerCtx对象c := &timerCtx{cancelCtx: newCancelCtx(parent),deadline: d,}// 将当前节点挂到父节点上propagateCancel(parent, c)// 获取过期时间dur := time.Until(d)// 当前时间已经过期了则直接取消if dur <= 0 {c.cancel(true, DeadlineExceeded) // deadline has already passedreturn c, func() { c.cancel(false, Canceled) }}c.mu.Lock()defer c.mu.Unlock()// 如果没被取消,则直接添加一个定时器,定时去取消if c.err == nil {c.timer = time.AfterFunc(dur, func() {c.cancel(true, DeadlineExceeded)})}return c, func() { c.cancel(true, Canceled) }
}
withDeadline相较于withCancel方法也就多了一个定时器去定时调用cancel方法,这个cancel方法在timerCtx类中进行了重写,我们来看一下timerCtx
类,他是基于cancelCtx
的,多了两个字段:
type timerCtx struct {cancelCtxtimer *time.Timer // Under cancelCtx.mu.deadline time.Time
}
除了继承 cancelCtx 的能力之外,新增了一个 time.Timer 用于定时终止 context;另外新增了一个 deadline 字段用于字段 timerCtx 的过期
func (c *timerCtx) cancel(removeFromParent bool, err error) {// 调用cancelCtx的cancel方法取消掉子节点contextc.cancelCtx.cancel(false, err)// 从父context移除放到了这里来做if removeFromParent {// Remove this timerCtx from its parent cancelCtx's children.removeChild(c.cancelCtx.Context, c)}// 停掉定时器,释放资源c.mu.Lock()if c.timer != nil {c.timer.Stop()c.timer = nil}c.mu.Unlock()
}
timerCtx
实现的cancel方法,内部也是调用了cancelCtx的cancel方法取消:
.
2.5 WithValue的实现
withValue内部主要就是调用valueCtx
类:
func WithValue(parent Context, key, val interface{}) Context {if parent == nil {panic("cannot create context from nil parent")}if key == nil {panic("nil key")}if !reflectlite.TypeOf(key).Comparable() {panic("key is not comparable")}return &valueCtx{parent, key, val}
}
valueCtx
类
valueCtx目的就是为Context
携带键值对,因为它采用匿名接口的继承实现方式,他会继承父Context,也就相当于嵌入Context当中了
type valueCtx struct {Contextkey, val interface{}
}func (c *valueCtx) String() string {return contextName(c.Context) + ".WithValue(type " +reflectlite.TypeOf(c.key).String() +", val " + stringify(c.val) + ")"
}func (c *valueCtx) Value(key interface{}) interface{} {if c.key == key {return c.val}return c.Context.Value(key)
}
- valueCtx 同样继承了一个 parent context;
- 一个 valueCtx 中仅有一组 kv 对.
- 实现了String方法输出Context和携带的键值对信息
- 实现Value方法来存储键值对
我们在调用Context中的Value方法时会层层向上调用直到最终的根节点,中间要是找到了key就会返回,否会就会找到最终的emptyCtx返回nil。
结论 :
阅读源码可以看出,valueCtx 不适合视为存储介质,来存放大量的 kv 数据
-
一个 valueCtx 实例只能存一个 kv 对,因此 n 个 kv 对会嵌套 n 个 valueCtx,造成空间浪费;
-
基于 k 寻找 v 的过程是线性的,时间复杂度 O(N);
-
不支持基于 k 的去重,相同 k 可能重复存在,并基于起点的不同,返回不同的 v. 由此得知,valueContext 的定位类似于请求头,只适合存放少量作用域较大的全局 meta 数据.
参考文档 : 小徐先生的编程世界