golang源码分析-Context

前言

平时写代码或者阅读代码的时候,经常会在方法内看到ctx context.Context这样的参数。本文就记录一下有关于context.Context的知识。

tips:本文源码基于golang 1.18


What Context

context主要有以下作用:

  1. 可用于在不同协程(goroutine)中传递信息
  2. 设置超时处理
  3. 控制协程退出

context字面量为上下文,准确点说它是goroutine的上下文。

context.Context 类型的值可以协调多个 groutine 中的代码执行“取消”操作,并且可以存储键值对。最重要的是它是并发安全的。 与它协作的 API 都可以由外部控制执行“取消”操作,例如:取消一个 HTTP 请求的执行。

go语言中,context可以形成一个树形结构。这个树形结构的每一个节点都是一个context对象,它们可以互相嵌套,形成一个完整的上下文链。

这种树形结构的最大优点是,当我们在处理请求时,可以将不同的处理器分别绑定到对应的Context对象上,并且可以共享父级Context对象所包含的请求数据。这样,在请求超时或取消时,所有的处理器都可以响应并进行清理工作。

此外,Context对象的树形结构还支持一些其他的特性,比如:

  • 父级Context对象的取消操作会自动传播到所有的子级Context对象。

  • 子级Context对象的取消操作不会影响父级Context对象和其他兄弟级别的Context对象。

  • 可以通过调用Context对象的Value方法来获取父级Context对象所存储的请求范围的数据。

总之,Context对象的树形结构是Go语言中非常重要的一种机制,它可以帮助我们有效地管理请求的上下文数据,并且可以在请求超时或取消时自动清理资源。

父子context描述


Why Context

在之前的一篇文章中介绍到:在golang中每个http请求都会启一个goroutine来处理请求。

而每个处理请求的goroutine可能还会依赖其他服务(数据库、其他子服务等),可能还会继续启用一个又一个的goroutine来进行处理:

一个http请求链条示例

这里http请求进来后开启了一个a协程,a又开启了b,c,d(称为下游协程)等等。如果某个下游协程处理时间过长,那么a就只能一直在等待。而一个服务肯定不只有一个请求进来,还会有大量的请求。最终会造成大量的协程等待阻塞。

协程是要消耗内存和其他系统资源的,这种情况下会把资源耗尽,最终造成服务不可用,也称为"雪崩效应"。

如果有一种办法,可以控制a等待下游协程处理的时间,处理过久就不等待下游直接返回响应,防止协程堆积。

context设置超时处理

context在这里就发挥作用了。(设置超时处理)

还有几种情况,不同的goroutine需要共享一些信息,可以通过context来完成,比如在多个goroutine中共享用户的token。(在不同goroutine中传递信息)

context共享信息

当一个上游协程发生某些情况需要退出时,需要告知它的下游协程及时退出,因为此时下游的工作已经没有意义了,则可以通过context来完成。(控制协程退出)

context控制协程退出


Context接口

context接口定义如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// A Context carries a deadline, a cancellation signal, and other values across
// API boundaries.
//
// Context's methods may be called by multiple goroutines simultaneously.
type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key any) any
}

根据注释描述可以知道: context可以跨api边界传递截止日期、取消信号、和其他值。并且context的方法可以被多个goroutine同时调用,它是并发安全的。

该接口有四个方法,简单一个个介绍下。

  1. Deadline方法会返回一个time.Time实例,代表这个上下文的截止日期。在这个时间点之后,上下文会被取消。第二个返回值如果为false则代表该上下文没有设置截止日期。拿到这个截止日期之后,能判断是否可以执行后续操作,如果可以则给后续的操作设置超时时间。

  2. Done方法返回一个channel。如果该channel被关闭则代表该上下文被取消了。这个方法是核心,在golang中一个channel如果被关闭,则会发送空值。context正是用到了这一特性,我们可以在方法内监听ctx.Done,如果接收到值则代表context被取消,那么则做一些资源关闭处理。

1
2
3
4
5
6
7
func test(ctx context.Context) {
	fmt.Println("lalala")
	select {
	case <- ctx.Done():
		// close resourse
	}
}
  1. Err: 上文说到Done方法会返回一个channel。如果该channel被关闭了则代表ctx被取消,Err方法会返回channel被关闭的原因。Canceled代表ctx被手动取消的,DeadlineExceeded代表该ctx因为超时而取消的。如果该方法返回一个非nil的错误后,后续的多次调用都返回相同的值。

  2. Value:该方法会返回一个与参数key相对应的值,把它理解为context中的一个map即可。需要注意,这个map应该只保存和跨边界api相关的请求数据,如token,uid等。业务数据不应该保存至map中。可以使用如下方法储存:

1
2
3
4
5
// 第一个参数为父context, context.TODO()类型下文再介绍。key为abc, value为123
ctx := context.WithValue(context.TODO(), "abc", 123)
// 取到的value强转为int类型
result := ctx.Value("abc").(int)
fmt.Println(result)

Context类型

上一个章节记录了Context接口,那么肯定会有对应的实现,这一章节介绍几个Context实现与具体的实现机制。

emptyCtx

先介绍一下emptyCtx:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// emptyCtx是一个空的context对象,它永远不会被取消,没有任何值,也没有任何截止日期。它不是struct{}类型,因为该类型的变量必须具有不同的地址。
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 any) any {
	return nil
}

它实现了Context接口,只不过每个方法都为空实现。意味着该ctx永远不会被取消。

平时我们常用的context.Background()context.TODO()底层就是emptyCtx:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
var (
	background = new(emptyCtx)
	todo       = new(emptyCtx)
)

func Background() Context {
	return background
}

func TODO() Context {
	return todo
}

Background常用于main函数、初始化、http请求的底层Context。它应该作为一个项目的根Context,后续的Context都作为它的子Context而存在。

TODO本质上与Background没有区别,只不过前者更偏向语义化的含义,当你不知道使用什么Context对象时,可以使用TODO临时替代。

使用示例

平时在学习与工作中主要是这样使用context:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// case 1:
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
DoSomeThing(ctx)

// case 2:
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
DoSomeThing(ctx)

// case 3:
deadline := time.Now().Add(3*time.Second)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()
DoSomeThing(ctx)

示例中使用了三个方法。

1
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

WithCancel方法返回一个parent的副本(相当于parent的子ctx)与一个关闭子ctx的函数。调用这个函数或者parent被取消(parent的Done被关闭)的话,子ctx就被取消。因此代码应该在正在运行的操作完成后尽快调用 cancel 函数,或者使用defer关闭ctx。下面两个方法也是一样,略。

1
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

WithDeadline方法要求传入一个父ctx(parent)与截止时间(time.Time对象)。返回一个带有截止时间的子ctx与关闭子ctx的函数。这个截止时间就是传进去的time.Time对象。当截止时间到达、调用返回的 cancel 函数或者父ctx的 Done 通道关闭时,返回的ctx也会被取消。

1
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

WithTimeout方法返回结果与WithDeadline一样,请求参数变成父ctx与time.Duration对象。代表返回的子ctx多久后超时被取消。

我们可以用上面三个方法来控制goroutine的操作时间(设置deadline或者timeout)或主动退出(调用cancel)。

canceler接口

知道了怎么使用context,但是还需要知其所以然。本章节主要在源码层面分析context取消的实现。

这里介绍一个重要的接口:

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

canceler是一个可以直接被取消的context类型。具体的实现有*cancelCtx*timerCtx两种。

Done()方法和Context接口的含义一样,这里跳过。cancel方法表示取消ctx,第一个参数代表是否从父ctx中去除,第二个参数代表取消的原因。

接下来看该接口的两个实现类:

1
2
3
4
5
6
7
8
9
// cancelCtx可以被取消,当它被取消时,也会取消实现了canceler接口的子ctx
type cancelCtx struct {
	Context

	mu       sync.Mutex            // 保护下面的字段,确保并发安全
	done     atomic.Value          // 懒加载创建的,它是一个 chan struct{} 类型的通道,在第一次调用取消函数时关闭。
	children map[canceler]struct{} // 第一次调用取消函数时置为nil
	err      error                 // 第一次调用取消函数是置为non-nil
}

cancelCtx

第一个是cancelCtx,它的字段含义都注释在上面的代码块里了。主要看它如何实现canceler接口的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
	// 参数判断
	if err == nil {
		panic("context: internal error: missing cancel error")
	}
	// 并发控制
	c.mu.Lock()
	if c.err != nil {
		// err不为空,说明之前就被取消了
		c.mu.Unlock()
		return // already canceled
	}
	// 给err赋值
	c.err = err
	d, _ := c.done.Load().(chan struct{})
	if d == nil {
		// 懒加载创建done
		c.done.Store(closedchan)
	} else {
		// 关闭d
		close(d)
	}
	for child := range c.children {
		// NOTE: acquiring the child's lock while holding parent's lock.
		// 将子ctx关闭
		child.cancel(false, err)
	}
	// 清楚父子ctx映射
	c.children = nil
	c.mu.Unlock()

	if removeFromParent {
		// 从父ctx移除
		removeChild(c.Context, c)
	}
}

cancelCtxcancel会关闭它自己的done(代表被取消了),接着关闭所有的子ctx。如果第一个参数为true则会从父ctx中移除。

逻辑注释在代码里了,这个方法大致做这几件事:

  1. 参数判断
  2. 懒加载创建done
  3. 调用子ctx的cancel, 取消子ctx
  4. 判断是否从父ctx中移除

Done的实现就是懒加载一个chan struct{}类型返回,代码就不放了。

现在来看WithCancel的实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
	// 参数校验
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	// 创建一个cancelCtx的实例
	c := newCancelCtx(parent)
	// 该方法判断, 当传入的父ctx被取消时, 该cancelCtx也被取消
	propagateCancel(parent, &c)
	// 返回cancelCtx与它的cancel方法
	return &c, func() { c.cancel(true, Canceled) }
}

先是参数校验,接着创建一个cancelCtx实例然后判断传入的父ctx有没有被取消。最后返回cancelCtx自己和它的cancel方法。这个实现就是上面介绍的。第一个参数传true代表会从父ctx中移除自己,第二个参数传的是errors.New("context canceled"),代表取消原因。

timerCtx

1
2
3
4
5
6
7
type timerCtx struct {
	cancelCtx
	// 受cancelCtx.mu的保护
	timer *time.Timer // Under cancelCtx.mu.

	deadline time.Time
}

timerCtx相比于cancelCtx,多了个timer和deadline。timer会在deadline到来时,自动取消ctx。

看下timerCtx.cancel方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
func (c *timerCtx) cancel(removeFromParent bool, err error) {
	// 直接调用cancelCtx取消ctx
	c.cancelCtx.cancel(false, err)
	if removeFromParent {
		// 从父ctx中移除
		removeChild(c.cancelCtx.Context, c)
	}
	c.mu.Lock()
	if c.timer != nil {
		// 将定时器关闭并置空
		c.timer.Stop()
		c.timer = nil
	}
	c.mu.Unlock()
}

第一步直接调用cancelCtx.cancel方法,来取消ctx。接着判断是否从父ctx中移除。最后将timer关闭,这里先是判nil,防止重复关闭。

timerCtx.Done的实现和cancelCtx.Done一致,懒加载一个chan struct{}返回,这里就不贴代码了。

知道了timerCtx如何实现canceler接口,接下来看看WithDeadlineWithTimeout是如何创建timerCtx的。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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) {
		// 父ctx的deadline早于传入的d,直接构造无deadline的子ctx返回
		// 因为父ctx到deadline后,会自动将子ctx取消,这里无需构造两个timer了
		return WithCancel(parent)
	}
	// 构造timerCtx实例
	c := &timerCtx{
		cancelCtx: newCancelCtx(parent),
		deadline:  d,
	}
	// 父子关系绑定
	propagateCancel(parent, c)
	// 获取距离d还有多久,dur为time.Duration
	dur := time.Until(d)
	if dur <= 0 {
		// 已经结束,直接取消
		c.cancel(true, DeadlineExceeded) // deadline has already passed
		return c, func() { c.cancel(false, Canceled) }
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.err == nil {
		// 启动子ctx的timer,在dur之后运行cancel方法。
		c.timer = time.AfterFunc(dur, func() {
			c.cancel(true, DeadlineExceeded)
		})
	}
	// 返回自身与cancel方法
	return c, func() { c.cancel(true, Canceled) }
}

还是一样,方法逻辑都注释在代码块里了,这里总结下:

  1. 参数判断,判断父ctx的deadline是否早于传入的deadline
  2. 构造timerCtx实例,绑定父子关系
  3. 获取当前时间距离传入的deadline还有多久(dur)
  4. 如果dur<0代表已经超时,直接取消并返回。dur>0则启动timer,在dur之后运行timerCtx.cancel方法。
  5. 方法返回timerCtxtimerCtx.cancel,使用者可以主动取消
1
2
3
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
	return WithDeadline(parent, time.Now().Add(timeout))
}

WithTimeout这里是直接调用了WithDeadline,参数传的是当前时间加上timeout。

valueCtx

上面介绍context对外方法时,只介绍了WithCancel,WithDeadline,WithTimeout。还剩一个WithValue。再介绍它前,需要介绍下valueCtx:

1
2
3
4
type valueCtx struct {
	Context
	key, val any
}

valueCtx拥有一个内嵌的ctx与一个键值对(key,value)。可以通过key找到对应的value,并将除context.Value(key)方法的其他调用都委托给内嵌的ctx

valueCtx.Value(key)的实现:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
func (c *valueCtx) Value(key any) any {
	// 如果传入的key等于valueCtx自身的key,则将自身的value返回
	if c.key == key {
		return c.val
	}
	// 自身没有对应的value,去父节点找
	return value(c.Context, key)
}

func value(c Context, key any) any {
	for {
		// 开启一个循环,判断c的类型
		switch ctx := c.(type) {
		case *valueCtx:
			// 父节点还是valueCtx,判断逻辑与上面一致
			if key == ctx.key {
				return ctx.val
			}
			// 寻找父节点的父节点
			c = ctx.Context
		case *cancelCtx:
			// 判断传入的key是否等于cancelCtxKey
			if key == &cancelCtxKey {
				return c
			}
			// 寻找父节点的父节点
			c = ctx.Context
		case *timerCtx:
			// 判断传入的key是否等于cancelCtxKey
			if key == &cancelCtxKey {
				return &ctx.cancelCtx
			}
			// 寻找父节点的父节点
			c = ctx.Context
		case *emptyCtx:
			// 空实现的ctx,直接返回nil
			return nil
		default:
			return c.Value(key)
		}
	}
}

方法逻辑都注释好了,简单来说就是先看看自身(valueCtx)保存key能否匹配传入的key。如果可以就直接返回自身的value,否则去父节点找对应的key(类似递归的方式)。找到最后没找到就返回nil。

接下来看看WithValue函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
func WithValue(parent Context, key, val any) 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}
}

第一步先参数校验,接着判断传入的key是不是可以比较的类型(如果不可比较,那么查找时会找不到,还存进去没意义)。最后生成一个valueCtx实例返回。

WithValue创建的ctx树

如图所示,WithValue所构建的context树。每个节点都会携带一个键值对,如果本节点找不到则会去父节点寻找。这里我们不推荐使用WithValue保存一些业务数据,该函数只适合保存一些请求相关的信息(如request_id和trace_id)。原因有几个,一是链表查询时间复杂度位O(n),查询效率不保证,二是子节点可能会持有和父节点相同的key,导致覆盖掉父节点的值了。

Context使用技巧

以下摘自官方:

1
2
3
4
1. Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx.
2. Do not pass a nil Context, even if a function permits it. Pass context.TODO if you are unsure about which Context to use.
3. Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.
4. The same Context may be passed to functions running in different goroutines; Contexts are safe for simultaneous use by multiple goroutines.

翻译一下:

  1. 不要将上下文存储在结构类型内;相反,对于每个需要它的函数,显式地传递上下文。上下文应该是第一个参数,通常命名为ctx。
  2. 即使函数允许,也不要传递空的上下文。如果您不确定使用哪个上下文,请传递context.TODO。
  3. 只将请求范围的数据(途经进程和 API)用作上下文值,不要将可选参数传递给函数。
  4. 相同的上下文可能会传递给在不同goroutine中运行的函数;上下文可以被多个goroutine同时使用,是安全的。

到这里,goalng context包就介绍完毕了。整个包源码不多,非常适合学习阅读。

最后,大家下次在看到代码里有用到 context 的,观察下是怎么使用的,肯定逃不出我们讲的几种类型。熟悉之后会发现:context 可能并不完美,但它确实简洁高效地解决了问题。

updatedupdated2023-06-072023-06-07