乐趣区

关于golang:Go语言小白也能看懂的context包详解从入门到精通

原文链接:小白也能看懂的 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包定义了上下文类型,能够应用 backgroundTODO 创立一个上下文,在函数调用链之间流传 context,也能够应用WithDeadlineWithTimeoutWithCancelWithValue 创立的批改正本替换它,听起来有点绕,其实总结起就是一句话:context 的作用就是在不同的 goroutine 之间同步申请特定的数据、勾销信号以及解决申请的截止日期。

目前咱们罕用的一些库都是反对 context 的,例如 gindatabase/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 衍生出四个子 contextctx1.0-cancelctx2.0-deadlinectx3.0-timeoutctx4.0-withvalue,这四个子context 还能够作为父 context 持续向下衍生,即便其中ctx1.0-cancel 节点勾销了,也不影响其余三个父节点分支。

创立 context 办法和 context 的衍生办法就这些,上面咱们就一个一个来看一下他们如何被应用。

WithValue携带数据

咱们日常在业务开发中都心愿能有一个 trace_id 能串联所有的日志,这就须要咱们打印日志时可能获取到这个 trace_id,在python 中咱们能够用 gevent.local 来传递,在 java 中咱们能够用 ThreadLocal 来传递,在 Go 语言中咱们就能够应用 Context 来传递,通过应用 WithValue 来创立一个携带 trace_idcontext,而后一直透传下去,打印日志时输入即可,来看应用例子:

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_idctx,而后通过 context 树一起传递,从中派生的任何 context 都会获取此值,咱们最初打印日志的时候就能够从 ctx 中取值输入到日志中。目前一些 RPC 框架都是反对了 Context,所以trace_id 的向下传递就更不便了。

在应用 withVaule 时要留神四个事项:

  • 不倡议应用 context 值传递要害参数,要害参数应该显示的申明进去,不应该隐式解决,context中最好是携带签名、trace_id这类值。
  • 因为携带 value 也是 keyvalue 的模式,为了防止 context 因多个包同时应用 context 而带来抵触,key倡议采纳内置类型。
  • 下面的例子咱们获取 trace_id 是间接从以后 ctx 获取的,理论咱们也能够获取父 context 中的 value,在获取键值对是,咱们先从以后context 中查找,没有找到会在从父 context 中查找该键对应的值直到在某个父 context 中返回 nil 或者查找到对应的值。
  • context传递的数据中 keyvalue 都是 interface 类型,这种类型编译期无奈确定类型,所以不是很平安,所以在类型断言时别忘了保障程序的健壮性。

超时管制

通常强壮的程序都是要设置超时工夫的,防止因为服务端长时间响应耗费资源,所以一些 web 框架或 rpc 框架都会采纳 withTimeout 或者 withDeadline 来做超时管制,当一次申请达到咱们设置的超时工夫,就会及时勾销,不在往下执行。withTimeoutwithDeadline 作用是一样的,就是传递的工夫参数不同而已,他们都会通过传入的工夫来主动勾销 Context,这里要留神的是他们都会返回一个cancelFunc 办法,通过调用这个办法能够达到提前进行勾销,不过在应用的过程还是倡议在主动勾销后也调用 cancelFunc 去进行定时缩小不必要的资源节约。

withTimeoutWithDeadline不同在于 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 0
deal time is 1
context 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 0
context 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 对应的值

这个接口次要被三个类继承实现,别离是emptyCtxValueCtxcancelCtx,采纳匿名接口的写法,这样能够对任意实现了该接口的类型进行重写。

上面咱们就从创立到应用来层层剖析。

创立根Context

其在咱们调用 context.Backgroundcontext.TODO 时创立的对象就是empty

var (background = new(emptyCtx)
    todo       = new(emptyCtx)
)

func Background() Context {return background}

func TODO() Context {return todo}

BackgroundTODO 还是截然不同的,官网说:background它通常由主函数、初始化和测试应用,并作为传入申请的顶级上下文;TODO是当不分明要应用哪个 Context 或尚不可用时,代码应应用 context.TODO,后续在在进行替换掉,归根结底就是语义不同而已。

emptyCtx

emptyCtx次要是给咱们创立根 Context 时应用的,其实现办法也是一个空构造,理论源代码长这样:

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 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 做锁优化
  • childrenkey是接口类型 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 办法能够被反复调用,是幂等的。

withDeadlineWithTimeout的实现

先看 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 办法,外部也是调用了 cancelCtxcancel办法勾销:

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 包解决了 goroutinecancelation问题,你感觉呢?

参考文章

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 梦工厂】

退出移动版