golang中context的Q&A
标准库 context Q&A
参考文档
context如何被取消
1. context.Context讲解
type Context interface {
// 返回context是否会被取消,以及自动取消时间
Deadline() (deadline time.Time, ok bool)
// 当context被取消了,或者到了最后的deadline, 返回一个被关闭的channel
Done() <-chan struct{}
// 在channel Done()关闭后,返回context取消原因
Err() error
// 获取key对应的value
Value(key interface{}) interface{}
}
2. Context是一个接口,定义了4个方法,它们都是幂等的,也就是说连续多次调用同一个方法,得到的结果是相同的
3. Done()返回一个channel, 可以表示context被取消的信号,当这个channel被关闭了,说明context被取消了
注意这里是一个只读的channel, 我们知道读取一个已关闭的channel,会读出响应类型的零值,并且源码里没有地方
会向这个channel塞入值,换句话说,这个一个read-only的channel,因此在子协程里读取这个channel,
除非被关闭,否则读取不出来任何东西, 也正是利用了这一点,子协程从channel中读出值(零值)后,就可以收尾退出了
4. Err() 返回一个错误,表示channel被关闭的原因,例如是被取消,还是超时
5. Deadline() 返回context的截止时间
6. Value() 获取之前设置的key对应的value
7. context.Background()通常用在main函数中,作为所有context的根节点
context.TODO()通常用在字并不知道传递什么context的情形,例如调用一个需要传递context的函数,
你手头并没有其它context可以传递,这时就可以传递TODO(),这常常发生在重构中,给一些函数添加了一个Context参数
但不知道要传递什么,就用TODO占个位子,最终要换成其他context
context是什么
1. go1.7标准库引入context,中文名就是"上下文", 准确说它是goroutine的上下文,包含goroutine的运行状态、环境、现场等信息
main goroutine 通知 child goroutine退出任务的案例
func main() {
messages := make(chan int, 10)
done := make(chan bool) // 创建一个无缓冲只读channel
go func() {
var ticker = time.NewTicker(time.Second)
for _ = range ticker.C{ // 遍历只读channel
select {
case <-done:
fmt.Println("main goroutine 通知 child goroutine结束了...")
return
default:
fmt.Println("messages: ", <-messages)
}
}
}()
for i := 0; i < 10; i++{
messages <- i
}
time.Sleep(time.Second * 5) // 主协程对上面的子协程有5秒的超时控制
close(done) // 关闭 channel
time.Sleep(time.Second)
fmt.Println("main goroutine 结束了...")
}
2. 上述例子中定义了一个buffer为0的channel done, 子协程运行这定时任务,如果主协程需要在某个时刻发送消息通知
子协程中断任务并退出,那么就可以让子协程监听这个done channel, 一旦主协程关闭done channel,那么子协程就可以
退出了,这样实现了主协程通知子协程的需求,但是还是有限的
3. 假如我们可以在简单的通知上附加额外的信息来控制取消,为什么取消,或者有一个它必须要完成的最终期限
更或者有多个取消选项,我们需要额外的信息来判断选择执行哪个取消选项
4. 考虑下面这种情况,假如主协程有多个任务,1,2,m,主协程对这些任务有超时控制,
而其中任务1又有多个子任务1,2,n, 任务1对这些子任务也有超时控制,
那么这些子任务即要感知主协程的取消信号,也要感知任务1的取消信号,
5. 如果还是使用done channel的方法,我们需要定义两个done channel, 子任务需要监听这两个done channel
这样好像也还行,但是如果层级更深的话,这些子任务还有子任务的话,那么使用done channel的方法将变得非常繁琐且混乱
6. 我们需要优雅的方案来实现这一种机制:
* 上层任务取消后,所有的下层任务都会被取消
* 中间某一层任务取消后,只会将当前任务的下层任务全部取消,而不会影响上层任务及同级任务
这个时候,context就派上用场了
7. Context接口包含四个方法:
Deadline返回绑定当前context的任务被取消的截止时间;如果没有设定期限,将返回ok == false。
Done 当绑定当前context的任务被取消时,将返回一个关闭的channel;如果当前context不会被取消,将返回nil。
Err 如果Done返回的channel没有关闭,将返回nil;如果Done返回的channel已经关闭,将返回非空的值表示任务结束的原因。如果是context被取消,Err将返回Canceled;如果是context超时,Err将返回DeadlineExceeded。
Value 返回context存储的键值对中当前key对应的值,如果没有对应的key,则返回nil。
8. 可以看到Done()方法返回的channel用来传递结束信号以中断当前任务,
Deadline()方法指示一段时间后当前goroutine是否会被取消,
以及一个Err()方法用来解释goroutine被取消的原因,而Value用于获取当前任务树的额外信息,
9. Background()方法和TODO()方法生成的context其实是一致的,那么我们何时调用哪个呢
background通常用于主函数、初始化、以及测试中使用,作为一个顶层的context,
也就是说一般我们创建的context都是基于bakground
TODO()是在不确定使用什么context的时候使用
两种不同功能的基础context类型,valueCtx、cancelCtx
type valueCtx struct {
Context
key, val interface{}
}
func (c *valueCtx) Value(key interface{}) interface{} {
if c.key == key {
return c.val
}
return c.Context.Value(key)
}
1. valueCtx嵌入了Context变量来表示父节点context,表示当前context继承了父context的所有信息
valueCtx还携带了一组键值对,也就是说这种context可以携带额外的信息,valueCtx实现了Value方法
用以在context链路上获取key对应的值,如果当前context不存在需要的key,会沿着context链向上
寻找key对应的值,直到根节点
WithValue
1. WithValue用以向context添加键值对,
func WithValue(parent Context, key, val interface{}) 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}
}
2. 这里添加键值对不是在原结构体上直接添加,而是以此context作为父节点,重新黄建一个valueCtx子节点
将键值对添加到子节点上,由此形成一条context链,获取value的过程就是在此context链上由尾部向前搜索
cancelCtx
type cancelCtx struct {
Context
mu sync.Mutex // protects following fields
done atomic.Value // of chan struct{}, created lazily, closed by first cancel call
children map[canceler]struct{} // set to nil by the first cancel call
err error // set to non-nil by the first cancel call
}
1. cancelCtx跟valueCtx类似,cancelCtx结构体中也有一个变量context作为父节点,
变量done表示一个channel, 用来表示传递关闭信号,children表示一个map,用来存储当前context节点下的子节点
err存储错误信息表示被取消的原因
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 {
c.mu.Unlock()
return // already canceled
}
// 设置取消原因
c.err = err
d, _ := c.done.Load().(chan struct{})
// 设置一个关闭的channel或者将done channel关闭,用以发送关闭的信号
if d == nil {
c.done.Store(closedchan)
} else {
close(d)
}
// 将子节点context依次取消
for child := range c.children {
// NOTE: acquiring the child's lock while holding parent's lock.
child.cancel(false, err)
}
c.children = nil
c.mu.Unlock()
// 将当前context节点从父节点上移除
if removeFromParent {
removeChild(c.Context, c)
}
}
2. 可以发现cancelCtx类型的变量其实也是canceler类型,因为cancelCtx实现了canceler接口,
Done()方法返回的是通知goroutine取消的信号通道,Err()方法返回的是被取消的原因
cancelCtx类型的context在调用cancel方法时,会设置取消原因,将done channel设置为一个关闭的channel
或者关闭channel, 然后将子节点context依次取消,如果有需要还会将当前节点从父节点直接移除
WithCancel
1. WithCancel函数用来创建一个可取消的context, 即cancelCtx类型的context,
WithCancel()方法返回一个cancelCtx和CancelFunc, 调用CancelFunc即可触发cancel操作,看源码
type CancelFunc func()
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
if parent == nil {
panic("cannot create context from nil parent")
}
c := newCancelCtx(parent)
propagateCancel(parent, &c)
return &c, func() { c.cancel(true, Canceled) }
}
TimerCtx
1. timerCtx是一种基于cancelCtx的context类型,从字面上就可以看出,这是一种可以定时取消的context类型
timerCtx增加了两个字段,timer和deadline, timer:计时器,deadline:截止日期
2. timerCtx内部使用cancelCtx实现取消,另外使用定时器timer和过期时间deadline实现定时取消的功能,
timerCtx在调用cancel方法时,会先将内部的cancelCtx取消,如果需要则将自己从cancelCtx祖先节点上移除
最后取消计时器
WithDeadline
1. 如果父节点parent有过期时间,并且过期时间<设置的时间d,那么新建的子节点context无须设置过期时间
使用WithCancel创建一个可取消的context即可
2. 否则就利用parent和过期时间d创建一个定时取消的timerCtx, 并建立context与可取消context祖先节点的
取消关联关系,接下来判断当前时间具体过期时间d的时长dur
3. 如果dur<0,表明当前已经过了过期,则直接取消新的timerCtx
4. 为新建的timerCtx设置定时器,一旦达到过期时间就取消当前的timerCtx
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
if parent == nil {
panic("cannot create context from nil parent")
}
// 如果父节点parent有过期时间,并且过期时间<设置的时间d,那么新建的子节点context无须设置过期时间
// 使用WithCancel创建一个可取消的context即可
if cur, ok := parent.Deadline(); ok && cur.Before(d) {
// The current deadline is already sooner than the new one.
return WithCancel(parent)
}
c := &timerCtx{
cancelCtx: newCancelCtx(parent),
deadline: d,
}
propagateCancel(parent, c)
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()
// 为新建的timerCtx设置定时器,一旦达到过期时间就取消当前的timerCtx
if c.err == nil {
c.timer = time.AfterFunc(dur, func() {
c.cancel(true, DeadlineExceeded)
})
}
return c, func() { c.cancel(true, Canceled) }
}
WithTimeout
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}
1. 与WithDeadline类似,WithTimeout也是创建一个定时取消的context,只不过WithDeadline是接收一个过期时间点
而WithTimeout是接收一个过期时长,看源码也知道,WithTimeout也是调用的WithDeadline
Context的使用
1. 使用context实现文章开头done channel的例子示范一下如何更优雅的实现协程间取消信号的同步
func main() {
messages := make(chan int, 10)
for i := 0; i < 10; i++{
messages <- i
}
// 创建带过期时间的context
ctx, cancel := context.WithTimeout(context.Background(), time.Second * 5)
go func(ctx context.Context) {
ticker := time.NewTicker(time.Second)
for _ = range ticker.C{ // 遍历通道,一秒执行一次
select{
case <-ctx.Done():
fmt.Println("child goroutine 结束了...")
return
default:
fmt.Println("receive message: ", <-messages)
}
}
}(ctx)
defer close(messages)
defer cancel() // 此语句可以省略,因为context是带过期时间的,不需要手动取消
select{
case <-ctx.Done():
time.Sleep(time.Second)
fmt.Println("main goroutine 结束了")
}
}
2. 这个例子中,只要让子协程监听主协程传入的ctx,一旦ctx.Done()方法返回空channel, 子线程即可取消执行任务
但是这个例子还无法展现context的传递取消信息的强大优势
3. net/http包的源码里在实现http server就用到了context, 简单分析下
4. 重点:
这样处理的目的主要有以下几点:
1. 一旦请求超时,即可中断当前请求;
2. 在处理构建response过程中如果发生错误,可直接调用response对象的cancelCtx方法结束当前请求;
3. 在处理构建response完成之后,调用response对象的cancelCtx方法结束当前请求。
* 在整个server处理流程中,使用了一条context链贯穿Server、Connection、Request,不仅将上游的信息共享给下游任务,
* 同时实现了上游可发送取消信号取消所有下游任务,而下游任务自行取消不会影响上游任务。
context是什么?
1. context主要用来在goroutine之间传递上下文信息,包括取消信号,超时时间,截止时间,k-v等
2. 随着context包的引入,标准库中很多接口也增加了context参数,例如database/sql,
context几乎成为了并发控制和超时控制的标准做法
context有什么用?
1. go通常用来写后台服务,只需要几行代码就可以写一个http server,在go的server里
通常每来一个请求都会启动若干个goroutine同时工作,有些去数据库拿数据,有些调用下游接口获取相关数据
这些goroutine需要共享这个请求的基本数据,例如登录的token,处理请求的最大超时时间
当请求被取消或者超时,所有为这个请求工作的goroutine需要快速退出,系统就可以回收相关资源
2. go语言中的server实际上是一个"协程模型", 也就是说一个协程处理一个请求,例如业务高峰期,某个下游服务器响应变慢
而当前系统的请求又没有超时控制,或者说超时时间设置的过大,那么等待下游服务器返回数据的协程会越来越多,
协程也是需要消耗资源的,如果携程数量激增,内存暴涨,甚至导致服务不可用,更严重会导致雪崩效应,整个服务对外不可用,P0级别的事故
3. 上面说的P0级别事故,可以通过设置下游服务器最大处理时间就可以避免,给下游服务器设置timeout=5ms,
如果这个时间没有接收到数据,就直接返回给客户端一个默认值或错误,
4. context包就是为了解决上面这些问题而开发的,在一组goroutine之间传递共享的值,取消信号,超时控制,截止日期
5. 用简练的话来说,在go里面,我们不能直接杀死协程,需要通过channel+select的方法来关闭协程,
但是在某些场景下,例如一个请求衍生了很多个协程,这些协程间是相互关联的,需要共享一些全局变量,有共同的deadline
而且可以同时被关闭,再用channel+select就会比较麻烦,这是就可以通过context来实现
* 一句话解决:context用来解决goroutine之间 退出通知、元数据传递 的功能
6. 【引申1】举例说明context在实际项目中如何使用
context会在函数传递间传递,只需要在适当的时间调用cancel函数就可以向goroutine发出取消信号
或者调用Value函数取出context中的值
7. context使用注意4个事项
1. 不要将context塞入结构体里,直接将context作为函数的第一参数,而且一般命名为ctx
2. 不要向函数传入一个nil context,如果你是在不知道传递什么,标准库给提供好了一个context:todo
3. 不要把本应该作为函数参数的类型放入到context中,context应该存储一些共同的数据,例如登录的session、cookie等
4. 同一个context有可能会被传入到多个goroutine,别担心,context是并发安全的
context可以传递共享的数据
1. 对于web服务端开发,往往希望将一个请求处理的整个过程串起来,这非常依赖于Thread Local(对于go可以理解为单个协程所独有的)
变量,go语言中没有Thread Local这个概念,所以在调用函数是需要传递context
func main() {
bgCtx := context.Background()
process(bgCtx)
vCtx := context.WithValue(bgCtx, "traceId", "123456")
process(vCtx)
}
func process(ctx context.Context){
if traceId, ok := ctx.Value("traceId").(string); ok{
fmt.Printf("process over, traceId = %s\n", traceId)
} else {
fmt.Println("process over, no traceId")
}
}
/*
process over, no traceId
process over, traceId = 123456
*/
context可以信号通知取消goroutine
1. 设想一个场景,打开外卖订单,上面显示外卖小哥的位置,而且每秒更新一次,app向后端发起websocket链接后,
后台启动一个协程,每隔一秒计算一次小哥的位置并发送给前端,如果用户退出订单页面,后台需要取消此过程,
退出goroutine,系统回收资源
func Perform(ctx context.Context) {
for {
calculatePos()
sendResult()
select {
case <-ctx.Done():
// 被取消,直接返回
return
case <-time.After(time.Second):
// block 1 秒钟
}
}
}
ctx, cancel := context.WithTimeout(context.Background(), time.Hour)
go Perform(ctx)
// ……
// app 端返回页面,调用cancel 函数
cancel()
2. 注意一个细节,WithTimeout函数返回的context和cancel是分开的,context本身并没有取消函数,
这样做的原因是取消函数只能由外层函数调用,防止子节点contxt调用取消函数,从而严格控制信息的流向
由父节点context流向子节点context
防止goroutine泄漏
1. 举一个例子,如果不用context取消,goroutine就会泄漏的例子
func gen() <-chan int {
ch := make(chan int)
var n int
go func() {
for {
ch <- n
n++
time.Sleep(time.Second)
}
}()
return ch
}
func main() {
for v := range gen(){
fmt.Println(v)
if v == 5{
break
}
}
time.Sleep(time.Second * 5)
}
2. 这是一个可以无限生成整数的协程,但如果我们只要产生的前5个数,那么就会发生goroutine泄漏
当n == 5, 直接break掉,那么gen函数的协程就会执行无限循环,发生了goroutine泄漏
3. 用context改进这个例子
func gen(ctx context.Context) <-chan int {
ch := make(chan int)
go func() {
var n int
for {
select{
case <-ctx.Done(): // 监听主协程发出的退出信号
return
case ch <- n:
n++
time.Sleep(time.Millisecond * 200)
}
}
}()
return ch
}
func main() {
cancelCtx, cancelFunc := context.WithCancel(context.Background())
defer cancelFunc() // 避免其它地方忘记cancel,并且重复调用不影响
for v := range gen(cancelCtx){
fmt.Println(v)
if v == 5{
cancelFunc() // 取消所有子协程, 然后退出循环
break
}
}
}
4. 增加一个context, 在break前调用cancel函数,取消子goroutine, gen()函数在接收到取消信号后,
直接退出,系统回收资源
context.Value的查找过程是怎样的
type valueCtx struct {
Context
key, val interface{}
}
func (c *valueCtx) String() string {
return contextName(c.Context) + ".WithValue(type " +
reflectlite.TypeOf(c.key).String() +
", val " + stringify(c.val) + ")"
}
func (c *valueCtx) Value(key interface{}) interface{} {
if c.key == key {
return c.val
}
return c.Context.Value(key)
}
1. 由于它直接将Context作为匿名字段,因此尽管它只实现了两个方法String()和Value(),其它方法继承自Context
但它仍然是一个context,这是go语言的一个特点
2. 创建valueCtx函数:
func WithValue(parent Context, key, val interface{}) 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}
}
3. 对key的要求是可比较,因为之后要通过key取出context中的值,可比较是必须的,通过层层传递context,
最终形成一棵树
4. 和链表有点像,只是它的方向相反,Context指向它的父节点,而链表指向下一个节点,
通过WithValue函数可以创建层层的valueCtx,存储goroutine间可以共享的变量,
取值的过程,实际上是一个递归查找的过程
func (c *valueCtx) Value(key interface{}) interface{} {
// 取值的过程,实际上是一个递归查找的过程,先从当前valueCtx中的key查找
// 如果不存在就从它的父节点的Context中去查找,层层递归,直到找到或nil为止
if c.key == key {
return c.val
}
return c.Context.Value(key)
}
5. 它会顺着链路一直往上找,比较当前节点的key是否是要查找的key,如果是,则直接返回value,
否则一直顺着context往前,最终找到根节点(一般是emtpyCtx),直接返回一个nil,
所以用Value方法的时候要判断,结果是否为nil
6. 因为查找方向是往上走的,所以父节点没法获取子节点的值,子节点却可以获取父节点的值,
7. WithValue: 创建context节点的过程实际上就是创建链表节点的过程,两个节点的key值是可以相等的,
但他们是两个不同的context节点,查找的时候会先从当前节点查找,如果找不到一直找到最后根节点,
整体而言,用WithValue构造的其实是一个低效率的链表
8. 注意:如果能用函数参数传递的尽量不要使用context传参,context尽量传递一些共享的变量如session,cookie等