平时写代码或者阅读代码的时候,经常会在方法内看到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
}
Copy
根据注释描述可以知道: 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
}
}
Copy
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 )
Copy
上一个章节记录了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
}
Copy
它实现了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
}
Copy
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 )
Copy
示例中使用了三个方法。
1
func WithCancel ( parent Context ) ( ctx Context , cancel CancelFunc )
Copy
WithCancel
方法返回一个parent的副本(相当于parent的子ctx)与一个关闭子ctx的函数。调用这个函数或者parent被取消(parent的Done被关闭)的话,子ctx就被取消。因此代码应该在正在运行的操作完成后尽快调用 cancel 函数,或者使用defer
关闭ctx。下面两个方法也是一样,略。
1
func WithDeadline ( parent Context , d time . Time ) ( Context , CancelFunc )
Copy
WithDeadline
方法要求传入一个父ctx(parent)与截止时间(time.Time
对象)。返回一个带有截止时间的子ctx与关闭子ctx的函数。这个截止时间就是传进去的time.Time
对象。当截止时间到达、调用返回的 cancel 函数或者父ctx的 Done 通道关闭时,返回的ctx也会被取消。
1
func WithTimeout ( parent Context , timeout time . Duration ) ( Context , CancelFunc )
Copy
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 {}
}
Copy
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
}
Copy
第一个是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 )
}
}
Copy
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 ) }
}
Copy
先是参数校验,接着创建一个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
}
Copy
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 ()
}
Copy
第一步直接调用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 ) }
}
Copy
还是一样,方法逻辑都注释在代码块里了,这里总结下:
参数判断,判断父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 ))
}
Copy
WithTimeout
这里是直接调用了WithDeadline
,参数传的是当前时间加上timeout。
上面介绍context
对外方法时,只介绍了WithCancel
,WithDeadline
,WithTimeout
。还剩一个WithValue
。再介绍它前,需要介绍下valueCtx
:
1
2
3
4
type valueCtx struct {
Context
key , val any
}
Copy
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 )
}
}
}
Copy
方法逻辑都注释好了,简单来说就是先看看自身(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 }
}
Copy
第一步先参数校验,接着判断传入的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 .
Copy
翻译一下:
不要将上下文存储在结构类型内;相反,对于每个需要它的函数,显式地传递上下文。上下文应该是第一个参数,通常命名为ctx。
即使函数允许,也不要传递空的上下文。如果您不确定使用哪个上下文,请传递context.TODO。
只将请求范围的数据(途经进程和 API)用作上下文值,不要将可选参数传递给函数。
相同的上下文可能会传递给在不同goroutine中运行的函数;上下文可以被多个goroutine同时使用,是安全的。
到这里,goalng context包就介绍完毕了。整个包源码不多,非常适合学习阅读。
最后,大家下次在看到代码里有用到 context 的,观察下是怎么使用的,肯定逃不出我们讲的几种类型。熟悉之后会发现:context 可能并不完美,但它确实简洁高效地解决了问题。