Go语言context详解
本篇文章的构成
对于很多熟悉go的人来说context是一个简单的功能点,但是一般简单的东西也有很多值得关注学习的地方,本篇本章就是针对context的设计及目标进行思考及剖析。本文将以一下几点作为分析的基础点:
- context的设计理念和目的
- context包内源码
- go原生context的问题点
- gin中context的使用
- 其他框架中context的使用 //todo
正文:
context的设计理念和目的:
通信的两种方案:
我们熟知为了提升程序的性能,必然会绕不过多进程、多线程的问题,而go相对来说还多出了一个协程。打个不恰当的比方,需要建一栋房子,当然是叫越多的工人来帮忙完成这样才搞得快,但是这其中就涉及到了一个问题:工人之间的交流,同步进展这样才能高效利用人力。而进程、线程之间也涉及这个问题。如何同步进程,如何传递相关信息?
关于这个问题,一般有两个解决方案:
1)共享内存
2)消息传递
这两个点当然是没有优劣之分,但是相对来说共享内存这点会比较难。从名称里面就可以看出,多个实体共享一个东西,那么对于这个共享的内存的修改或者使用都需要万分小心;而第二点,消息传递这个方案的话,既可以传递同一个消息(或者说值)也可以做一个复制传递这样的话相对来说对使用者的要求不会很高。
传递信息的种类,和传递者之间的关系
对于传递信息种类的话,我认为应该是分两类来看:
一种是单纯的信号类,这种实现就很简单,利用一个channel就可以简单实现。
//todo 例子
第二种是需要传递具体信息,其中还需要分辨我是否需要共享这个值还是说只需要做一个信息的传递
对于传递者之间的关系也可以简单地作区分:
一类是没有任何关系
还有一类是父子关系
当然考虑到父子关系的话,会有一个疑问:兄弟关系会有影响吗?我这边姑且认为这种情况下应该更多考虑的是父子关系。
go中的context
说了这么多终于说到了go中的context,go的context就是用来在一次请求经过的所有协程或函数间传递取消信号及共享数据,以达到父协程对子协程的管理和控制的目的
这里需要注意的是,go中处理请求每一次都会有一个context(翻译叫做上下文,这个名字就很能凸显context的作用,在go的程序中单个的goroutine只能算作处理逻辑的单元,因为对于一个请求可能会经过多个goroutine来处理,当进行到某一步的时候需要知道我进行到这一步时之前步骤对于一些值或者逻辑的处理,所以这个名字很恰当)
所以简单总结一下:go中context是为了实现主goroutine对子goroutine的控制(如果主goroutine被干掉子没有得到这个信息的话,那他就会一直运行下去),这就是我们之前说的关于信号传递;程序不同阶段或者说不同的执行单元数值的传递,这类属于具体信息的传递。
context源码粗览
在这里我们可以看到context是一个接口类型,里面包含四个方法,其中Deadline方法是用来获取过期时间、Err是用来获取Context取消的原因的。
剩下两个方法比较重要:
1)Done()用于返回一个只读的 channel,用于通知当前 Context 是否已经被取消。这里看来其实就是传递信息的种类中阐述的发送信号这一种,只不过这个信号相当固定是用来通知该context已过期。
//todo 增加一个调用的例子
2)Value()用于获取 Context 中保存的键值对数据,这个就是说的第二种发送具体的数值,当然这个数值肯定是共享的。//todo增加说明的例子
这里有个点事,因为context是接口类型,我们使用的时候必须通过实现其中的方法才能真正的使用,所以这里需要我们来实现吗?答案是不需要,在这个context文件中,已经根据使用场景帮你实现了几类context:
这里这四类(emptyCtx、cancelCtx、timerCtx、valueCtx)一般情况下我们不会直接用到,因为我们日常使用的还是各个框架封装的。但是这几个的设计我们还是可以领悟其中很多的思想在里面。
emptyCtx:
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
}
这个类型从接口实现的角度来看,其实并没有实现仅仅是套了一个壳,但是这个类型却是其他三种的父context。生成方法是这样的:
var (
background = new(emptyCtx)
todo = new(emptyCtx)
)
我们需要关注一下background,对于这个注释上说: as the top-level Context for incoming requests.
同时在net/http/server.go里面, Serve方法中:
baseCtx := context.Background()
//这里就是生成了一个空类型的Ctx
if srv.BaseContext != nil {
baseCtx = srv.BaseContext(origListener)
if baseCtx == nil {
panic("BaseContext returned a nil context")
}
}
var tempDelay time.Duration // how long to sleep on accept failure
ctx := context.WithValue(baseCtx, ServerContextKey, srv) //增加赋值属性
cancelCtx、timerCtx:
查看cancelCtx、timerCtx的生成方法:
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) }
}
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
return WithDeadline(parent, time.Now().Add(timeout))
}
可以看出cancelCtx、timerCtx的生成是基于一个parentContext的,在net/http/server.go中我们也看到了这个parent是emptyCtx来充当的。
go语言中并没有继承这种操作,但是通过包装结构体可以达到隐形继承的关系:
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
}
type timerCtx struct {
cancelCtx
timer *time.Timer // Under cancelCtx.mu.
deadline time.Time
}
从这里可以看出 timerCtx继承cancelCtx,cancelCtx继承emptyCtx。
context的设计思想
- 属性脱离开
首先需要明确的是context的作用是1 控制请求的生存周期 2 消息传递
对于这两个属性,这里的设计把它们分隔开cancelCtx和timerCtx(用于控制生存周期)、WithValue(用于携带参数)
同时有一个父类的实现(emptyCtx),这个没有任何属性在里面,所以就可以重复利用。对于不同的情况可以根据情况附加属性在里面 - 属性有轻重之分
对于生存周期的控制是必须的,所以timerCtx及cancelCtx是可以直接生成的;但是参数是非必须的,所以只用了context.WithValue这种方式。
而cancelCtx和timerCtx也是逐渐递进的关系。
其实就是一个解耦合和易复用的思想在这里。
go原生context的问题点
初始化的性能消耗:
由源代码可知,每个请求过来会对应生成一个context,虽说这种重新生成可能消耗也不算大,但是积少成多还是有对系统的性能有所影响。
这个的优化点就是利用context池,初始化一部分,循环使用这个里面的东西,从而提高性能。
gin中的context就是这样生成的:
// ServeHTTP conforms to the http.Handler interface.
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
c := engine.pool.Get().(*Context) //从池子中获取
c.writermem.reset(w)
c.Request = req
c.reset()
engine.handleHTTPRequest(c)
engine.pool.Put(c)
}
当然这里面获取context及将使用过的context放回池子里面也存在很多值得学习和注意的地方,我们之后的文章中再说这个问题。
泛滥的context参数
对于context,不同的函数之间是需要将context作为一个参数进行传递的,对代码来说是一种侵入式的。也就意味着相当多的函数都要接受这个context参数的输入,造成很多不必要的参数输入及context被广泛传播。
对于信号传输处理不足
信号的设计理念是我们约定一个信号,收到之后去做事先规定好的某件事情。从这里可以看出关键点是约定好的某件事;而在context中这件事情就是cancel信号所引发的结束相关context的生命周期,相对来说这个设计或者说这个约定好的事情是比较死板且片面的,比如说我如果要求多个goroutine执行完一段代码后再怎么怎么,这个cancel信号就没办法完成,只能借助sync.WaitGroup来实现。
只能传输key-value的键值对
这一点的缺陷倒不是很明显,很多时候传输key-value就已经足够我们使用了,其他复杂结构也可以通过拆分从而借助key-value来存储;但是如果数据量过多,key可能会重复,同时也没有一个统一的地方来维护,如果团队中不同人开发使用了相同的key可能会踩到这里的坑。