Context学习
一、什么是Context
Go1.7标准库引入Context,译为“上下文”,准确说是goroutine的上下文,包含goroutine的运行状态、环境、现场等信息。主要用来在goroutine之间传递上下文信息,包括:取消信号、超时时间、截止时间、k-v等 。
二、为什么要有Context
Go常用于写后台服务,通常每一个请求都会启动若干个goroutine同时工作:有些拿数据,有些调用下游接口获取相关数据……
这些goroutine需要共享这个请求的基本数据,例如登录的token,处理请求的最大超时时间(如果超过此值再返回数据,请求方因为超时接收不到)等等。当请求被取消或者处理时间太长(可能是使用者关闭了浏览器或者超过了请求方规定的超时时间),请求方直接放弃了这次请求结果。这时,所有为这个请求工作的goroutine需要快速退出,因为他们的工作不再被需要。相关goroutine退出后,系统就可以回收资源。
context包就是为了解决上述问题而开发的:在一组goroutine之间传递共享的值、取消信号、deadline……
一句话概述:context用来解决goroutine之间退出通知、元数据传递的功能。
另外,像 WithCancel
、WithDeadline
、WithTimeout
、WithValue
这些创建函数,实际上是创建了一个个的链表结点而已。我们知道,对链表的操作,通常都是 O(n)
复杂度的,效率不高。
那么,context 包到底解决了什么问题呢?答案是:cancelation
。仅管它并不完美,但它确实很简洁地解决了问题。
三、如何使用Context
context使用非常方便,源码对外提供了一个创建根节点context的函数:func Background() Context
。这是一个空的context,它不能被取消,没有值,也没有超时时间。
有了根节点,又提供四个函数创建子节点context:
context会在函数传递间传值。只需要在适当的时间调动cancel函数向goroutine发出取消信号或者调用Value函数取出context中的值。
官方博客有如下几点使用建议:
-
不要将Context塞到结构体中。直接将Context类型作为参数的第一参数,并且一般命名为ctx。
-
不要向函数传入一个nil的context,如果实在不知道传什么,标准库给你准备好了一个context:todo。
-
不要把本应该作为函数参数的类型塞到context中,context存储的应该是一些共同的数据。例如:登录的session、cookie等。
-
同一个context可能会被传递到多个gorourine,别担心,context是并发安全的。
3.1 传递共享的数据
对于web服务端开发,往往希望将一个请求处理的整个过程串起来,这就非常依赖于Thread Local(对于Go可理解为单个协程所独有)的变量,而Go语言没有这个概念,因此需要在函数调用的时候传递context。
3.2 取消goroutine
有如下一个场景:打开外卖的订单页,地图上显示外卖小哥的位置,而且是每秒更新 1 次。app 端向后台发起 websocket 连接(现实中可能是轮询)请求后,后台启动一个协程,每隔 1 秒计算 1 次小哥的位置,并发送给端。如果用户退出此页面,则后台需要“取消”此过程,退出 goroutine,系统回收资源。
后端可能实现如下:
如果需要实现“取消”功能,并且在不了解 context 功能的前提下,可能会这样做:给函数增加一个指针型的 bool 变量,在 for 语句的开始处判断 bool 变量是发由 true 变为 false,如果改变,则退出循环。
优雅的方式则是使用Context:
注意到WithTimeout函数返回的context和cancelFun是分开的,context本身没有取消函树,这样做的原因是取消函数只能由外层调用,防止子节点context调用取消函数,从而严格控制信息流向:从父节点流向子节点。
3.3 防止goroutine泄露
如下例子,如果不用context取消,goroutine则会泄露:
这是一个可以生成无限整数序列的协程,但如果只需要前五个序列,就会发生goroutine泄露。
使用context改进的例子如下:
四、Context底层实现原理
4.1 整体概述
context代码包并不长,总共500多行
4.2 接口
Context
Context
是一个接口,定义了四个方法,都是幂等的。也就是说连续多次调用同一个方法,得到的结果是相同的。
-
Done()
返回一个channel,可以表示context被取消的信号:当这个channel被关闭时,说明context被取消了 。 -
注意:这是一个只读的channel。我们知道,读一个关闭的channel会读出相应类型的零值。并且源码没有地方会向这个channel里面写入值。换句话说在子协程里读这个channel,除非被关闭,否则读不出任何东西。当子协程从channel里面读出了零值后,就可以做一些收尾工作,尽快退出。
-
Err()
返回一个错误,表示channel被关闭的原因。例如被取消还是超时。 -
Deadline()
返回context的截止时间,通过此时间,函数就可以决定是否进行接下来的操作,如果时间太短,就可以不往下做了,否则浪费系统资源。当然也可以用这个deadline来设置一个I/O操作的超时时间。 -
Value()
获取之前设置的key对应的value。
canceler
实现了上面定义的两个方法的Context,就表明该Context是可取消的。源码中有两个类型实现了canceler接口:*cannelCtx
和*timerCtx
。注意:加*表明是这两个结构体的指针实现了canceler接口。
Context接口设计成这个样子的原因:
- “取消”操作应该是建议性,而非强制性
caller不应该去关心、干涉callee的情况,决定如何以及何时return是callee的责任。caller只需发送“取消”信息,callee根据收到的信息来做进一步的决策,因此接口没有定义cancel方法。
- “取消”操作应该可传递
“取消”某个函数时,和它相关联的其它函数也应该“取消”。因此,Done()
方法返回一个只读的channel,所有相关函数监听此channel。一旦channel关闭,通过channel的“广播机制”,所有监听者都能收到。
4.3 结构体
emptyCtx
源代码中定义了Context后,给出了一个实现:
每个函数的实现都非常简单,要么直接返回,要么返回nil。**这实际上是一个空的context,永远不会被cancel,没有存储值,也没有deadline。**它被包装成:
通过下面两个导出函数对外公开:
background通常在main函数中,作为所有context的根节点。
todo通常用在并不知道传递什么context的情形。 例如,调用一个需要传递context参数的函数,手头并没有其他context可以传递,就可以传递todo。这常常发生在重构进行中,给一些函数添加一个Context参数,但不知道传递什么,可以暂时用todo占一个位置。
cancelCtx
这是一个重要的context:
这是一个可以取消的Context,实现了canceler接口。它直接将接口Context作为它的一个匿名字段,这样它就可以被看成一个Context。
- Done()方法的实现
c.done是“懒汉式”创建,只有调用了Done()方法的时候才会被创建。函数返回的是一个只读的channel,而且没有地方向这个channel里面写数据。所以直接调用读这个channel,协程会被block住,一般通过搭配select来使用。一旦关闭,就会立即读出零值。
- Err()方法和String()方法实现简单,推荐看源码
- cancel()方法实现
cancel()
方法的**功能就是关闭channel:c.done;递归地取消它的所有子节点;从父节点删除自己。**达到的效果是通过关闭channel,将取消信号传递给它的所有子节点。goroutine接收到取消信号的方式就是select语句中读c.done被选中。
- 创建一个可取消的context
这是一个暴露给用户的方法,传入一个父Context(通常是一个
background
,作为根节点),返回新建的context。当WithCancel
函数返回的CancelFunc被调用或者父节点的done channel被关闭(父节点的CancelFunc被调用),此context的done channel也会被关闭。
注意传给WithCancel方法的参数,前者是true表示取消的时候,需要将自己从父节点里面删除。第二个参数则是一个固定的取消错误类型:
var Canceled = errors.New("context canceles")
。
valueCtx
它实现了两个方法:
由于它直接将 Context 作为匿名字段,因此仅管它只实现了 2 个方法,其他方法继承自父 context。但它仍然是一个 Context,这是 Go 语言的一个特点。
创建valueCtx的函数:
对 key 的要求是可比较,因为之后需要通过 key 取出 context 中的值,可比较是必须的。
通过层层传递context,最终形成一棵树:
和链表有点像,只是它的方向相反:Context 指向它的父节点,链表则指向下一个节点。通过 WithValue 函数,可以创建层层的 valueCtx,存储 goroutine 间可以共享的变量。
取值的过程,实际上是一个递归查找的过程:
func (c *valueCtx) Value(key any) any {
if c.key == key {
return c.val
}
return value(c.Context, key)
}
func value(c Context, key any) any {
for {
switch ctx := c.(type) {
case *valueCtx:
if key == ctx.key {
return ctx.val
}
c = ctx.Context
case *cancelCtx:
if key == &cancelCtxKey {
return c
}
c = ctx.Context
case *timerCtx:
if key == &cancelCtxKey {
return &ctx.cancelCtx
}
c = ctx.Context
case *emptyCtx:
return nil
default:
return c.Value(key)
}
}
}
它会顺着链路一直往上找,比较当前节点的 key 是否是要找的 key,如果是,则直接返回 value。否则,一直顺着 context 往前,最终找到根节点(一般是 emptyCtx),直接返回一个 nil。所以用 Value 方法的时候要判断结果是否为 nil。
因为查找方向是往上走的,所以,父节点没法获取子节点存储的值,子节点却可以获取父节点的值。
WithValue
创建 context 节点的过程实际上就是创建链表节点的过程。两个节点的 key 值是可以相等的,但它们是两个不同的 context 节点。查找的时候,会向上查找到最后一个挂载的 context 节点,也就是离得比较近的一个父节点 context。所以,整体上而言,用 WithValue
构造的其实是一个低效率的链表。