Go context 包

一、context

什么是context?

Go 1.7 标准库引入 context,中文译作“上下文”,准确说它是 goroutine 的上下文,包含 goroutine 的运行状态、环境、现场等信息。context 主要用来在 goroutine 之间传递上下文信息,包括:取消信号、超时时间、截止时间、k-v 等。随着 context 包的引入,标准库中很多接口因此加上了 context 参数,例如 database/sql 包。context 几乎成为了并发控制和超时控制的标准做法。

go 为什么要引入 context?

在并发程序中,由于超时、取消操作或者一些异常情况,往往需要进行抢占操作或者中断后续操作。例如在在 Go 的 server 里,通常每来一个请求都会启动若干个 goroutine 同时工作:有些去数据库拿数据,有些调用下游接口获取相关数据……,这些 goroutine 需要共享这个请求的基本数据,例如登录token,处理请求的最大超时时间(如果超过此值再返回数据,请求方因为超时接收不到)等等。当请求被取消或者是处理时间太长(可能是使用者关闭了浏览器或是已经超过了请求方规定的超时时间,请求方直接放弃了这次请求结果),这时所有正在为该请求工作的 goroutine 需要快速并优雅的推出,因为这时候不需要他们的任何工作成果了,在相关的 goroutine 退出后,系统就可以回收相关的资源,Go 语言中的 server 实际上是一个“协程模型”,也就是说一个协程处理一个请求。例如在业务的高峰期,某个下游服务的响应变慢,而当前系统的请求又没有超时控制,或者超时时间设置地过大,那么等待下游服务返回数据的协程就会越来越多。而我们知道,协程是要消耗系统资源的,后果就是协程数激增,内存占用飙涨,甚至导致服务不可用。更严重的会导致雪崩效应,整个服务对外表现为不可用,其实前面描述的问题通过设置“允许下游最长处理时间”就可以避免。例如,给下游设置的 timeout 是 50 ms,如果超过这个值还没有接收到返回数据,就直接向客户端返回一个默认值或者错误。context 包就是为了解决上面所说的这些问题而开发的:在 一组 goroutine 之间传递共享的值、取消信号、deadline……,这也就是为什么我们常常将context搭配channel一起使用。

1.1、context的核心方法

  • context.WithValue:设置键值对,并且返回一个新的 context 实例;
  • context.WithCancel;
  • context.WithDeadline;
  • context.WithTimeout:三者都返回一个可取消的 context 实例,和取消函数

注意:context 实例是不可变的,每一次都是新创建的。

1.2、Context 核心接口

  • Deadline:返回过期时间,如果ok为false,说明没有设置过期时间。不常用;
  • Done:返回一个channel,一般用于监听Context实例的信号,比如说过期,或者正常关闭。常用;
  • Err:返回一个错误用于表达Context发生了什么,Canceled=>正常关闭,DeadlineExceeded=>过期超时。比较常用;
  • context.Value:取值。非常常用;

源码如下:

1.3 valueCtx实现

valueCtx用于存储key-value数据,特点

  • 典型的装饰器模式:在已有Context的基础上附加一个存储key-value的功能;
  • 只能存储一个key,val:为什么不用map?
    • map要求key是comparable的,而我们可能用不是comparable的key;
    • context包的设计理念就是将Context设计成不可变;
func TestContext(t *testing.T)  {
   ctx := context.Background()  // context.Background() 代表这是一个 上下文的 开始/起点
   valCtx := context.WithValue(ctx, "key1", 999)
   val := valCtx.Value("kay1")
   fmt.Println(val)
}


func SomeBusiness() {
   ctx := context.TODO()  // 意味着对当前的上下文位置模糊, 未确定状态
   Step1()
}

复制代码

1.4、安全传递数据

context包我们就用来做两件事:

  • 安全传递数据
    • 安全传递数据,是指在请求执行上下文中线程安全地传递数据, 依赖于 WithValue 方法;
  • 控制链路
  • 因为Go本身没有thread-local机制,所以大部分类似的功能都是借助于context来实现的。

例子:

  • 分库分表中间件中传递shardinghint
  • ORM中间件传递SQLhint
  • Web框架传递上下文
  • 链路追踪的 trace id
  • AB测试的标记位
  • 压力测试标记位

进程内传递就是依赖于context.Context传递的,也就是意味着所有的方法都必须有context.Context参数。

1.5、父子关系

特点:context 的实例之间存在父子关系

  • 当父亲取消或者超时,所有派生的子context 都被取消或者超时;
  • 当找 key 的时候,子 context 先看自己有没有,没有则去祖先里面找;

控制是从上至下的,查找是从下至上的。

func TestParentCtx(t *testing.T)  {
   ctx := context.Background()

   //dlCtx, cancel := context.WithDeadline(ctx, time.Now().Add(time.Minute))
   // ctx, cancel := context.WithCancel(ctx)
   toCtx, cancel := context.WithTimeout(ctx, 3 * time.Second)

   // childCtx := context.WithValue(dlCtx, "name", "kfc")
   // childCtx := context.WithValue(ctx, "name", "kfc")
   childCtx := context.WithValue(toCtx, "name", "kfc")

   defer cancel()
   
   err := childCtx.Err()
   fmt.Println(err)

}
复制代码

父无法访问子内容:

因为父context始终无法拿到子context设置的值,所以在逼不得已的时候我们可以在父context里面放一个map,后续都是修改这个map。

示例如下:

func TestMapParentValueCtx() {
   ctx := context.Background()
   childCtx := context.WithValue(ctx, "map", map[string]string{})
   ccChild := context.WithValue(childCtx, "age", "111")
   m := ccChild.Value("map").(map[string]string)
   m["age"] = "121"
   val := childCtx.Value("age")  // 正常情况父 ctx 是无法获取子 ctx设置的value
   fmt.Println(val)  
   val = childCtx.Value("map")  // 由于父ctx中设置了一个引用数据结构,当引用数据结构修改时,也能被父ctx获取
   fmt.Println(val)
}

执行结果:
<nil>
map[age:121]
复制代码

1.6、控制

context 包提供了三个控制方法, WithCancel、WithDeadline 和 WithTimeout。三者用法大同小异:

  • 没有过期时间,但是又需要在必要的时候取 消,使用 WithCancel
  • 在固定时间点过期,使用 WithDeadline
  • 在一段时间后过期,使用 WithTimeout

而后便是监听 Done(); Done()返回一个 channel,不管是主动调用 cancel() 还是超时,都能从这个 channel 里面取出来数据。后面可以用 Err() 方法来判断究竟是哪种情况。

代码示例:

func CtxTimeOut()  {
   bg := context.Background()
   timeOutCtx, cancel1 := context.WithTimeout(bg, 1*time.Second)
   subCtx, cancel2 := context.WithTimeout(timeOutCtx, 3*time.Second)

   go func() {
      // 一秒钟后就会过期, 然后输出timeout
      <- subCtx.Done()
      fmt.Println("timeout")
   }()

   time.Sleep(2 * time.Second)
   cancel2()
   cancel1()

}
复制代码

子 context 试图重新设置超时时间,然而并没有成功,它依旧受到了父亲的控制; 父亲可以控制儿子,但是儿子控制不了父亲。

1.7、cancelCtx 实现

cancelCtx 也是典型的装饰器模式:在已有 Context 的基础上,加上取消/停止的功能。

核心实现:

  • Done 方法是通过类似于 double-check 的机制写的。这种原子操作和锁结合的用法比较罕见。(思考:能不能换成读写锁?);
  • 利用 children 来维护了所有的衍生节点, 难点就在于它是如何维护这个衍生节点。

源码如下:

// A cancelCtx can be canceled. When canceled, it also cancels any children
// that implement 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
}


func (c *cancelCtx) Done() <-chan struct{} {
   d := c.done.Load()
   if d != nil {
      return d.(chan struct{})
   }
   c.mu.Lock()
   defer c.mu.Unlock()
   d = c.done.Load()
   if d == nil {
      d = make(chan struct{})
      c.done.Store(d)
   }
   return d.(chan struct{})
}
复制代码
  • children核心是儿子把自己加进去父亲的 children 字段里面。
  • 但是因为 Context 里面存在非常多的层级, 所以父亲不一定是 cancelCtx,因此本质上 是找最近属于 cancelCtx 类型的祖先,然后儿子把自己加进去。
  • cancel 就是遍历 children,挨个调用 cancel。然后儿子调用孙子的 cancel,子 子孙孙无穷匮也。

源码如下:


// propagateCancel arranges for child to be canceled when parent is.
func propagateCancel(parent Context, child canceler) {
   done := parent.Done()
   if done == nil {
      return // parent is never canceled
   }

   select {
   case <-done:
      // parent is already canceled
      child.cancel(false, parent.Err())
      return
   default:
   }
    //  找最近属于 cancelCtx 类型的祖先,然后child把自己加进祖先的children里
   if p, ok := parentCancelCtx(parent); ok {
      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 {
       //  找不到就只需要监听parent的信号,或者自己的信号,这些信号源自cancel或者超时
      atomic.AddInt32(&goroutines, +1)
      go func() {
         select {
         case <-parent.Done():
            child.cancel(false, parent.Err())
         case <-child.Done():
         }
      }()
   }
}

复制代码

核心的 cancel 方法,做了两件事:

  • 遍历所有的 children
  • 关闭 done 这个 channel:这个符合谁 创建谁关闭的原则

cancelCtx.cancel 方法 (源码)

// cancel closes c.done, cancels each of c's children, and, if
// removeFromParent is true, removes c from its parent's children.
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
   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()

   if removeFromParent {
      removeChild(c.Context, c)
   }
}
复制代码

1.8、timerCtx 实现

timerCtx 也是装饰器模式:在已有 cancelCtx 的基础上增加了超时的功能。 实现要点:

  • WithTimeout 和 WithDeadline 本质一样
  • WithDeadline 里面,在创建 timerCtx 的时 候利用 time.AfterFunc 来实现超时

源码示例:

// A timerCtx carries a timer and a deadline. It embeds a cancelCtx to
// implement Done and Err. It implements cancel by stopping its timer then
// delegating to cancelCtx.cancel.
type timerCtx struct {
   cancelCtx
   timer *time.Timer // Under cancelCtx.mu.

   deadline time.Time
}



// Canceling this context releases resources associated with it, so code should
// call cancel as soon as the operations running in this Context complete.
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
   if parent == nil {
      panic("cannot create context from nil parent")
   }
   if cur, ok := parent.Deadline(); ok && cur.Before(d) {
      // The current deadline is already sooner than the new one.
      return WithCancel(parent)
   }
   c := &timerCtx{
      cancelCtx: newCancelCtx(parent),
      deadline:  d,
   }
   propagateCancel(parent, c)  // cancal 关系依旧要建立起来
   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() {
          //  超时就 cancal
         c.cancel(true, DeadlineExceeded)
      })
   }
   return c, func() { c.cancel(true, Canceled) }
}

复制代码

1.9、控制超时

最经典用法是利用 context 来控制超时;

控制超时,相当于我们同时监听两个 channel,一个是正常业务结束的 channel,Done() 返回的。

代码实例:

func TimeOutContral()  {
   ctx, cancel := context.WithTimeout(context.Background(), time.Second)
   defer cancel()
   baChan := make(chan struct{})

   go func() {
      slowBiz()
      baChan <- struct{}{}
   }()

   select {
   case <- ctx.Done():
      fmt.Println("timeout")
   case <- baChan:
      fmt.Println("biz end")
   }

}

func slowBiz()  {
   time.Sleep(2 * time.Second)
}
复制代码

1.10、time.AfterFunc

另外一种超时控制是采用 time.AfterFunc:一般这种用 法我们会认为是定时任务,而不是超时控制。

这种超时控制有两个弊端:

  • 如果不主动取消,那么 AfterFunc 是必然会执行的;
  • 如果主动取消,那么在业务正常结束到主动取消之 间,有一个短时间的时间差;

代码实例:

func timeOutTimeAfer()  {
   baChan := make(chan struct{})

   go func() {
      slowBiz()
      baChan <- struct{}{}
   }()

   timer := time.AfterFunc(3 * time.Second, func() {
      fmt.Println("timeout")
   })
   <- baChan
   fmt.Println("biz end")
   timer.Stop()

}

func main()  {
   //TestContext()
   //TestParentCtx()
   TestMapParentValueCtx()
}
复制代码

例子1:DB.conn 控制超

  1. 首先直接检查一次 context.Context 有没有超时。
  2. 这种提前检测一下的用法还是比较常见的。比 如说 RPC 链路超时控制就可以先看看 context 有没有超时。
  3. 如果超时则可以不发送请求,直接返回超时响应。

这是一段来自 Database/sql 的例子(源码):

超时控制至少两个分支:

  • 超时分支
  • 正常业务分支 这段源码同样来自 Database/sql:

所以普遍来说 context.Context 会和 select- case 一起使用。

例子2:http.Request 使用 context 作为字段类型

  • http.Request 本身就是 request-scope 的;
  • http.Request 里面的 ctx 依旧设计为不可变的,我们只能创建一个新的 http.Request;

  • 所以实际上我们没有办法修改一个已有的 http.Request 里面的 ctx;
  • 即便我们要把 context.Context 做成字段, 也要遵循类似的用法;

http.Request 的WithContext方法:

例子3:errgroup.WithContext 利用 context 来传递信号

  • WithContext 会返回一个 context.Context 实例;
  • 如果 errgroup.Group 的 Wait 返回,或 者任何一个 Group 执行的函数返回 error,context.Context 实例都会被取消 (一损俱损);
  • 所以用户可以通过监听 context.Context 来判断 errgroup.Group 的执行情;

这是典型的将 context.Context 作为信号载体 的用法,本质是依赖于 channel 的特性。

下边是 Kratos 利用这个特性来优雅启动服务实例,并且监听服务实例启动情况的代码片 段。

  • 如果黄色框返回(这里的start会进行阻塞,当异常/退出服务器时才会返回),说明是启动有问题,那么 其它启动没有问题的 Server 也会退出,确保要么全部成功,要么全部失败;
  • 如果是蓝色框返回,说明监听到了退出信 号,比如说 ctrl+ C,Server 都会退出;
  • 红色的框里等待退出信号,等到了就关闭服务器。服务器关闭包括异常退出和正常退出。

1.11、使用注意事项:

  • 一般只用做方法参数,而且是作为第一个参数;
  • 所有公共方法,除非是 util,helper 之类的方法,否则都加上 context 参数;
  • 不要用作结构体字段,除非你的结构体本身也是表达一个上下文的概念。

1.12、面试要点

  • context.Context 使用场景:上下文传递和超时控制
  • context.Context 原理:
    • 父亲如何控制儿子:通过儿子主动加入到父亲的 children 里面,父亲只需要遍历 就可以;
    • valueCtx 和 timeCtx 的原理。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值