golang底层解析之Context
什么是context
context,中文译作“上下文”,它是 goroutine 的上下文,包含 goroutine 的运行状态、环境、现场等信息。
context 主要用来在 goroutine 之间传递上下文信息,包括:取消信号、超时时间、截止时间、k-v 等。
context.Context 类型的值可以协调多个 groutine 中的代码执行“取消”操作,并且可以存储键值对。最重要的是它是并发安全的。
与它协作的 API 都可以由外部控制执行“取消”操作,例如:取消一个 HTTP 请求的执行。
Context底层解析
整体概览
名称 | 类型 | 作用 |
---|---|---|
CancelFunc:func() | Func | 取消,关闭函数 |
Context | interface | 定义了Context的接口方法 |
cancelCtx | struct | 可以取消的Context |
canceler | interface | Context 取消接口,定义了两个方法 |
deadlineExceededError | struct | 定义了Context返回错误 |
emptyCtx | struct | 实现了 Context 接口,其实是个空的 context |
timerCtx | struct | 实现了 Context 接口,有超时取消机制 |
valueCtx | struct | 实现了 Context 接口,用于存储 k-v 键值对 |
Background | Func | 空的Context,常为根context |
TODO | Func | 返回一个空的 context,常用于 未知context使用场景 |
WithCancel | Func | 基于父 context,生成一个可以取消的 子context |
WithDeadline | Func | 基于父 context,创建一个按日期超时取消的子 context |
WithTimeout | Func | 基于父 context,创建一个有 时间超时取消的 子context |
WithValue | Func | 基于父 context,创建一个存储 k-v 对的子 context |
init | Func | 包初始化 |
newCancelCtx | Func | 创建一个可以取消的context |
parentCancelCtx | Func | 找到最近的可取消的父节点 |
propagateCancel | Func | 向下传递 context 节点间的取消关系 |
removeChild | Func | 去掉父节点的子节点 |
接口
Context
1 | type Context interface { |
Context
的方法都是幂等,线程安全
的,所以连续多次调用同一个方法,得到的结果都是相同的:Deadline
方法返回当前Context
是否有超时机制和被取消的时间,也就是完成工作的截止日期Done
方法返回一个 Channel,这个 Channel 会在当前工作完成或者上下文被取消之后返回,用与提醒协程关闭,进行收尾工作,尽快退出,多次调用Done
方法会返回同一个 ChannelErr
方法会返回当前Context
结束的原因,它只会在Done
返回的 Channel 被关闭时才会返回非空的值:- 如果当前
Context
被取消就会返回Canceled
错误; - 如果当前
Context
超时就会返回DeadlineExceeded
错误;
- 如果当前
Value
方法会从Context
中返回键对应的值,对于同一个上下文来说,多次调用Value
并传入相同的Key
会返回相同的结果,这个功能可以用来传递请求特定的数据;
canceler
1 | type canceler interface { |
Context
取消的接口方法,源码中有两个类型实现了 canceler 接口:*cancelCtx
和*timerCtx
: 1.
Done
方法返回一个只读的 channel,所有相关函数监听此 channel。一旦 channel 关闭,通过 channel 的“广播机制”,所有监听者都能收到。 2.
cancel
方法当removeFromParent
为 true 时,会将当前节点的 context 从父节点 context中删除,err
为取消原因
结构体
emptyCtx
1 | type emptyCtx int |
emptyCtx
是一个空的 context,永远不会被 取消,没有存储值,也没有 超时。它被包装成:
1
2
3
4var (
background = new(emptyCtx)
todo = new(emptyCtx)
)下面两个导出的函数对外公开:
1
2
3
4
5
6
7func Background() Context {
return background
}
func TODO() Context {
return todo
}- ``Background
和
TODO方法在某种层面上看其实也只是互为别名,两者没有太大的差别,不过
context.Background()是上下文中最顶层的默认值,所有其他的上下文都应该从
context.Background()` 演化出来。 - 我们应该只在不确定时使用
context.TODO()
,在多数情况下如果函数没有上下文作为入参,我们往往都会使用context.Background()
作为起始的Context
向下传递。
- ``Background
cancelCtx
1 | type cancelCtx struct { |
cancelCtx
实现了 canceler
接口。它直接将接口 Context
作为它的一个匿名字段,这样,它就可以被看成一个 Context
。
Done()方法
1
2
3
4
5
6
7
8
9func (c *cancelCtx) Done() <-chan struct{} {
c.mu.Lock()
if c.done == nil { // 懒汉式创建
c.done = make(chan struct{})
}
d := c.done
c.mu.Unlock()
return d
}c.done
是“懒汉式”创建,只有调用了 Done() 方法的时候才会被创建String
和Err
方法1
2
3
4
5
6
7
8
9
10func (c *cancelCtx) Err() error {
c.mu.Lock()
err := c.err
c.mu.Unlock()
return err
}
func (c *cancelCtx) String() string {
return fmt.Sprintf("%v.WithCancel", c.Context)
}cancel()
方法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
29func (c *cancelCtx) cancel(removeFromParent bool, err error) {
if err == nil {
panic("context: internal error: missing cancel error")
}
c.mu.Lock()
if c.err != nil { // 已经被取消
c.mu.Unlock()
return
}
// 设置取消原因
c.err = err
// 关闭channel,通知协程关闭
if c.done == nil {
c.done = closedchan
} else {
close(c.done)
}
for child := range c.children {
// 取消所有子节点
child.cancel(false, err)
}
// 将子节点设为空
c.children = nil
c.mu.Unlock()
if removeFromParent {
// 从父节点中移除自己
removeChild(c.Context, c)
}
}removeChild
1
2
3
4
5
6
7
8
9
10
11func removeChild(parent Context, child canceler) {
p, ok := parentCancelCtx(parent)
if !ok {
return
}
p.mu.Lock()
if p.children != nil { // 如果父节点的子节点不为空
delete(p.children, child) // 将子节点从父节点中移除
}
p.mu.Unlock()
}removeChild
方法当removeFromParent
为true时,将自己从父节点中移除cancel()
方法的功能:1. 关闭 channel:c.done; 2. 递归地取消它的所有子节点; 3. 从父节点从删除自己。 4. 通过关闭 `channel`,将取消信号传递给了它的所有子节点。`goroutine `接收到取消信号的方式就是 select 语句中的读 c.done 被选中
timerCtx
1 | type timerCtx struct { |
timerCtx
基于 cancelCtx
,只是多了一个 time.Timer
和一个 deadline
。Timer
会在 deadline
到来时,自动取消 context
。
cancel
方法1
2
3
4
5
6
7
8
9
10
11
12
13
14
15func (c *timerCtx) cancel(removeFromParent bool, err error) {
// 直接调用 cancelCtx 的取消方法
c.cancelCtx.cancel(false, err)
if removeFromParent {
// 从父节点中删除子节点
removeChild(c.cancelCtx.Context, c)
}
c.mu.Lock()
if c.timer != nil {
// 关掉定时器,这样,在deadline 到来时,不会再次取消
c.timer.Stop()
c.timer = nil
}
c.mu.Unlock()
}Deadline
方法1
2
3func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
return c.deadline, true
}获取
timerCtx
超时时间
valueCtx
1 | type valueCtx struct { |
String
和Value
方法1
2
3
4
5
6
7
8
9
10
11// Context信息
func (c *valueCtx) String() string {
return fmt.Sprintf("%v.WithValue(%#v, %#v)", c.Context, c.key, c.val)
}
// 获取指定的键值
func (c *valueCtx) Value(key interface{}) interface{} {
if c.key == key {
return c.val
}
return c.Context.Value(key)
}
和链表有点像,只是它的方向相反:Context
指向它的父节点,链表则指向下一个节点。通过 WithValue
函数,可以创建层层的 valueCtx
,存储 goroutine
间可以共享的变量。
1 | func (c *valueCtx) Value(key interface{}) interface{} { |
它会顺着链路一直往上找,比较当前节点的 key
是否是要找的 key,如果是,则直接返回 value。否则,一直顺着 context 往前,最终找到根节点(一般是 emptyCtx),直接返回一个 nil。所以用 Value 方法的时候要判断结果是否为 nil。
WithValue
创建 context 节点的过程实际上就是创建链表节点的过程。两个节点的 key 值是可以相等的,但它们是两个不同的 context 节点。查找的时候,会向上查找到最后一个挂载的 context 节点,也就是离得比较近的一个父节点 context。所以,整体上而言,用 WithValue
构造的其实是一个低效率的链表。
因为查找方向是往上走的,所以,父节点没法获取子节点存储的值,子节点却可以获取父节点的值。
因为它是链表查询的方式,所有时间复制度为O(n)
,所以不推荐大量使用
函数
WithCancel
1 | func WithCancel(parent Context) (ctx Context, cancel CancelFunc) { |
- 传入一个父的Context,返回一个新子
Context
和CancelFunc
取消方法 - 当
WithCancel
函数返回的 CancelFunc 被调用或者是父节点的 done channel 被关闭(父节点的 CancelFunc 被调用),此 context(子节点) 的 done channel 也会被关闭。
propagateCancel
1 | func propagateCancel(parent Context, child canceler) { |
propagateCancel
方法的作用就是向上寻找可以“挂靠”的“可取消”的 context,并且“挂靠”上去。这样,调用上层 cancel 方法的时候,就可以层层传递,将那些挂靠的子 context 同时“取消”。
这里着重解释下为什么会有 else 描述的情况发生。else
是指当前节点 context 没有向上找到可以取消的父节点,那么就要再启动一个协程监控父节点或者子节点的取消动作。
select 语句里的两个 case说明
1
2
3
4
5
6
7go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err())
case <-child.Done():
}
}()- 第一个 case 说明当父节点取消,则取消子节点。如果去掉这个 case,那么父节点取消的信号就不能传递到子节点。
- 第二个 case 是说如果子节点自己取消了,那就退出这个 select,父节点的取消信号就不用管了。如果去掉这个 case,那么很可能父节点一直不取消,这个 goroutine 就泄漏了。当然,如果父节点取消了,就会重复让子节点取消,不过,这也没什么影响嘛。
parentCancelCtx
1 | func parentCancelCtx(parent Context) (*cancelCtx, bool) { |
这里只会识别三种 Context 类型:*cancelCtx
,*timerCtx
,*valueCtx
。若是把 Context 内嵌到一个类型里,就识别不出来了。
WithTimeout
1 | func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) { |
WithTimeout
函数直接调用了 WithDeadline
,传入的 deadline 是当前时间加上 timeout 的时间,也就是从现在开始再经过 timeout 时间就算超时
WithDeadline
1 | func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc) { |
WithValue
1 | func WithValue(parent Context, key, val interface{}) Context { |
创建valueCtx
Context使用案例
共享数据
1 | package main |
超时取消和协程安全关闭
1 | func main() { |
Context使用建议
- 不要将 Context 塞到结构体里。直接将 Context 类型作为函数的第一参数,而且一般都命名为 ctx。
- 不要向函数传入一个 nil 的 context,如果你实在不知道传什么,标准库给你准备好了一个 context:todo。
- 不要把本应该作为函数参数的类型塞到 context 中,context 存储的应该是一些共同的数据。例如:登陆的 session、cookie 等。
- 同一个 context 可能会被传递到多个 goroutine,别担心,context 是并发安全的。