zoukankan      html  css  js  c++  java
  • golang context的一些思考

    前言

    因为goroutine,go的并发非常方便,但是这也带来了另外一个问题,当我们进行一个耗时的异步操作时,如何在约定的时间内终止该操作并返回一个自定义的结果?这也是大家常说的我们如何去终止一个goroutine(因为goroutine不同于os线程,没有主动interrupt机制),这里就轮到今天的主角context登场了。

    context源于google,于1.7版本加入标准库,按照官方文档的说法,它是一个请求的全局上下文,携带了截止时间、手动取消等信号,并包含一个并发安全的map用于携带数据。context的API比较简单,标准库实现上也比较干净、独立,接下来我会从具体的使用场景和源码分析两个角度进行阐述。

    使用技巧

    使用场景一: 请求链路传值

    一般来说,我们的根context会在请求的入口处构造如下

    1
    ctx := context.Background()

    如果拿捏不准是否需要一个全局的context,可以使用下面这个函数构造

    1
    ctx := context.TODO()

    但是不可以为nil。
    传值使用方式如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    package main

    import (
    "context"
    "fmt"
    )

    func func1(ctx context.Context) {
    ctx = context.WithValue(ctx, "k1", "v1")
    func2(ctx)
    }
    func func2(ctx context.Context) {
    fmt.Println(ctx.Value("k1").(string))
    }

    func main() {
    ctx := context.Background()
    func1(ctx)
    }

    我们在func1通过WithValue(parent Context, key, val interface{}) Context,赋值k1为v1,在其下层函数func2通过ctx.Value(key interface{}) interface{}获取k1的值,比较简单。这里有个疑问,如果我是在func2里赋值,在func1里面能够拿到这个值吗?答案是不能,context只能自上而下携带值,这个是要注意的一点。

    使用场景二: 取消耗时操作,及时释放资源

    可以考虑这样一个问题,如果没有context包,我们如何取消一个耗时操作呢?我这里模拟了两种写法

    • 网络交互场景,经常通过SetReadDeadline、SetWriteDeadline、SetDeadline进行超时取消
    1
    2
    3
    4

    timeout := 10 * time.Second
    t = time.Now().Add(timeout)
    conn.SetDeadline(t)
    • 耗时操作场景,通过select模拟
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      package main

      import (
      "errors"
      "fmt"
      "time"
      )

      func func1() error {
      respC := make(chan int)
      // 处理逻辑
      go func() {
      time.Sleep(time.Second * 3)
      respC <- 10
      close(respC)
      }()

      // 超时逻辑
      select {
      case r := <-respC:
      fmt.Printf("Resp: %d\n", r)
      return nil
      case <-time.After(time.Second * 2):
      fmt.Println("catch timeout")
      return errors.New("timeout")
      }
      }

      func main() {
      err := func1()
      fmt.Printf("func1 error: %v\n", err)
      }

    以上两种方式在工程实践中也会经常用到,下面我们来看下如何使用context进行主动取消、超时取消以及存在多个timeout时如何处理

    • 主动取消

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      package main

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

      func func1(ctx context.Context, wg *sync.WaitGroup) error {
      defer wg.Done()
      respC := make(chan int)
      // 处理逻辑
      go func() {
      time.Sleep(time.Second * 5)
      respC <- 10
      }()
      // 取消机制
      select {
      case <-ctx.Done():
      fmt.Println("cancel")
      return errors.New("cancel")
      case r := <-respC:
      fmt.Println(r)
      return nil
      }
      }

      func main() {
      wg := new(sync.WaitGroup)
      ctx, cancel := context.WithCancel(context.Background())
      wg.Add(1)
      go func1(ctx, wg)
      time.Sleep(time.Second * 2)
      // 触发取消
      cancel()
      // 等待goroutine退出
      wg.Wait()
      }
    • 超时取消

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      package main

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

      func func1(ctx context.Context) {
      hctx, hcancel := context.WithTimeout(ctx, time.Second*4)
      defer hcancel()

      resp := make(chan struct{}, 1)
      // 处理逻辑
      go func() {
      // 处理耗时
      time.Sleep(time.Second * 10)
      resp <- struct{}{}
      }()

      // 超时机制
      select {
      // case <-ctx.Done():
      // fmt.Println("ctx timeout")
      // fmt.Println(ctx.Err())
      case <-hctx.Done():
      fmt.Println("hctx timeout")
      fmt.Println(hctx.Err())
      case v := <-resp:
      fmt.Println("test2 function handle done")
      fmt.Printf("result: %v\n", v)
      }
      fmt.Println("test2 finish")
      return

      }

      func main() {
      ctx, cancel := context.WithTimeout(context.Background(), time.Second*2)
      defer cancel()
      func1(ctx)
      }

    对于多个超时时间的处理,可以把上述超时取消例子中的注释打开,会观察到,当处理两个ctx时,时间短的会优先触发,这种情况下,如果只判定一个context的Done()也是可以的,但是一定要保证调用到两个cancel函数

    源码分析

    context中的接口

    在使用场景中可以看到context包本身包含了数个导出函数,包括WithValue、WithTimeout等,无论是最初构造context还是传导context,最核心的接口类型都是context.Context,任何一种context也都实现了该接口,包括value context。

    到底有几种context?

    既然context都需要实现Context,那么包括不直接可见(非导出)的结构体,一共有几种context呢?答案是4种。

    • 类型一: emptyCtx,context之源头

    emptyCtx定义如下

    1
    type emptyCtx int

    为了减轻gc压力,emptyCtx其实是一个int,并且以do nothing的方式实现了Context接口,还记得context包里面有两个初始化context的函数

    1
    2
    func Background() Context
    func TODO() Context

    这两个函数返回的实现类型即为emptyCtx,而在contex包中实现了两个emptyCtx类型的全局变量: background、todo,其定义如下

    1
    2
    3
    4
    var (
    background = new(emptyCtx)
    todo = new(emptyCtx)
    )

    上述两个函数依次对应这两个全局变量。到这里我们可以很确定地说context的根节点就是一个int全局变量,并且Background()和TODO()是一样的。所以千万不要用nil作为context,并且从易于理解的角度出发,未考虑清楚是否传递、如何传递context时用TODO,其他情况都用Background(),如请求入口初始化context

    • 类型二: cancelCtx,cancel机制之灵魂

    cancelCtx的cancel机制是手工取消、超时取消的内部实现,其定义如下

    1
    2
    3
    4
    5
    6
    7
    8
    type cancelCtx struct {
    Context

    mu sync.Mutex
    done chan struct{}
    children map[canceler]struct{}
    err error
    }

    这里的mu是context并发安全的关键、done是通知的关键、children存储结构是内部最常用传导context的方式。

    • 类型三: timerCtx,cancel机制的场景补充

    timerCtx内部包含了cancelCtx,然后通过定时器,实现了到时取消的功能,定义如下

    1
    2
    3
    4
    5
    6
    type timerCtx struct {
    cancelCtx
    timer *time.Timer // Under cancelCtx.mu.

    deadline time.Time
    }

    这里deadline只做记录、String()等边缘功能,timer才是关键。

    • 类型四: valueCtx,传值

    valueCtx是四个类型的最后一个,只用来传值,当然也可以传递,所有context都可以传递,定义如下

    1
    2
    3
    4
    type valueCtx struct {
    Context
    key, val interface{}
    }

    由于有的人认为context应该只用来传值、有的人认为context的cancel机制才是核心,所以对于valueCtx也在下面做了一个单独的介绍,大家可以通过把握内部实现后按照自己的业务场景做一个取舍(传值可以用一个全局结构体、map之类)。

    value context的底层是map吗?

    在上面valueCtx的定义中,我们可以看出其实value context底层不是一个map,而是每一个单独的kv映射都对应一个valueCtx,当传递多个值时就要构造多个ctx。同时,这要是value contex不能自低向上传递值的原因。

    valueCtx的key、val都是接口类型,在调用WithValue的时候,内部会首先通过反射确定key是否可比较类型(同map中的key),然后赋值key

    在调用Value的时候,内部会首先在本context查找对应的key,如果没有找到会在parent context中递归寻找,这也是value可以自顶向下传值的原因。

    context是如何传递的

    首先可以明确,任何一种context都具有传递性,而传递性的内在机制可以理解为: 在调用WithCancel、WithTimeout、WithValue时如何处理父子context。从传递性的角度来说,几种With*函数内部都是通过propagateCancel这个函数来实现的,下面以WithCancel函数为例

    1
    2
    3
    4
    5
    func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
    c := newCancelCtx(parent)
    propagateCancel(parent, &c)
    return &c, func() { c.cancel(true, Canceled) }
    }

    newCancelCtx是cancelCtx赋值父context的过程,而propagateCancel建立父子context之间的联系。

    propagateCance定义如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    func propagateCancel(parent Context, child canceler) {
    if parent.Done() == nil {
    return // parent is never canceled
    }
    if p, ok := parentCancelCtx(parent); ok {// context包内部可以直接识别、处理的类型
    p.mu.Lock()
    if p.err != nil {
    // parent has already been canceled
    child.cancel(false, p.err)
    } else {
    if p.children == nil {
    p.children = make(map[canceler]struct{})
    }
    p.children[child] = struct{}{}
    }
    p.mu.Unlock()
    } else {// context包内部不能直接处理的类型,比如type A struct{context.Context},这种静默包含的方式
    go func() {
    select {
    case <-parent.Done():
    child.cancel(false, parent.Err())
    case <-child.Done():
    }
    }()
    }
    }

    1.如果parent.Done是nil,则不做任何处理,因为parent context永远不会取消,比如TODO()、Background()、WithValue等。
    2.parentCancelCtx根据parent context的类型,返回bool型ok,ok为真时需要建立parent对应的children,并保存parent->child映射关系(cancelCtx、timerCtx这两种类型会建立,valueCtx类型会一直向上寻找,而循环往上找是因为cancel是必须的,然后找一种最合理的),这里children的key是canceler接口,并不能处理所有的外部类型,所以会有else,示例见上述代码注释处。对于其他外部类型,不建立直接的传递关系。
    parentCancelCtx定义如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    func parentCancelCtx(parent Context) (*cancelCtx, bool) {
    for {
    switch c := parent.(type) {
    case *cancelCtx:
    return c, true
    case *timerCtx:
    return &c.cancelCtx, true
    case *valueCtx:
    parent = c.Context // 循环往上寻找
    default:
    return nil, false
    }
    }
    }

    context是如何触发取消的

    上文在阐述传递性时的实现时,也包含了一部分取消机制的代码,这里不会再列出源码,但是会依据上述源码进行说明。对于几种context,传递过程大同小异,但是取消机制有所不同,针对每种类型,我会一一解释。不同类型的context可以在一条链路进行取消,但是每一个context的取消只会被一种条件触发,所以下面会单独介绍下每一种context的取消机制(组合取消的场景,按照先到先得的原则,无论那种条件触发的,都会传递调用cancel)。这里有两个设计很关键:

    1. cancel函数是幂等的,可以被多次调用。
    2. context中包含done channel可以用来确认是否取消、通知取消。
    • cancelCtx类型

    cancelCtx会主动进行取消,在自顶向下取消的过程中,会遍历children context,然后依次主动取消。
    cancel函数定义如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    func (c *cancelCtx) cancel(removeFromParent bool, err error) {
    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
    if c.done == nil {
    c.done = closedchan
    } else {
    close(c.done)
    }
    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()

    if removeFromParent {
    removeChild(c.Context, c)
    }
    }
    • timerCtx类型

    WithTimeout是通过WithDeadline来实现的,均对应timerCtx类型。通过parentCancelCtx函数的定义我们知道,timerCtx也会记录父子context关系。但是timerCtx是通过timer定时器触发cancel调用的,部分实现如下

    1
    2
    3
    4
    5
    if c.err == nil {
    c.timer = time.AfterFunc(dur, func() {
    c.cancel(true, DeadlineExceeded)
    })
    }
    • 静默包含context

    这里暂时只想到了静默包含即type A struct{context.Context}的情况。通过parentCancelCtx和propagateCancel我们知道这种context不会建立父子context的直接联系,但是会通过单独的goroutine去检测done channel,来确定是否需要触发链路上的cancel函数,实现见propagateCancel的else部分。

    结尾

    context的使用注意大致有以下三点:

    • context只能自顶向下传值,反之则不可以。
    • 如果有cancel,一定要保证调用,否则会造成资源泄露,比如timer泄露。
    • context一定不能为nil,如果不确定,可以使用context.TODO()生成一个empty的context。

    context的实现并不复杂,但在实际使用中能给大家带来不小的便利。使用场景部分通过介绍几个常见的开发场景使大家对号入座,熟练地使用context;源码分析部分,通过了解context的实现,能够在context使用中更加得心应手,做到知其然知其所以然,谢谢。

    参考资料

    golang官方包

    Go Concurrency Patterns: Context

    etcd客户端超时处理示例代码

     
  • 相关阅读:
    博客发在oschina
    博客发在oschina
    C#将dll打包到程序中
    C#将dll打包到程序中
    WPF checkbox文字下掉
    WPF checkbox文字下掉
    手机1520 win8.1升级win10
    CRITICAL **: Couldn't acquire global lock, snapshots will not be consistent: Access denied
    [学习笔记]Dsu On Tree
    手机1520 win8.1升级win10
  • 原文地址:https://www.cnblogs.com/ExMan/p/15563337.html
Copyright © 2011-2022 走看看