前言

详细大家在读 Golang 相关的代码的时候,见到的最多的 Go 标准库工具就是 context。

context 翻译成中文是“上下文”的意思,在生活或者工作中我们需要上下文来判断某些话或者某个工作所处背景或者环境。比如说我们在写一个需求文档的时候,往往会在第一部分写上基本背景,这个就是上下文,它能够让读者更快速地感知到当前需求产生的背景和必要性等等。同理,在开发场景中,上下文也是不可缺少的,它能够告诉我们完整的程序信息。

我们可以想象一个开发场景:在服务端处理一个客户端请求的过程中,我们可能需要打印一些必要的日志信息,并且想要通过 LogID 将这些日志串联起来,形成 trace 信息,代表它们是来自于同一次请求的。如果不使用 context,我们可能需要在每一次函数调用中都需要把 LogID 作为入参传递下去,如果只有一个 LogID 可能还好,那么假设我们后面又希望把客户端的 IP 地址以及 UserID 等信息也一并传递下去呢?这个时候如果一个函数一个函数地调整它们的参数显然不现实,更好的做法是将这些参数封装到一个结构体里面,这样可以避免频繁地调整函数参数,然而这一步我们现在不需要自己来做了,context 已经提供了这样的能力。

当然,Golang 标准库中的 context 的作用远不止传递参数这么简单,它还提供了超时和取消的机制,利用 context 的这些能力,我们可以很方便地协调协程之间的工作,从而开发出更加灵活的并发任务的场景。

本文目录结构如下:

Context 的诞生

Context 并不是从 Golang 伊始就存在的,相反,它是从 Go1.7 版本才被引入到 Golang 标准库的。我们都知道 Golang 比较擅长用来写服务端代码,并且由于其特有的协程机制,我们通常在每个请求到来的时候都去启动若干个协程去分别处理不同的任务,然后等待所有协程执行成功之后,统一返回给客户端。那么如何将一些通用的参数(例如 LogID、用户 session 等)传给每个协程就成了一个问题。同时主协程如何统筹子协程的生命周期,例如超时取消等机制在当初也是一个很棘手的问题。

在 Go1.7 之前,很多 Web 框架在定义自己的 handler 时,都会传递一个自定义的 Context,把客户端的信息和客户端的请求信息放入到 Context 中。Go 最初提供了 golang.org/x/net/context 库用来提供上下文信息,但最终还是在 Go1.7 中把此库提升到标准库 context 包中,同时标准库的 context 包还提供了手动取消以及超时自动取消等能力,从而可以更好地统筹协程的运行。

接下来就让我们深入 context 源码,充分解读一下 context 的工作机制和实现原理。

以下代码基于 Go1.17 版本

context 简称 ctx,下文中出现的 ctx 都代表 context

实现原理与源码解读

context/context.go

Context 接口定义

Context 是以接口的形式对外提供的,其接口定义如下:

type Context interface {


// 返回 context 是否设置了超时时间以及超时的时间点
// 如果没有设置超时,那么 ok 的值返回 false
// 每次调用都会返回相同的结果
Deadline() (deadline time.Time, ok bool)


// 如果 context 被取消,这里会返回一个被关闭的 channel
// 如果是一个不会被取消的 context,那么这里会返回 nil
// 每次调用都会返回相同的结果
Done() <-chan struct{}


// 返回 done() 的原因
// 如果 Done() 对应的通道还没有关闭,这里返回 nil
// 如果通道关闭了,这里会返回一个非 nil 的值:
// - 若果是被取消掉的,那么这里返回 Canceled 错误
    // - 如果是超时了,那么这里返回 DeadlineExceeded 错误
// 一旦被赋予了一个非 nil 的值之后,每次调用都会返回相同的结果
Err() error


// 获取 context 中保存的 key 对应的 value,如果不存在则返回 nil
// 每次调用都会返回相同的结果
Value(key interface{}) interface{}
}

Context 中 error 对应的错误类型包含两个,如下:

var Canceled = errors.New("context canceled")


var DeadlineExceeded error = deadlineExceededError{}
type deadlineExceededError struct{}
func (deadlineExceededError) Error() string { return "context deadline exceeded" }
func (deadlineExceededError) Timeout() bool { return true }
func (deadlineExceededError) Temporary() bool { return true }

细心点的同学从上述接口的四个方法的注释中都能找到 "Successive calls to xxx return the same xxx" 字眼,这表明以上四个方法都是幂等的,也就是每次(被赋值之后)调用它们都能返回相同的结果。那么为什么会这样呢?其实通过下面的源码分析我们就能得到答案了。

Context 类型的结构体

emptyCtx

emptyCtx 不是一个结构体,它只是 int 类型的一个别名,实现的 Context 的四个方法都是返回 nil 或者默认值:

type emptyCtx int


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 interface{}) interface{} {
return nil
}

这意味着 emptyCtx 永远不能被取消,没有 deadline,并且也不会保存任何值。它是一个私有类型,没有提供相关的导出方法,但是却被包装成了两个可以被导出的 ctx,用作顶层 Context:

var (
background = new(emptyCtx)
todo = new(emptyCtx)
)


func Background() Context {
return background
}


func TODO() Context {
return todo
}

其实这两个实例本身也不具有任何含义,本质上就是一个 emptyCtx,但是从其注释上我们可以发现 Golang 官方赋予(规定)了使用它们的不同场景:

backgroundtodo
todo

valueCtx

相比 emptyCtx,valueCtx 要稍微复杂一些,它维护了一个键值对,可以保存一组 kv(只有一组),其结构体类型定义如下:

type valueCtx struct {
// 父 ctx
Context
// kv
key, val interface{}
}

它的定义比较简单,只是维护了它的父 ctx 以及 kv 共三个字段,创建 valueCtx 的函数如下:

WithValue(Context, interface{}, interface{})

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}
}

除了常规的 nil 检查之外,唯一的要求就是需要 key 是可比较的(这个很好理解,因为我们需要通过 key 来定位相应的 value 嘛)。并且由于它是将 Context 作为匿名字段的,因此它不需要自己去实现 Context 接口的方法,只需要继承自父 ctx 即可。因此 valueCtx 只实现了下述两个方法:

String()

func (c *valueCtx) String() string {
return contextName(c.Context) + ".WithValue(type " +
reflectlite.TypeOf(c.key).String() +
", val " + stringify(c.val) + ")"
}

Value(interface{})

Value() 方法用于获取 key 对应的 value,如果从当前的 ctx 找不到,那么会递归去它的父 ctx 中去找。

func (c *valueCtx) Value(key interface{}) interface{} {
if c.key == key {
return c.val
}
// 如果当前 ctx 没有找到,那么会去父 ctx 上面去找
return c.Context.Value(key)
}

一个 ctx 只能保存一对 kv,那么如果我们想要在 ctx 中保存多个 kv 键值对该怎么办?

WithValue()Value()
Value
Value()

cancelCtx

在讲 cancelCtx 之前,我们先看一下 canceler 接口,因为 cancelCtx 正是实现了这个接口:

type canceler interface {
cancel(removeFromParent bool, err error)
Done() <-chan struct{}
}

它包含两个方法:

cancelDoneContext.Done()

cancelCtx 是更加复杂的一个 ctx,它实现了 canceler 接口,支持取消操作,并且取消操作能够往子节点蔓延,其结构体定义如下:

type cancelCtx struct {
// 父 ctx
Context
// 通过互斥锁来保证对下面三个 field 操作的安全性
mu sync.Mutex
// 保存一个 chan struct{},第一个 cancel() 调用会关闭这个通道
done atomic.Value
// 维护所有子 canceler
// 当前 ctx 被取消之后,它的所有子 canceler 都会被取消,并且这个属性会被置空
children map[canceler]struct
// 第一个 cancel() 调用会赋值
err error
}

WithCancel(Context, CancelFunc)

WithCancel
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
if parent == nil {
panic("cannot create context from nil parent")
}
// 初始化一个 cancelCtx 实例
c := newCancelCtx(parent)
// 将当前 ctx 挂载到父节点上,从而实现 cancel 操作的向下传播
propagateCancel(parent, &c)
return &c, func() { c.cancel(true, Canceled) }
}

该函数返回一个 cancelCtx 实例以及一个取消函数,一旦这个函数被我们调用,那么当前 ctx 及其子 cancelCtx 会被马上取消。

value(interface{})

Value()
var cancelCtxKey int


func (c *cancelCtx) Value(key interface{}) interface{} {
// 特殊路径,如果传入的 key 是 &cancelCtxKey,那么直接返回当前的 ctx
if key == &cancelCtxKey {
return c
}
// 否则去它的父 ctx 上面查找对应的 value
return c.Context.Value(key)
}
Value

cancel(bool, error)

cancel()
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
if err == nil {
panic("context: internal error: missing cancel error")
}
c.mu.Lock()
// 如果 c.err 不是 nil,代表已经被取消了,直接返回
if c.err != nil {
c.mu.Unlock()
return
}
// 标记已经被取消
c.err = err
d, _ := c.done.Load().(chan struct{})
// 关闭 channel,从而可以通知到其他协程
// 如果 d == nil,代表之前没有调用过 Done() 方法,这里直接传入一个 closedchan(关闭的通道)
if d == nil {
c.done.Store(closedchan)
} else {
// 否则的话,需要关闭当前的 channel
close(d)
}
// cancel 子节点
for child := range c.children {
child.cancel(false, err)
}
c.children = nil
c.mu.Unlock()
// 如果需要从父节点中移除当前节点,那么执行 remove 操作
if removeFromParent {
removeChild(c.Context, c)
}
}


// removeChild 移除一个子节点
func removeChild(parent Context, child canceler) {
p, ok := parentCancelCtx(parent)
if !ok {
return
}
p.mu.Lock()
if p.children != nil {
// 从 map 中移除当前元素
delete(p.children, child)
}
p.mu.Unlock()
}
cancelremoveChild

如下图所示,当 cancelCtx1 取消之后,它的子节点 cancelCtx2 和 timerCtx1 以及子节点的子节点 timerCtx2 都会被取消。

那么子节点是如何挂载到父节点上面的呢?这个就需要依赖下面这个函数了。

propagateCancel(Context, canceler)

propagateCancel
func propagateCancel(parent Context, child canceler) {
done := parent.Done()
// done == nil 代表父 ctx 不是一个可以被 cancel 的 ctx,直接返回
if done == nil {
return
}


select {
case <-done:
// parent ctx 已经被 cancel 了,那么直接 cancel 当前 ctx
child.cancel(false, parent.Err())
return
default:
}
// 判断父节点是不是一个(合法的) cancelCtx
// yes
if p, ok := parentCancelCtx(parent); ok {
p.mu.Lock()
// 双重检查
if p.err != nil {
// 父节点已经被 cancel 了,那么直接 cancel 当前 ctx
// 第一个参数传 false 是因为当前节点还没有添加到父节点下面,自然没必要执行 remove 操作
child.cancel(false, p.err)
} else {
if p.children == nil {
p.children = make(map[canceler]struct{})
}
// 将当前节点挂载到父节点上面
p.children[child] = struct{}{}
}
p.mu.Unlock()
} else {
atomic.AddInt32(&goroutines, +1)
go func() {
select {
// 如果当前父节点取消,那么取消对应的子节点
// 对应 parentCancelCtx 函数中 done == closedchan 的场景
case <-parent.Done():
child.cancel(false, parent.Err())
// 子节点自己取消,退出 select
case <-child.Done():
}
}()
}
}


func parentCancelCtx(parent Context) (*cancelCtx, bool) {
// 由于只有 cancelCtx 实现了 Done() 方法(emptyCtx 除外)
// 通过继承机制,这里会返回向上查找的第一个 cancelCtx 的 channel
done := parent.Done()
// done == closedchan 代表已经父节点取消了
// done == nil 代表不是一个 cancelCtx
if done == closedchan || done == nil {
return nil, false
}
// 可以联想一下 cancelCtx.Value() 以及 valueCtx.Value() 的实现
// 它们都会递归往上层取值
// 并且由于 key 是 &cancelCtxKey,它会向上取第一个 cancelCtx 实例
p, ok := parent.Value(&cancelCtxKey).(*cancelCtx)
// 如果最终断言失败,代表从当前节点一直往上层都找不到 cancelCtx
// 直接返回失败
if !ok {
return nil, false
}
pdone, _ := p.done.Load().(chan struct{})
// 这里检查两次拿到的 channel 是不是同一个
// 如果不是,代表这个 *cancelCtx 被用户自定义的包装实现中提供了一个不同的 done channel
// 如果是这种情况,那么会直接拦截,返回 false
// 之所以要拦截,是因为在这里不清楚调用者会怎么使用自定义的 done channel
// 由于 cancel 操作依赖这个 channel,如果依然把子节点放进当前节点中,可能会产生预期之外的效果
if pdone != done {
return nil, false
}
return p, true
}
parentCancelCtx
DoneErr

Done()

Done()
func (c *cancelCtx) Done() <-chan struct{} {
d := c.done.Load()
if d != nil {
return d.(chan struct{})
}
c.mu.Lock()
defer c.mu.Unlock()
d = c.done.Load()
// 懒汉式
if d == nil {
d = make(chan struct{})
c.done.Store(d)
}
return d.(chan struct{})
}
Done()
func cancelled(ctx context.Context) bool {
select {
case <-ctx.Done():
return true
default:
return false
}
}

Err()

Err()
func (c *cancelCtx) Err() error {
c.mu.Lock()
err := c.err
c.mu.Unlock()
return err
}

timerCtx

有了上面 cancelCtx 的铺垫,timerCtx 就好理解许多了。我们知道 cancelCtx 可以被手动取消(cancel),timerCtx 则在此基础上增加了超时自动取消的能力,其结构体定义如下:

type timerCtx struct {
// 维护一个 cancelCtx
cancelCtx
// 通过 cancelCtx.mu 加锁保护
timer *time.Timer
// 超时时间
deadline time.Time
}

timerCtx 的结构体定义很简单,维护了一个 cancelCtx 用来提供手动取消的能力,同时维护了一个 timer 和 deadline 用来实现到时间(deadline)自动(timer)取消。

timeCtx 提供了两种初始化实例的方法:

WithTimeout(Context, time.Duration)

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

WithDeadline(Context, time.Time)

WithDeadline
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
if parent == nil {
panic("cannot create context from nil parent")
}
if cur, ok := parent.Deadline(); ok && cur.Before(d) {
// 如果父节点的截止日期比新的早
// 那么新的截止日期实际上不会产生作用,因为它会被父节点提前 cancel
// 所以这里直接返回一个 cancelCtx 即可
return WithCancel(parent)
}
c := &timerCtx{
cancelCtx: newCancelCtx(parent),
deadline: d,
}
// 挂载到父节点上,这样父节点取消的话,当前节点也能被取消
propagateCancel(parent, c)
dur := time.Until(d)
// 如果已经超过截止时间了,那么直接取消
if dur <= 0 {
c.cancel(true, DeadlineExceeded)
return 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) }
}
CancelFunc
String()timerCtxcancelCtxcancelCtxcancelDoneparentCancelCtx

经过上述的源码分析,我们可以大致画一下 context 的整体类图:

典型应用

context 包提供的几种特殊的 Context 分别具有不同的功能,按照能力上划分,主要分别适用于如下的几个场景。

数据传递

Context 的应用场景之一就是实现数据的传递。有时候我们希望将某些参数能够在函数调用链当中层层传递下去,例如用户的登录信息、操作的 logID 等等,这些可以作为函数参数的一部分,但是显然这么设计会使函数参数异常臃肿,函数间耦合程度太高。利用 context 我们可以很优雅地实现此功能。虽然每个 context 实例都只有一个 key-value 键值对,但是由于它实现了链式查找的机制,也就是如果从当前的 context 找不到对应的 key,那么会一层一层递归地向父 context 去找,我们可以链式地为每一个 kv 都创建一个对应的 valueCtx。使用示例如下:

const KEY_LOG = "LOG_ID"
const KEY_USER_ID = "USER_ID"


func TestWithValue(t *testing.T) {
// 通过 WithValue() 生成一个保存 key-value 键值对的 ctx
ctx := context.WithValue(context.Background(), KEY_LOG, "2021082900001")
// 链式存入第二个 key
ctx = context.WithValue(ctx, KEY_USER_ID, "112233")
logId := GetLogID(ctx)
t.Log(logId)
}


func GetLogID(ctx context.Context) string {
// 通过 Value() 方法查找
if logId := ctx.Value(KEY_LOG); logId != nil {
return logId.(string)
}
return ""
}
WithValueValue

取消协程执行

DoneWithTimeoutWithDeadlinecancelcancel

下面的代码是一个简单的示例:

func TestWithCancel(t *testing.T) {
ctx, cancel := context.WithCancel(ctx)
// WithTimeout 可以实现超时自动调用 Cancel()
// ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)


go TakeTasks(ctx, "c1")
// ctx2 是 ctx 的子 context,当 ctx 被取消之后,ctx2 也会被取消
ctx2, _ := context.WithCancel(ctx)
go TakeTasks(ctx2, "c2")


time.Sleep(500 * time.Millisecond)
// 也可以手动提前取消
cancel()
time.Sleep(100 * time.Millisecond)
}


func cancelled(ctx context.Context) bool {
select {
case <-ctx.Done():
fmt.Println("finish taking tasks!")
return true
default:
fmt.Println("continue!")
return false
}
}


func TakeTasks(ctx context.Context, flag string) {
for {
if cancelled(ctx) {
break
}
fmt.Printf("%s taking tasks!\n", flag)
time.Sleep(100 * time.Millisecond)
}
}
cancel()

context 使用中的注意事项

同样的,我们来总结一下使用 context 的一些注意事项:

•context 携带的 kv 是向上查找的,如果当前节点查不到对应的 key,那么会继续从其父节点中查找;•context 的取消操作是向下蔓延的,如果当前节点取消,那么它的子节点(cancelCtx)也会被取消;•使用带有超时的 timerCtx,如果能提前取消,那么最好手动提前取消,从而可以快速释放资源,同时需要注意的是 context 的取消操作针对的只是 context,如果还涉及到一些其他的操作,例如和数据库通信、文件读写等,这些也需要我们手动取消。

以下几点是使用 context 的一些约定俗成的建议:

WithValue

总结

本文我们主要讨论的是 context 包中的函数和 Context 类型。Context 主要有四种,分别是 emptyCtx、valueCtx、cancelCtx 以及 timerCtx,它们从根 Context 开始,由上往下进行传递,可以形成一个上下文树。我们可以从这棵树中的 valueCtx 获取数据,也可以取消其中的一些 cancelCtx 或者 timerCtx,其中获取数据是从下往上寻找,而取消操作则是从上往下蔓延。

其实从这里我们也可以发现,context 取值操作和取消操作就相当于是对链表的遍历操作,时间复杂度是 O(n),效率并不是很高,但是考虑到 context 的层级不会那么深,权衡利弊之下,context 还是非常实用的一个工具。

参考

•Context:信息穿透上下文 - 晁岳攀[1]•Context isn’t for cancellation - Dave Cheney[2]

往期 Golang 源码系列