乐趣区

关于go:43-Golang常用标准库上下文context

  Context 顾名思义上下文,可用于在整个申请上下文传值以及管制超时,本篇文章次要介绍 Context 的设计思路,以及根本应用形式。

Context 应用形式

  构想有一个 Go HTTP 服务,在申请的整个解决链路,可能随时须要获取一些公共数据,如用户 ID 等,怎么办呢?通过参数呗,每个函数的第一个输出参数都是用户 ID 不就行了,如果再加一个公共数据呢?再加一个参数吗?如果将所有这些公共参数封装成一个构造体呢?这样貌似也能够。

  不过 Go 语言自身就为咱们提供了 context.Context(context.valueCtx),其能够存储一个 key-value 键值对,那不行啊,只能存储一个必定不够啊;怎么办,基于老的 context.valueCtx 对象再衍生新的 context.valueCtx 对象,两个 context.valueCtx 对象各能存储一个 key-value 键值对,想存储更多的数据,持续衍生就能够了。事例程序如下:

package main

import (
    "context"
    "fmt"
)

func main() {ctx := context.Background()
    ctx1 := context.WithValue(ctx, "k1", "v1")
    ctx2 := context.WithValue(ctx1, "k2", "v2")

    fmt.Println(ctx1.Value("k1"))
    fmt.Println(ctx1.Value("k2")) // 返回 nil

    fmt.Println(ctx2.Value("k1"))
    fmt.Println(ctx2.Value("k2"))
}

// v1 <nil> v1 v2

  基于 context.WithValue 函数能够衍生新的 context.valueCtx 对象,同时传递须要存储的 key-value 键值对;留神第一个参数须要一个 context.Context(这是一个接口,context.valueCtx 实现了该接口)对象,context.Background 函数可返回一个空的 context.valueCtx 对象。

  仔细观察输入后果,ctx1 对象只能获取到 k1,ctx2 对象能够获取到 k1 以及 k2,因为 ctx2 对象是基于 ctx1 对象衍生进去的,也能够说 ctx1 对象是 ctx2 对象的父对象,而 ctx2.Value 既能够获取到本人存储的数据,也能获取到父对象存储的数据。最初值得一提的是,context.valueCtx 存储的 key-value 键值对,类型都是 interface{},所以获取到数据之后个别须要进行类型转换能力应用。

  既然通过 context.Context 就能实现 key-value 数据的存储,那就应用它呗,只须要我的项目中所有函数的第一个参数都是 context.Context,就能实现在整个申请链路传值。

  context.Context 就这么点作用?当然不是,最罕用的还是它的超时管制性能。假如某项工作有工夫限度,最多执行 3 秒,超时后勾销工作的继续执行,这不很简略,通过定时器就能实现,那如果工作比较复杂,又分为多个子工作并启动了多个子协程别离执行呢,3 秒超时后能同时完结整个工作吗,包含主工作以及多个子工作?这时候定时器能实现吗?比拟艰难。

  上面程序展现了基于 context.Context 实现的工作超时管制。

package main

import (
    "context"
    "fmt"
    "sync"
    "time"
)

func main() {ctx := context.Background()
    // 可勾销的 context
    ctx1, cancel := context.WithCancel(ctx)

    //WaitGroup 管制并发工作,主协程需期待子工作完结能力退出
    wg := sync.WaitGroup{}
    wg.Add(1)
    go task(ctx1, &wg)

    // 三秒后勾销子工作
    time.AfterFunc(time.Second*3, func() {cancel()
    })

    // 期待子工作完结
    wg.Wait()}

func task(ctx context.Context, wg *sync.WaitGroup) {defer wg.Done()
    for {
        //context 勾销时会敞开管道,从而可读,实现工作完结 return
        select {case <-ctx.Done():
            fmt.Println("context cancel and return")
            return
        default:

        }
        // 子工作 1 秒周期执行一次,死循环
        fmt.Println("exec task", time.Now())
        time.Sleep(time.Second)
    }
}

  context.WithCancel 函数返回可勾销的上下文(contetx.cancelCtx 对象,同样实现了接口 context.Context),函数返回两个值,第一个值就是 contetx.cancelCtx 对象,第二个值是一个函数,可用于完结该上下文,完结之后 ctx.Done 函数返回的管道变为可读的,所以子工作能够通过其判断上下文是否被完结,是否该完结当前任务。下面程序事例,定时器 3 秒超时后完结以后上下文,当然你也能够基于任何条件判断是否须要完结上下文。

  超时管制绝对也是比较简单的,只须要主协程管制何时完结上下文,子工作只需监听 ctx.Done 管道,上下文完结后管道可读,从而完结所有子工作。

  contetx.cancelCtx 须要你本人基于定时器实现超时管制,Go 语言还提供有两个函数,很不便实现 context 的超时管制:

//timeout 之后,主动完结上下文
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)

  这两个函数都是返回新的上下文对象(contetx.timerCtx,同样实现了接口 context.Context),函数返回两个值,第一个值就是 contetx.timerCtx 对象,第二个值是一个函数,同样可用于完结该上下文。也就是说,在 timeout 之后,会主动完结上下文,然而你也能够通过返回的 CancelFunc 完结该上下文。

实现原理

  在介绍 context 的应用形式时,咱们提到了几个接口或者构造,如 context.Context 接口,context.valueCtx 构造,contetx.cancelCtx 构造,contetx.timerCtx 构造,context 的所有性能都是基于这几个类型实现的。

  context.Context 是一个接口,定义了 4 个根本办法:

type Context interface {
    // 返回上下文完结工夫,ok=false 阐明没有定时完结性能
    Deadline() (deadline time.Time, ok bool)

    //  func Stream(ctx context.Context, out chan<- Value) error {
    //      for {//          v, err := DoSomething(ctx)
    //          if err != nil {
    //              return err
    //          }
    //          select {//          case <-ctx.Done():
    //              return ctx.Err()
    //          case out <- v:
    //          }
    //      }
    //  }
    // 返回一个管道,上下文完结时会敞开该管道,从而可读
    Done() <-chan struct{}

    // 如果 Done 管道敞开则 Err 不为空,用于示意谬误起因;否则为 nil
    Err() error

    //     func NewContext(ctx context.Context, u *User) context.Context {//         return context.WithValue(ctx, userKey, u)
    //     }
    //
    //     // FromContext returns the User value stored in ctx, if any.
    //     func FromContext(ctx context.Context) (*User, bool) {//         u, ok := ctx.Value(userKey).(*User)
    //         return u, ok
    //     }
    // 存取键值对数据
    Value(key interface{}) interface{}}

  从这四个办法也能看出,context.Context 天生就是为了上下文传值以及管制超时的。另外通过下面的几个事例,你有没有发现 context.WithXXX 等函数,都是基于父 context 衍生出新的 context,也就是说这些 context 存在父子关系(相似一棵树);父 context 超时完结后,也会遍历完结其所有的 context(如果子 context 可完结),顺次类推。

  上面咱们先看看 context.valueCtx 的实现原理,其定义如下:

type valueCtx struct {
    // 父对象
    Context
    // 存储 key-value 键值对
    key, val any
}

func WithValue(parent Context, key, val any) Context {return &valueCtx{parent, key, val}
}

func (c *valueCtx) Value(key any) any {
    // 间接返回
    if c.key == key {return c.val}
    // 遍历父节点
    return value(c.Context, key)
}

func value(c Context, key any) any {
    // 循环遍历父节点
    for {switch ctx := c.(type) {
        case *valueCtx:
            if key == ctx.key {return ctx.val}
            // 接续遍历父节点
            c = ctx.Context
        case *emptyCtx:
            return nil

        // 省略其余类型 context 分支

        default:
            return c.Value(key)
        }
    }
}

  context.valueCtx 的实现还是特地简略的,只是要留神其存储的 key-value 键值对类型都是 interface{},所以在获取到值对象后可能须要进行类型转换;另外,如果以后 context 对象没有获取到键值对,则遍历父对象获取,顺次类推。

  contetx.cancelCtx 构造用于实现可勾销的 context 对象,其定义如下:

type cancelCtx struct {
    // 父对象
    Context
    // 理论类型为管道,敞开管道就是完结以后上下文
    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
    err      error                 // set to non-nil by the first cancel call
}

  这才对嘛,基于这些字段才能够实现 context.Context 接口,不然怎么实现 ctx.Done 以及 ctx.Err 办法呢?contetx.cancelCtx 的实现如下:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {c := newCancelCtx(parent)
    propagateCancel(parent, &c)

    // 返回一个函数 CancelFunc,用于完结以后 context 对象
    return &c, func() { c.cancel(true, Canceled) }
}


func propagateCancel(parent Context, child canceler) {done := parent.Done()
    select {
    case <-done:
        // 如果父对象已完结,完结以后 context 对象并返回
        child.cancel(false, parent.Err())
        return
    default:
    }

    // 判断父对象类型是否为 contetx.cancelCtx
    if p, ok := parentCancelCtx(parent); ok {
        
        // 关联父子 context 对象
        p.children[child] = struct{}{}

    } else {

        // 子协程解决:如果父对象完结,则敞开以后 context 对象
        go func() {
            select {case <-parent.Done():
                child.cancel(false, parent.Err())
            case <-child.Done():}
        }()}
}

func (c *cancelCtx) cancel(removeFromParent bool, err error) {
    // 敞开管道,用于告诉 context 完结了
    d, _ := c.done.Load().(chan struct{})
    close(d)

    // 完结所有子对象
    for child := range c.children {child.cancel(false, err)
    }
}

  基于 context.WithCancel 衍生新的可勾销 context 对象时,留神要保护父子 context 对象之间的关系,而且在父对象完结后,也须要完结其所有子对象,顺次类推。另外,context.WithCancel 返回的 CancelFunc 函数,主逻辑其实也就是敞开管道,以此传递 context 对象敞开信号。

  contetx.timerCtx 与 contetx.cancelCtx 还是比拟相似的,只不过内置了定时器而已,定时器触发时主动敞开 context 对象,并没有其余区别,这里就不再赘述。

  再次强调,基于 context 实现工作的超时管制,在 Go 语言中十分常见,能够说到处都能看到,所以肯定要纯熟应用并理解其原理。

全链路追踪

  全链路追踪是什么意思呢?构想你保护着一个 Go HTTP 服务,申请处理过程必定会记录一些日志,不然遇到问题怎么排查定位?可是,一个 HTTP 申请过程必定会记录多条日志,如何将这些日志汇总起来呢?要晓得日志文件里这些日志记录可是扩散开的。能够基于 traceId 实现,即所有日志记录都蕴含 traceId 字段,并且同一个申请打印的日志 traceId 雷同,这就是全链路追踪了;更进一步,如果还依赖了其余第三方服务,在向他们发动 HTTP 申请时,也能够携带 traceId,第三方服务打印日志也携带该 traceId,这样咱们甚至能汇总一个用户申请波及的多个服务间的日志(这只能汇总,还不能直观剖析其调用关系,个别还会有其余字段形容调用关系)。

  为什么全链路追踪要放到 context 这一篇文章介绍呢?因为日志要记录 traceId,可是 traceId 从哪来?上下文呗,也就是 context 了。所以你的 Go 服务,所有函数的第一个参数最好都是 context.Context。大略应该是这个样子:

func Handler(ctx context.Context, req Req) (resp Resp, err error) {
    // 出错,记录 error 日志
    if err != nil {logger.Errorf(ctx, "xxxx error:%v", err)
        return
    }
}

func Errorf(ctx context.Context, tag, template string, args ...interface{}) {logger.With("traceId", ctx.Value("traceId")).Errorf(template, args...)
}

  那如果已有的 Go 我的项目,参数的确没有 context.Context 呢?革新所有函数吗?这老本挺高的,当然也能够尝试革新。退而求其次,其实还有一个下下策,日志中记录协程 ID,这样同一个协程记录的日志能够依据该 ID 汇总(子协程执行的工作就无奈汇总了)。

  不过,Go 语言貌似没有提供形式获取协程 ID,的确没有(不倡议)。这里提一个开源组件(https://github.com/petermatti…),封装了协程 ID 的获取形式,当然底层也是基于线程本地存储获取的,还记得不,Go 语言以后调度的协程 g 会保留在线程本地存储,有了 g 对象,是不是就能获取到协程 ID。这里略微理解一下就能够。

func getg() *g

func Get() int64 {return getg().goid
}

TEXT ·Get(SB),NOSPLIT,$0-8
    // 协程本地存储 TLS
    MOVQ (TLS), R14
    MOVQ g_goid(R14), R13
    MOVQ R13, ret+0(FP)
    RET

总结

  基于 context 实现工作的超时管制,在 Go 语言中十分常见,能够说到处都能看到,所以本篇文章介绍了 context 是如何在上下文传递 key-value 键值对,以及管制超时的。最初还简略形容了全链路追踪的含意,在日常 Go 我的项目开发中,肯定要记得,所有函数的第一个参数最好都是 context.Context。

退出移动版