原文链接:小白也能看懂的context包详解:从入门到精通
前言
哈喽,大家好,我是asong
。明天想与大家分享context
包,通过一年的积淀,从新登程,基于Go1.17.1
从源码角度再次剖析,不过这次不同的是,我打算先从入门开始,因为大多数初学的读者都想先晓得怎么用,而后才会关怀源码是如何实现的。
置信大家在日常工作开发中肯定会看到这样的代码:
func a1(ctx context ...){ b1(ctx)}func b1(ctx context ...){ c1(ctx)}func c1(ctx context ...)
context
被当作第一个参数(官网倡议),并且一直透传下去,根本一个我的项目代码中到处都是context
,然而你们真的晓得它有何作用吗以及它是如何起作用的吗?我记得我第一次接触context
时,共事都说这个用来做并发管制的,能够设置超时工夫,超时就会勾销往下执行,疾速返回,我就单纯的认为只有函数中带着context
参数往下传递就能够做到超时勾销,疾速返回。置信大多数初学者也都是和我一个想法,其实这是一个谬误的思维,其勾销机制采纳的也是告诉机制,单纯的透传并不会起作用,比方你这样写代码:
func main() { ctx,cancel := context.WithTimeout(context.Background(),10 * time.Second) defer cancel() go Monitor(ctx) time.Sleep(20 * time.Second)}func Monitor(ctx context.Context) { for { fmt.Print("monitor") }}
即便context
透传下去了,不应用也是不起任何作用的。所以理解context
的应用还是很有必要的,本文就先从应用开始,逐渐解析Go
语言的context
包,上面咱们就开始喽!!!
context
包的起源与作用
看官网博客咱们能够晓得context
包是在go1.7
版本中引入到规范库中的:
<img src="https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/c0d332914b0c44ae8706589eaef6ebaa~tplv-k3u1fbpfcp-zoom-1.image" style="zoom:50%;" />
context
能够用来在goroutine
之间传递上下文信息,雷同的context
能够传递给运行在不同goroutine
中的函数,上下文对于多个goroutine
同时应用是平安的,context
包定义了上下文类型,能够应用background
、TODO
创立一个上下文,在函数调用链之间流传context
,也能够应用WithDeadline
、WithTimeout
、WithCancel
或 WithValue
创立的批改正本替换它,听起来有点绕,其实总结起就是一句话:context
的作用就是在不同的goroutine
之间同步申请特定的数据、勾销信号以及解决申请的截止日期。
目前咱们罕用的一些库都是反对context
的,例如gin
、database/sql
等库都是反对context
的,这样更不便咱们做并发管制了,只有在服务器入口创立一个context
上下文,一直透传下去即可。
context
的应用
创立context
context
包次要提供了两种形式创立context
:
context.Backgroud()
context.TODO()
这两个函数其实只是互为别名,没有差异,官网给的定义是:
context.Background
是上下文的默认值,所有其余的上下文都应该从它衍生(Derived)进去。context.TODO
应该只在不确定应该应用哪种上下文时应用;
所以在大多数状况下,咱们都应用context.Background
作为起始的上下文向下传递。
下面的两种形式是创立根context
,不具备任何性能,具体实际还是要依附context
包提供的With
系列函数来进行派生:
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)func WithValue(parent Context, key, val interface{}) Context
这四个函数都要基于父Context
衍生,通过这些函数,就创立了一颗Context树,树的每个节点都能够有任意多个子节点,节点层级能够有任意多个,画个图示意一下:
基于一个父Context
能够随便衍生,其实这就是一个Context
树,树的每个节点都能够有任意多个子节点,节点层级能够有任意多个,每个子节点都依赖于其父节点,例如上图,咱们能够基于Context.Background
衍生出四个子context
:ctx1.0-cancel
、ctx2.0-deadline
、ctx3.0-timeout
、ctx4.0-withvalue
,这四个子context
还能够作为父context
持续向下衍生,即便其中ctx1.0-cancel
节点勾销了,也不影响其余三个父节点分支。
创立context
办法和context
的衍生办法就这些,上面咱们就一个一个来看一下他们如何被应用。
WithValue
携带数据
咱们日常在业务开发中都心愿能有一个trace_id
能串联所有的日志,这就须要咱们打印日志时可能获取到这个trace_id
,在python
中咱们能够用gevent.local
来传递,在java
中咱们能够用ThreadLocal
来传递,在Go
语言中咱们就能够应用Context
来传递,通过应用WithValue
来创立一个携带trace_id
的context
,而后一直透传下去,打印日志时输入即可,来看应用例子:
const ( KEY = "trace_id")func NewRequestID() string { return strings.Replace(uuid.New().String(), "-", "", -1)}func NewContextWithTraceID() context.Context { ctx := context.WithValue(context.Background(), KEY,NewRequestID()) return ctx}func PrintLog(ctx context.Context, message string) { fmt.Printf("%s|info|trace_id=%s|%s",time.Now().Format("2006-01-02 15:04:05") , GetContextValue(ctx, KEY), message)}func GetContextValue(ctx context.Context,k string) string{ v, ok := ctx.Value(k).(string) if !ok{ return "" } return v}func ProcessEnter(ctx context.Context) { PrintLog(ctx, "Golang梦工厂")}func main() { ProcessEnter(NewContextWithTraceID())}
输入后果:
2021-10-31 15:13:25|info|trace_id=7572e295351e478e91b1ba0fc37886c0|Golang梦工厂Process finished with the exit code 0
咱们基于context.Background
创立一个携带trace_id
的ctx
,而后通过context
树一起传递,从中派生的任何context
都会获取此值,咱们最初打印日志的时候就能够从ctx
中取值输入到日志中。目前一些RPC
框架都是反对了Context
,所以trace_id
的向下传递就更不便了。
在应用withVaule
时要留神四个事项:
- 不倡议应用
context
值传递要害参数,要害参数应该显示的申明进去,不应该隐式解决,context
中最好是携带签名、trace_id
这类值。 - 因为携带
value
也是key
、value
的模式,为了防止context
因多个包同时应用context
而带来抵触,key
倡议采纳内置类型。 - 下面的例子咱们获取
trace_id
是间接从以后ctx
获取的,理论咱们也能够获取父context
中的value
,在获取键值对是,咱们先从以后context
中查找,没有找到会在从父context
中查找该键对应的值直到在某个父context
中返回nil
或者查找到对应的值。 context
传递的数据中key
、value
都是interface
类型,这种类型编译期无奈确定类型,所以不是很平安,所以在类型断言时别忘了保障程序的健壮性。
超时管制
通常强壮的程序都是要设置超时工夫的,防止因为服务端长时间响应耗费资源,所以一些web
框架或rpc
框架都会采纳withTimeout
或者withDeadline
来做超时管制,当一次申请达到咱们设置的超时工夫,就会及时勾销,不在往下执行。withTimeout
和withDeadline
作用是一样的,就是传递的工夫参数不同而已,他们都会通过传入的工夫来主动勾销Context
,这里要留神的是他们都会返回一个cancelFunc
办法,通过调用这个办法能够达到提前进行勾销,不过在应用的过程还是倡议在主动勾销后也调用cancelFunc
去进行定时缩小不必要的资源节约。
withTimeout
、WithDeadline
不同在于WithTimeout
将持续时间作为参数输出而不是工夫对象,这两个办法应用哪个都是一样的,看业务场景和集体习惯了,因为实质withTimout
外部也是调用的WithDeadline
。
当初咱们就举个例子来试用一下超时管制,当初咱们就模仿一个申请写两个例子:
- 达到超时工夫终止接下来的执行
func main() { HttpHandler()}func NewContextWithTimeout() (context.Context,context.CancelFunc) { return context.WithTimeout(context.Background(), 3 * time.Second)}func HttpHandler() { ctx, cancel := NewContextWithTimeout() defer cancel() deal(ctx)}func deal(ctx context.Context) { for i:=0; i< 10; i++ { time.Sleep(1*time.Second) select { case <- ctx.Done(): fmt.Println(ctx.Err()) return default: fmt.Printf("deal time is %d\n", i) } }}
输入后果:
deal time is 0deal time is 1context deadline exceeded
- 没有达到超时工夫终止接下来的执行
func main() { HttpHandler1()}func NewContextWithTimeout1() (context.Context,context.CancelFunc) { return context.WithTimeout(context.Background(), 3 * time.Second)}func HttpHandler1() { ctx, cancel := NewContextWithTimeout1() defer cancel() deal1(ctx, cancel)}func deal1(ctx context.Context, cancel context.CancelFunc) { for i:=0; i< 10; i++ { time.Sleep(1*time.Second) select { case <- ctx.Done(): fmt.Println(ctx.Err()) return default: fmt.Printf("deal time is %d\n", i) cancel() } }}
输入后果:
deal time is 0context canceled
应用起来还是比拟容易的,既能够超时主动勾销,又能够手动管制勾销。这里大家要记的一个坑,就是咱们往从申请入口透传的调用链路中的context
是携带超时工夫的,如果咱们想在其中独自开一个goroutine去解决其余的事件并且不会随着申请完结后而被勾销的话,那么传递的context
要基于context.Background
或者context.TODO
从新衍生一个传递,否决就会和预期不合乎了,能够看一下我之前的一篇踩坑文章:context使用不当引发的一个bug。
withCancel
勾销管制
日常业务开发中咱们往往为了实现一个简单的需要会开多个gouroutine
去做一些事件,这就导致咱们会在一次申请中开了多个goroutine
确无法控制他们,这时咱们就能够应用withCancel
来衍生一个context
传递到不同的goroutine
中,当我想让这些goroutine
进行运行,就能够调用cancel
来进行勾销。
来看一个例子:
func main() { ctx,cancel := context.WithCancel(context.Background()) go Speak(ctx) time.Sleep(10*time.Second) cancel() time.Sleep(1*time.Second)}func Speak(ctx context.Context) { for range time.Tick(time.Second){ select { case <- ctx.Done(): fmt.Println("我要闭嘴了") return default: fmt.Println("balabalabalabala") } }}
运行后果:
balabalabalabala....省略balabalabalabala我要闭嘴了
咱们应用withCancel
创立一个基于Background
的ctx,而后启动一个讲话程序,每隔1s说一话,main
函数在10s后执行cancel
,那么speak
检测到勾销信号就会退出。
自定义Context
因为Context
实质是一个接口,所以咱们能够通过实现Context
达到自定义Context
的目标,个别在实现Web
框架或RPC
框架往往采纳这种模式,比方gin
框架的Context
就是本人有封装了一层,具体代码和实现就贴在这里,有趣味能够看一下gin.Context
是如何实现的。
源码赏析
Context其实就是一个接口,定义了四个办法:
type Context interface { Deadline() (deadline time.Time, ok bool) Done() <-chan struct{} Err() error Value(key interface{}) interface{}}
Deadlne
办法:当Context
主动勾销或者到了勾销工夫被勾销后返回Done
办法:当Context
被勾销或者到了deadline
返回一个被敞开的channel
Err
办法:当Context
被勾销或者敞开后,返回context
勾销的起因Value
办法:获取设置的key
对应的值
这个接口次要被三个类继承实现,别离是emptyCtx
、ValueCtx
、cancelCtx
,采纳匿名接口的写法,这样能够对任意实现了该接口的类型进行重写。
上面咱们就从创立到应用来层层剖析。
创立根Context
其在咱们调用context.Background
、context.TODO
时创立的对象就是empty
:
var ( background = new(emptyCtx) todo = new(emptyCtx))func Background() Context { return background}func TODO() Context { return todo}
Background
和TODO
还是截然不同的,官网说:background
它通常由主函数、初始化和测试应用,并作为传入申请的顶级上下文;TODO
是当不分明要应用哪个 Context 或尚不可用时,代码应应用 context.TODO,后续在在进行替换掉,归根结底就是语义不同而已。
emptyCtx
类
emptyCtx
次要是给咱们创立根Context
时应用的,其实现办法也是一个空构造,理论源代码长这样:
type emptyCtx intfunc (*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 interface{}) interface{} { return nil}func (e *emptyCtx) String() string { switch e { case background: return "context.Background" case todo: return "context.TODO" } return "unknown empty Context"}
WithValue
的实现
withValue
外部次要就是调用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}}
valueCtx
类
valueCtx
目标就是为Context
携带键值对,因为它采纳匿名接口的继承实现形式,他会继承父Context
,也就相当于嵌入Context
当中了
type valueCtx struct { Context key, val interface{}}
实现了String
办法输入Context
和携带的键值对信息:
func (c *valueCtx) String() string { return contextName(c.Context) + ".WithValue(type " + reflectlite.TypeOf(c.key).String() + ", val " + stringify(c.val) + ")"}
实现Value
办法来存储键值对:
func (c *valueCtx) Value(key interface{}) interface{} { if c.key == key { return c.val } return c.Context.Value(key)}
看图来了解一下:
所以咱们在调用Context
中的Value
办法时会层层向上调用直到最终的根节点,两头要是找到了key
就会返回,否会就会找到最终的emptyCtx
返回nil
。
WithCancel
的实现
咱们来看一下WithCancel
的入口函数源代码:
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) }}
这个函数执行步骤如下:
- 创立一个
cancelCtx
对象,作为子context
- 而后调用
propagateCancel
构建父子context
之间的关联关系,这样当父context
被勾销时,子context
也会被勾销。 - 返回子
context
对象和子树勾销函数
咱们先剖析一下cancelCtx
这个类。
cancelCtx
类
cancelCtx
继承了Context
,也实现了接口canceler
:
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}
字短解释:
mu
:就是一个互斥锁,保障并发平安的,所以context
是并发平安的done
:用来做context
的勾销告诉信号,之前的版本应用的是chan struct{}
类型,当初用atomic.Value
做锁优化children
:key
是接口类型canceler
,目标就是存储实现以后canceler
接口的子节点,当根节点产生勾销时,遍历子节点发送勾销信号error
:当context
勾销时存储勾销信息
这里实现了Done
办法,返回的是一个只读的channel
,目标就是咱们在内部能够通过这个阻塞的channel
期待告诉信号。
具体代码就不贴了。咱们先返回去看propagateCancel
是如何做构建父子Context
之间的关联。
propagateCancel
办法
代码有点长,解释有点麻烦,我把正文增加到代码中看起来比拟直观:
func propagateCancel(parent Context, child canceler) { // 如果返回nil,阐明以后父`context`从来不会被勾销,是一个空节点,间接返回即可。 done := parent.Done() if done == nil { return // parent is never canceled } // 提前判断一个父context是否被勾销,如果勾销了也不须要构建关联了, // 把以后子节点勾销掉并返回 select { case <-done: // parent is already canceled child.cancel(false, parent.Err()) return default: } // 这里目标就是找到能够“挂”、“勾销”的context if p, ok := parentCancelCtx(parent); ok { p.mu.Lock() // 找到了能够“挂”、“勾销”的context,然而曾经被勾销了,那么这个子节点也不须要 // 持续挂靠了,勾销即可 if p.err != nil { child.cancel(false, p.err) } else { // 将以后节点挂到父节点的childrn map中,里面调用cancel时能够层层勾销 if p.children == nil { // 这里因为childer节点也会变成父节点,所以须要初始化map构造 p.children = make(map[canceler]struct{}) } p.children[child] = struct{}{} } p.mu.Unlock() } else { // 没有找到可“挂”,“勾销”的父节点挂载,那么就开一个goroutine atomic.AddInt32(&goroutines, +1) go func() { select { case <-parent.Done(): child.cancel(false, parent.Err()) case <-child.Done(): } }() }}
这段代码真正产生纳闷的是这个if、else分支。不看代码了,间接说为什么吧。因为咱们能够本人定制context
,把context
塞进一个构造时,就会导致找不到可勾销的父节点,只能从新起一个协程做监听。
对这块有蛊惑的举荐浏览饶大大文章:[深度解密Go语言之context](https://www.cnblogs.com/qcrao...),定能为你排忧解惑。
cancel
办法
最初咱们再来看一下返回的cancel
办法是如何实现,这个办法会敞开上下文中的 Channel 并向所有的子上下文同步勾销信号:
func (c *cancelCtx) cancel(removeFromParent bool, err error) { // 勾销时传入的error信息不能为nil, context定义了默认error:var Canceled = errors.New("context canceled") 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 // 用来敞开channel,告诉其余协程 d, _ := c.done.Load().(chan struct{}) if d == nil { c.done.Store(closedchan) } else { close(d) } // 以后节点向下勾销,遍历它的所有子节点,而后勾销 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() // 把以后节点从父节点中移除,只有在内部父节点调用时才会传true // 其余都是传false,外部调用都会因为c.children = nil被剔除进来 if removeFromParent { removeChild(c.Context, c) }}
到这里整个WithCancel
办法源码就剖析好了,通过源码咱们能够晓得cancel
办法能够被反复调用,是幂等的。
withDeadline
、WithTimeout
的实现
先看WithTimeout
办法,它外部就是调用的WithDeadline
办法:
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) { return WithDeadline(parent, time.Now().Add(timeout))}
所以咱们重点来看withDeadline
是如何实现的:
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) { // 不能为空`context`创立衍生context if parent == nil { panic("cannot create context from nil parent") } // 当父context的完结工夫早于要设置的工夫,则不须要再去独自解决子节点的定时器了 if cur, ok := parent.Deadline(); ok && cur.Before(d) { // The current deadline is already sooner than the new one. return WithCancel(parent) } // 创立一个timerCtx对象 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() // 如果没被勾销,则间接增加一个定时器,定时去勾销 if c.err == nil { c.timer = time.AfterFunc(dur, func() { c.cancel(true, DeadlineExceeded) }) } return c, func() { c.cancel(true, Canceled) }}
withDeadline
相较于withCancel
办法也就多了一个定时器去定时调用cancel
办法,这个cancel
办法在timerCtx
类中进行了重写,咱们先来看一下timerCtx
类,他是基于cancelCtx
的,多了两个字段:
type timerCtx struct { cancelCtx timer *time.Timer // Under cancelCtx.mu. deadline time.Time}
timerCtx
实现的cancel
办法,外部也是调用了cancelCtx
的cancel
办法勾销:
func (c *timerCtx) cancel(removeFromParent bool, err error) { // 调用cancelCtx的cancel办法勾销掉子节点context c.cancelCtx.cancel(false, err) // 从父context移除放到了这里来做 if removeFromParent { // Remove this timerCtx from its parent cancelCtx's children. removeChild(c.cancelCtx.Context, c) } // 停掉定时器,开释资源 c.mu.Lock() if c.timer != nil { c.timer.Stop() c.timer = nil } c.mu.Unlock()}
终于源码局部咱们就看完了,当初你何感想?
context
的优缺点
context
包被设计进去就是做并发管制的,这个包有利有弊,集体总结了几个优缺点,欢送评论区补充。
毛病
- 影响代码好看,当初根本所有
web
框架、RPC
框架都是实现了context
,这就导致咱们的代码中每一个函数的一个参数都是context
,即便不必也要带着这个参数透传下去,集体感觉有点俊俏。 context
能够携带值,然而没有任何限度,类型和大小都没有限度,也就是没有任何束缚,这样很容易导致滥用,程序的强壮很难保障;还有一个问题就是通过context
携带值不如显式传值难受,可读性变差了。- 能够自定义
context
,这样危险不可控,更加会导致滥用。 context
勾销和主动勾销的谬误返回不够敌对,无奈自定义谬误,呈现难以排查的问题时不好排查。- 创立衍生节点理论是创立一个个链表节点,其工夫复杂度为O(n),节点多了会掉支效率变低。
长处
- 应用
context
能够更好的做并发管制,能更好的治理goroutine
滥用。 context
的携带者性能没有任何限度,这样我咱们传递任何的数据,能够说这是一把双刃剑- 网上都说
context
包解决了goroutine
的cancelation
问题,你感觉呢?
参考文章
https://pkg.go.dev/context@go...
https://studygolang.com/artic...
https://draveness.me/golang/d...
https://www.cnblogs.com/qcrao...
https://segmentfault.com/a/11...
https://www.flysnow.org/2017/...
总结
context
尽管在应用上俊俏了一点,然而他却能解决很多问题,日常业务开发中离不开context
的应用,不过也别应用错了context
,其勾销也采纳的channel
告诉,所以代码中还有要有监听代码来监听勾销信号,这点也是常常被宽广初学者容易漠视的一个点。
文中示例已上传github
:https://github.com/asong2020/...
好啦,本文到这里就完结了,我是asong
,咱们下期见。
**欢送关注公众号:【Golang梦工厂】