func (p *Pool) Get(ctx context.Context)(net.Conn,error){
//...
//没有空闲的连接,并且已经达到最大连接数情况
//阻塞等待其他客户端归还conn
select{
//如果token返回,说明有其他客户端归还conn
case <-p.token:
//返回conn
//...
case <-ctx.Done()
//上游调用context#cancel()取消请求,返回
return
如上,如果没有context
的取消机制,则Get
会一直阻塞直到有客户端归还连接,这种没有超时机制的请求对于线上的系统无疑是一种灾难,而通过context
提供的取消功能,可以方便的管理异步阻塞的请求。
context
被设计为线程安全的结构体,可以传播在多个goroutine
中,同时对于在需要多个不同context
的场景,context
提供了parent-children
机制,例如:
服务端收到请求,准备处理请求,服务端预期这个请求的总体处理时间不能超过500ms
第一步需要查询redis
,查询redis
时预期处理时间不能超过100ms
如果redis
查询失败,则第二步需要查询mysql
,查询mysql
时预期处理时间不能超过200ms
第三步请求其他模块,设置超时时间为200ms
返回结果。
对于以上场景,仅用一个context
完全无法满足要求,如果只是简单的设置500ms
,那对于redis
来说,本来100ms
之后就能发现问题,却直接延后到了500ms
,耗时增加了5
倍。
因此对于context
包提供的结构体来说,每个context
可以基于函数传入的context
作为parent
派生出新的context
,派生出的context
与传入的context
具有父子关系,随着调用链的传播与context
的派生,会逐渐形成context
树。
在一颗树种,任何一个节点发起cancel()
方法,其都会传播给所有的子节点。子节点的cancel()
不会导致父节点cancel()
.这种传播机制完美的解决了每个函数需要定制自己的context
的需求。
context
同时提供了Value
方法用来传递请求上下文的参数,例如traceId
。对于Value()
方法来说,子context
可以访问所有父context
的Value()
,但是父context
无法访问子Value()
。
如何使用context
context.Context
是对外暴露的一个接口,具体实现的struct
都没有对外暴漏,而是提供了4种创建函数:
//设置会超时的日期,当超过这个日期后会自动取消请求
WithDeadline(parent Context, d time.Time)
//返回一个可以主动取消的context,通过调用cancel可以取消请求
WithCancel(parent Context) (ctx Context, cancel CancelFunc)
//可以传入一个key-value对,子context可以通过key获取对应的value
//Value主要是传入和request生命周期相同的参数,典型的便是trace
//目前很多log框架都有通过context获取trace的方案
WithValue(parent Context, key, val any)
//可以设置超时时间,当超执行时间超过这个时间会自动取消请求
WithTimeout(parent Context, timeout time.Duration)
在所有的context
构造函数中,都需要传入一个parent
;因此在context
包中提供了两种通用的根节点:
//顶层context,通常作为context的起点。
context.Background()
//当不知道需要使用什么context时,一般用于在开发过程中还没有上下文时,通过TODO作为一个占位符。
context.TODO()
作为context
树的根节点,使用方式如下:
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
context
的使用场景非常简单,但是使用context
需要注意以下几点:
在官方注释中有说明,context
应该作为函数的第一个参数传入,context
不能保存在结构体中,避免goroutine
泄露
对于具有cancel()
功能的context
,应该注意defer cancel()
;因为在context
中可能会启动一个goroutine
来监听取消信号(见下面源码分析),如果函数退出但是没有执行cancel()
,则可能导致goroutine
泄露。
对于Value()
类型context
,子context
是可以获取父context
的key-value
的,但是父context
无法获取子key-value
对于ctx
的父子关系,并不是只有直接父节点才是父节点,而是从根节点到此节点的路径上的所有节点都是其父节点;所有父节点的cancel()
方法都会导致其Done()
方法返回,因为其父节点的方法是通过第三点的Value()
方法实现。
对于Value()
类型,由于子节点可以查找所有父节点的key-value
,因此如果使用简单的string
类型作为其key
,那很容易因为同命名导致被覆盖,而在Value()
的查找中,是通过==
进行判断是否相等,因此可以创建自己的类型作为对应的key
,防止被覆盖:
type myKey struct{}
ctx = context.WithValue(ctx, myKey{},"123")
context 原理
其实context
原理并不难,在没有context
的时候,我们如果想要在系统中实现优雅退出,也会借助channel
:
select{
case job:=<-jobqueue:
//执行任务
doJob(job)
case <-closeChan:
//执行关闭
doClose()
//退出循环
return
context
的核心原理也是channel
,不过context
还加入了parent-children
机制,也就是上面说到的树状结构,使得closeChan
可以在多个goroutine
间传播。
这里简单分析一下cancelCtx
源码,其他原理都是基于cancelCtx
实现。
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
//不允许传入空的parent,即使是顶节点也应该使用context.Backgroud()
if parent == nil {
panic("cannot create context from nil parent")
//创建cancelCtx结构体
c := newCancelCtx(parent)
//将自己加入到父context的child属性中
propagateCancel(parent, &c)
return &c, func() { c.cancel(true, Canceled) }
// newCancelCtx returns an initialized cancelCtx.
func newCancelCtx(parent Context) cancelCtx {
return cancelCtx{Context: parent}
可以看到步骤比较简单:
创建cancelCtx
结构体
调用propagateCancel
将创建的context
加入到parent
children
属性中,便于在parent
调用cancel()
时回调children
的cancel()
函数。
cancelCtx
结构体的属性如下:
type cancelCtx struct {
//parent
Context
//按照golang的规范,mu主要保护下面的属性
mu sync.Mutex
//通过atomic实现不加锁获取最新的channel
done atomic.Value
//子节点
//这里是吧map当做了set使用,为什么使用set而不是通过链表实现?
children map[canceler]struct{}
//通过此标识记录done是否已经close,避免重复close(chan)导致panic
err error
接下来继续看调用propagateCancel
函数将自己加入到parent
的children
属性中:
// propagateCancel arranges for child to be canceled when parent is.
func propagateCancel(parent Context, child canceler) {
//首先检查,如果父ctx返回的channel为nil,则说明父ctx永远不会cancel,则直接返回
//例如emptyCtx,执行Done()方法便会返回nil
done := parent.Done()
if done == nil {
return // parent is never canceled
//再次检查父ctx返回的channel是否已经关闭,如果已经关闭则说明子ctx也应该关闭
select {
case <-done:
//执行子ctx的cancel()方法,这里ctx.cancel()主要是执行close(channel)
child.cancel(false, parent.Err())
return
default:
//通过Value()方法获取父节点
if p, ok := parentCancelCtx(parent); ok {
//如果成功获取,因为父节点和当前子ctx可能不在同一个goroutine中,因此后面的操作需要加锁
p.mu.Lock()
//再次通过err判断父节点是否已经执行过cancel()方法,这一步已经加锁,因此获取到的一定是最新的信息
if p.err != nil {
//如果父节点已经关闭,则调用子节点的cancel方法
child.cancel(false, p.err)
} else {
//否则,尝试初始化父节点的children属性
//可以看到这里使用的是延迟初始化
if p.children == nil {
p.children = make(map[canceler]struct{})
p.children[child] = struct{}{}
p.mu.Unlock()
} else {
//这里的else表示没有成功获取的可以主动回调的父ctx,因此需要主动启动一个goroutine开始监听
//主要是测试使用
atomic.AddInt32(&goroutines, +1)
go func() {
select {
//监听父ctx的取消
case <-parent.Done():
child.cancel(false, parent.Err())
//或则是自己主动取消
case <-child.Done():
在没有看propagateCancel()
源码之前,其实在心里构想过一版自己如何实现contex
的功能:
type Context struct {
*Context
children map[*Context]struct{}
mu sync.Mutex
done atomic.Value
func WithCancel(parent *Context) *Context {
c := &Context{
Context: parent,
parent.addChild(c)
return c
func (c *Context) addChild(child *Context) {
c.mu.Lock()
defer c.mu.Unlock()
c.children[child]= struct{}{}
这种简版的实现与golang
中的实现,最大的区别在于:
在propagateCancel
中,寻找父节点是通过Value()
方法查找的,前面说明,Value()
会一直向上查找所有父节点是否包含key
,直到成功查询,也就是如果第一级父节点查找不到,会一直递归查找所有的父节点,直到根节点。也就是下面的代码中:
ctx, cancel := context.WithCancel(context.Background())
ctx2 := context.WithValue(ctx, "key", "value")
ctx3 := context.WithValue(ctx2, 1, 2)
go func() {
select {
case <-ctx3.Done():
fmt.Println("done", ctx3.Err())
cancel()
time.Sleep(time.Second)
即使ctx3
的直接父节点是一个Value
类型,但是ctx
的cancel()
方法依然会使其Done()
方法成功返回。
而直接通过addChild()
方法却无法实现此功能。
Context
接口并没有要求实现addChild()
方法,而上面的实现中,却要求传入的parent
必须包含addChild()
方法。
上面的方法默认传入的parent
能维护子节点,也就是父节点在cancel()
时会主动调用子节点的cancel()
方法;但是Context
是一个公开的接口,也就是用户也可以实现自己的Context
,然而Context
的接口中并没有这样要求父节点需要主动调用子节点的cancel()
方法,就源码中的后半段else
一样,这种情况需要启动一个goroutine
监听父ctx
的Done()
方法
明白上面三点,便可以知道为什么golang
不使用这种”简单“的实现方案。
接下来看cancel()
方法:
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
//比如传入error标记关闭原因
if err == nil {
panic("context: internal error: missing cancel error")
c.mu.Lock()
//如果err不为空,则说明已经调用过一次cancel方法
//不能重复调用,因为关闭已经关闭的channel会panic
if c.err != nil {
c.mu.Unlock()
return // already canceled
c.err = err
d, _ := c.done.Load().(chan struct{})
//c.done是延迟初始化,因此这里可能还没有用户来得及调用done
//因此这里直接给一个已经close的channel即可,避免浪费初始化性能
if d == nil {
c.done.Store(closedchan)
} else {
//否则,关闭channel
close(d)
//关闭完毕之后,查找所有child,依次调用child的close方法
for child := range c.children {
//注意:这里是在锁中执行了加锁的操作,注意死锁
//为什么这里的removeFromParent传入的false?是因为后面会主动删除所有子节点
child.cancel(false, err)
//删除所有的children
c.children = nil
//释放锁
c.mu.Unlock()
//如果需要将自己从父节点删除,则通过父节点删除自己
if removeFromParent {
removeChild(c.Context, c)
可以看到,cancel()
方法也是比较简单,主要就是为了close(channel)
,同时还通过err
属性标记cancel()
是否已经执行过以及记录关闭的原因。
使用context的弊端
context
从诞生以来,争议都很多。其通过简单的context
包,解决了同一个Request
传播到各个goroutine
的管理问题。
但是其代码侵入式的设计,导致有人形容其传播就像病毒,只要一个地方需要context
,那么整个函数调用链都要传递context
。
有人希望context
可以像Java
的ThreadLocal
一样,线程私有,可以通过一个全局方法随意调用而不是必须作为函数参数传递,在在go2
的讨论中也有人认为应该删除context
。
个人认为context
的设计也符合golang
大道至简的理念,如同err
机制,虽不能面面俱到,但是只要能通过简洁的方案解决问题,对于golang
来说它就是解决方案。
使用context
一定需要明白其原理,理解使用context
的注意事项,避免goroutine
泄露。
借鉴context的源码技巧
本来文章到上面就应该结束了,但是作为Java
转到Golang
的开发者,在阅读context
的源码时,学习到很多值得借鉴的技巧,因此这里简单总结一下:
利用golang
的多返回值特性,可以返回一个ok
,表示两种结果,而不是通过nil
判断,例如:
map
通过多返回值可以先判断map
是包含否key
,再返回key
对应的value
。
Deadline()
方法,通过两个返回值,第一个返回值用来表示是否设置Deadline
,第二个返回值表示具体的值是多少。
在Java
中,一般通过if xx==null
来实现。
设计到关于多线程的代码时,一定想好其线程模型,注意每个方法执行时是否会将本身添加到其他线程中,如果是,则注意后面的属性修改需要加锁。在context#propagateCancel()
代码中:
//...
//将自己添加到parent的children列表中
if p, ok := parentCancelCtx(parent); ok {
//添加成功之后需要加锁
p.mu.Lock()
//...
这一段代码中由于将自己通过添加到parent
的children
列表中,而parent
与自己很可能不在同一个线程,因此添加完毕之后就已经将自己暴露在其他线程中,因此需要先加锁,然后再次判断属性。这种隐蔽的地方往往也是产生bug
的地方。
不要害怕使用锁,过度的提前优化反而是负向优化。平时往往在开发过程中,能使用atomic
就想着不使用锁,有时候这样写出的代码会非常别扭,但是真正访问的QPS
又不高,反而导致了维护性差。
在context
中,为了强制用户使用WithXXX
函数初始化context
,其将所有的context
实现的结构体都设置为包访问级别,仅暴露接口,使得用户无法通过new(cancelCtx)
初始化结构体,这也是golang
强制使用构造方法的一种技巧。
设计多种功能时,可以将功能分层,合理使用装饰者模式可以更好的复用代码。例如先实现 cancelCtx
,然后在其基础上实现 timerCtx
,在将功能分层的同时还复用了代码。
合理利用包级别的全局变量,然后通过函数暴漏出去,这样既可以方便的访问,又可以防止别人修改变量,作用类似Java
的Getter
, 参考context.Backgroud()、context.TODO()
可以通过一些err
标识表示channel
是否关闭,而不是一定需要通过bool
表示,通过err
在实现标识的同时还可以记录其关闭的原因,更加便捷。
查看withCancel()
返回的cancel()
方法的签名:func (c *cancelCtx) cancel(removeFromParent bool, err error)
可以看到第一个参数:removeFromParent
,removeFromParent
表示是否将自己从父节点中删除。在看源码时思考了下,不管什么时候,子ctx
都cancel
了,不都应该从parent
删除吗?在parent
中children
的类型是map
,对于map
来说不管key
是否存在执行delete()
不是也没有问题的么?
可以看到在源码中,在子节点还没有添加到父节点之前,如果此时父节点已经close
,那么调用cancel()
函数时传入的removeFromParent
参数为false
,也就是不用删除;这样做的好处是,执行removeChild()
方法需要获取锁并查找map
,既然已经明确知道自己一定不在map
中,那么就可以通过一个参数避免了此次的加锁以及map
查找的性能消耗。