平时写代码或者阅读代码的时候,经常会在方法内看到ctx context.Context
这样的参数。本文就记录一下有关于context.Context
的知识。
tips:本文源码基于golang 1.18
context
主要有以下作用:
- 可用于在不同协程(goroutine)中传递信息
- 设置超时处理
- 控制协程退出
context
字面量为上下文,准确点说它是goroutine的上下文。
context.Context 类型的值可以协调多个 groutine 中的代码执行“取消”操作,并且可以存储键值对。最重要的是它是并发安全的。
与它协作的 API 都可以由外部控制执行“取消”操作,例如:取消一个 HTTP 请求的执行。
go语言中,context
可以形成一个树形结构。这个树形结构的每一个节点都是一个context
对象,它们可以互相嵌套,形成一个完整的上下文链。
这种树形结构的最大优点是,当我们在处理请求时,可以将不同的处理器分别绑定到对应的Context对象上,并且可以共享父级Context对象所包含的请求数据。这样,在请求超时或取消时,所有的处理器都可以响应并进行清理工作。
此外,Context对象的树形结构还支持一些其他的特性,比如:
-
父级Context对象的取消操作会自动传播到所有的子级Context对象。
-
子级Context对象的取消操作不会影响父级Context对象和其他兄弟级别的Context对象。
-
可以通过调用Context对象的Value方法来获取父级Context对象所存储的请求范围的数据。
总之,Context对象的树形结构是Go语言中非常重要的一种机制,它可以帮助我们有效地管理请求的上下文数据,并且可以在请求超时或取消时自动清理资源。
在之前的一篇文章中介绍到:在golang中每个http请求都会启一个goroutine来处理请求。
而每个处理请求的goroutine可能还会依赖其他服务(数据库、其他子服务等),可能还会继续启用一个又一个的goroutine来进行处理:
这里http请求进来后开启了一个a协程,a又开启了b,c,d(称为下游协程)等等。如果某个下游协程处理时间过长,那么a就只能一直在等待。而一个服务肯定不只有一个请求进来,还会有大量的请求。最终会造成大量的协程等待阻塞。
协程是要消耗内存和其他系统资源的,这种情况下会把资源耗尽,最终造成服务不可用,也称为"雪崩效应"。
如果有一种办法,可以控制a等待下游协程处理的时间,处理过久就不等待下游直接返回响应,防止协程堆积。
context
在这里就发挥作用了。(设置超时处理)
还有几种情况,不同的goroutine需要共享一些信息,可以通过context
来完成,比如在多个goroutine中共享用户的token。(在不同goroutine中传递信息)
当一个上游协程发生某些情况需要退出时,需要告知它的下游协程及时退出,因为此时下游的工作已经没有意义了,则可以通过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同时调用,它是并发安全的。
该接口有四个方法,简单一个个介绍下。
-
Deadline
方法会返回一个time.Time
实例,代表这个上下文的截止日期。在这个时间点之后,上下文会被取消。第二个返回值如果为false则代表该上下文没有设置截止日期。拿到这个截止日期之后,能判断是否可以执行后续操作,如果可以则给后续的操作设置超时时间。
-
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
}
}
|
-
Err
: 上文说到Done
方法会返回一个channel
。如果该channel
被关闭了则代表ctx被取消,Err
方法会返回channel
被关闭的原因。Canceled
代表ctx被手动取消的,DeadlineExceeded
代表该ctx因为超时而取消的。如果该方法返回一个非nil的错误后,后续的多次调用都返回相同的值。
-
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
实现与具体的实现机制。
先介绍一下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)。
知道了怎么使用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
,它的字段含义都注释在上面的代码块里了。主要看它如何实现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)
}
}
|
cancelCtx
的cancel
会关闭它自己的done(代表被取消了),接着关闭所有的子ctx。如果第一个参数为true则会从父ctx中移除。
逻辑注释在代码里了,这个方法大致做这几件事:
- 参数判断
- 懒加载创建done
- 调用子ctx的cancel, 取消子ctx
- 判断是否从父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")
,代表取消原因。
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
接口,接下来看看WithDeadline
与WithTimeout
是如何创建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) }
}
|
还是一样,方法逻辑都注释在代码块里了,这里总结下:
- 参数判断,判断父ctx的deadline是否早于传入的deadline
- 构造
timerCtx
实例,绑定父子关系
- 获取当前时间距离传入的deadline还有多久(dur)
- 如果dur<0代表已经超时,直接取消并返回。dur>0则启动timer,在dur之后运行
timerCtx.cancel
方法。
- 方法返回
timerCtx
与timerCtx.cancel
,使用者可以主动取消
1
2
3
|
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}
|
WithTimeout
这里是直接调用了WithDeadline
,参数传的是当前时间加上timeout。
上面介绍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
所构建的context树。每个节点都会携带一个键值对,如果本节点找不到则会去父节点寻找。这里我们不推荐使用WithValue
保存一些业务数据,该函数只适合保存一些请求相关的信息(如request_id和trace_id)。原因有几个,一是链表查询时间复杂度位O(n),查询效率不保证,二是子节点可能会持有和父节点相同的key,导致覆盖掉父节点的值了。
以下摘自官方:
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.
|
翻译一下:
- 不要将上下文存储在结构类型内;相反,对于每个需要它的函数,显式地传递上下文。上下文应该是第一个参数,通常命名为ctx。
- 即使函数允许,也不要传递空的上下文。如果您不确定使用哪个上下文,请传递context.TODO。
- 只将请求范围的数据(途经进程和 API)用作上下文值,不要将可选参数传递给函数。
- 相同的上下文可能会传递给在不同goroutine中运行的函数;上下文可以被多个goroutine同时使用,是安全的。
到这里,goalng context包就介绍完毕了。整个包源码不多,非常适合学习阅读。
最后,大家下次在看到代码里有用到 context 的,观察下是怎么使用的,肯定逃不出我们讲的几种类型。熟悉之后会发现:context 可能并不完美,但它确实简洁高效地解决了问题。