Go之context包的分析

context是Go语言官方定义的一个包,称之为上下文。

Go中的context包在与API和慢进程交互时可以派上用场,特别是在提供Web请求的生产级系统中。在哪里,您可能想要通知所有goroutines停止工作并返回。

这是一个基本教程,介绍如何在项目中使用它以及一些最佳实践和陷阱。

先决条件

在了解上下文之前,请先了解以下概念

Context

在Go语言中 context 包允许您传递一个 "context" 到您的程序,如超时或截止日期(deadline)或通道(channel),以及指示停止运行和返回等。例如,如果您正在执行Web请求或运行系统命令,那么对生产级系统进行超时控制通常是个好主意。因为,如果您依赖的API运行缓慢,您不希望在系统上备份请求,这可能最终会增加负载并降低您所服务的所有请求的性能。导致级联效应。这是超时或截止日期context可以派上用场的地方。

这里我们先来分析context源码( https://golang.org/src/contex...)。

context包的核心就是Context接口,其定义如下:

type Context interface {

    Deadline() (deadline time.Time, ok bool)

    Done() <-chan struct{}
    Err() error   
    Value(key interface{}) interface{}
}

这个接口共有4个方法:

  • Deadline`方法是获取设置的截止时间的意思,第一个返回式是截止时间,到了这个时间点,Context会自动发起取消请求;第二个返回值ok==false时表示没有设置截止时间,如果需要取消的话,需要调用取消函数进行取消。
  • Done方法返回一个只读的chan,类型为struct{},我们在goroutine中,如果该方法返回的chan可以读取,则意味着parent context已经发起了取消请求,我们通过Done方法收到这个信号后,就应该做清理操作,然后退出goroutine,释放资源。
  • Err方法返回取消的错误原因,因为什么Context被取消。
  • Value方法获取该Context上绑定的值,是一个键值对,所以要通过一个Key才可以获取对应的值,这个值一般是线程安全的。但使用这些数据的时候要注意同步,比如返回了一个map,而这个map的读写则要加锁。

以上四个方法中常用的就是Done了,如果Context取消的时候,我们就可以得到一个关闭的chan,关闭的chan是可以读取的,所以只要可以读取的时候,就意味着收到Context取消的信号了,以下是这个方法的经典用法。

  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:
          }
      }
  }

Context接口并不需要我们实现,Go内置已经帮我们实现了2个(Background、TODO),我们代码中最开始都是以这两个内置的作为最顶层的partent context(即根context),衍生出更多的子Context。

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

func Background() Context {
    return background
}

func TODO() Context {
    return todo
}

Background:主要用于main函数、初始化以及测试代码中,作为Context这个树结构的最顶层的Context,也就是根Context。

TODO:在还不确定使用context的场景,可能当前函数以后会更新以便使用 context。

这两个函数的本质是emptyCtx结构体类型。

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
}

这就是emptyCtx实现Context接口的方法,可以看到,这些方法什么都没做,返回的都是nil或者零值。

context衍生节点

有上面的根context,那么是如何衍生更多的子Context的呢?这就要靠context包为我们提供的With系列的函数了。

1、取消函数

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

此函数接收一个parent Context参数,父 context 可以是后台 context 或传递给函数的 context。

返回派生 context 和取消函数。只有创建它的函数才能调用取消函数来取消此 context。如果您愿意,可以传递取消函数,但是,强烈建议不要这样做。这可能导致取消函数的调用者没有意识到取消 context 的下游影响。可能存在源自此的其他 context,这可能导致程序以意外的方式运行。简而言之,永远不要传递取消函数。

示例

package main

import (
   "fmt"
   "time"
   "golang.org/x/net/context"
)

func main() {
   //创建一个可取消子context,context.Background():返回一个空的Context,这个空的Context一般用于整个Context树的根节点。
   ctx, cancel := context.WithCancel(context.Background())
   go func(ctx context.Context) {
      for {
         select {
         //使用select调用<-ctx.Done()判断是否要结束
         case <-ctx.Done():
            fmt.Println("goroutine exit")
            return
         default:
            fmt.Println("goroutine running.")
            time.Sleep(2 * time.Second)
         }
      }
   }(ctx)

   time.Sleep(10 * time.Second)
   fmt.Println("main fun exit")
   //取消context
   cancel()
   time.Sleep(5 * time.Second)

 }

2、超时控制

func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc):

此函数返回其父项的派生 context,当截止日期超过或取消函数被调用时,该 context 将被取消。例如,您可以创建一个将在以后的某个时间自动取消的 context,并在子函数中传递它。当因为截止日期耗尽而取消该 context 时,获此 context 的所有函数都会收到通知去停止运行并返回。

示例

package main

import (
   "fmt"
   "golang.org/x/net/context"
   "time"
)

func main() {
   d := time.Now().Add(2 * time.Second)
   //设置超时控制WithDeadline,超时时间2
   ctx, cancel := context.WithDeadline(context.Background(), d)

   defer cancel()
   select {
   case <-time.After(3 * time.Second):
      fmt.Println("timeout")
   case <-ctx.Done():
      //2到了到了,执行该代码
      fmt.Println(ctx.Err())
   }

}

3、超时控制

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc):

此函数类似于 context.WithDeadline。不同之处在于它将持续时间作为参数输入而不是时间对象。此函数返回派生 context,如果调用取消函数或超出超时持续时间,则会取消该派生 context。

package main

import (
   "fmt"
   "golang.org/x/net/context"
   "time"
)

func main() {

   //设置超时控制WithDeadline,超时时间2
   ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)

   defer cancel()
   select {
   case <-time.After(3 * time.Second):
      fmt.Println("timeout")
   case <-ctx.Done():
      //2到了到了,执行该代码
      fmt.Println(ctx.Err())
   }

}

4、返回派生的context

func WithValue(parent Context, key, val interface{}) Context:

此函数接收 context 并返回派生 context,其中值 val 与 key 关联,并通过 context 树与 context 一起传递。这意味着一旦获得带有值的 context,从中派生的任何 context 都会获得此值。不建议使用 context 值传递关键参数,而是函数应接收签名中的那些值,使其显式化。

示例

package main

import (
   "context"
   "fmt"
)

func Route(ctx context.Context) {
   ret, ok := ctx.Value("id").(int)
   if !ok {
      ret = 1
   }
   fmt.Printf("id:%d\n", ret)
   s, _ := ctx.Value("name").(string)
   fmt.Printf("name:%s\n", s)
}

func main() {
   ctx := context.WithValue(context.Background(), "id", 123)
   ctx = context.WithValue(ctx, "name", "jerry")
   Route(ctx)
}

在函数中接受和使用context

在下面的示例中,您可以看到接受context的函数启动goroutine并等待返回该goroutine或取消该context。select语句帮助我们选择先发生的任何情况并返回。

<-ctx.Done()关闭“完成”通道后,将case <-ctx.Done():选中该通道。一旦发生这种情况,该功能应该放弃工作并准备返回。这意味着您应该关闭所有打开的管道,释放资源并从函数返回。有些情况下,释放资源可以阻止返回,比如做一些挂起的清理等等。在处理context返回时,你应该注意任何这样的可能性。

本节后面的示例有一个完整的go程序,它说明了超时和取消功能。

//Function that does slow processing with a context
//Note that context is the first argument
func sleepRandomContext(ctx context.Context, ch chan bool) {

  //Cleanup tasks
  //There are no contexts being created here
  //Hence, no canceling needed
  defer func() {
    fmt.Println("sleepRandomContext complete")
    ch <- true
  }()

  //Make a channel
  sleeptimeChan := make(chan int)

  //Start slow processing in a goroutine
  //Send a channel for communication
  go sleepRandom("sleepRandomContext", sleeptimeChan)

  //Use a select statement to exit out if context expires
  select {
  case <-ctx.Done():
    //If context expires, this case is selected
    //Free up resources that may no longer be needed because of aborting the work
    //Signal all the goroutines that should stop work (use channels)
    //Usually, you would send something on channel,
    //wait for goroutines to exit and then return
    //Or, use wait groups instead of channels for synchronization
    fmt.Println("Time to return")
  case sleeptime := <-sleeptimeChan:
    //This case is selected when processing finishes before the context is cancelled
    fmt.Println("Slept for ", sleeptime, "ms")
  }
}

例子

到目前为止,我们已经看到使用 context 可以设置截止日期,超时或调用取消函数来通知所有使用任何派生 context 的函数来停止运行并返回。以下是它如何工作的示例:

main 函数

  • 用 cancel 创建一个 context
  • 随机超时后调用取消函数

doWorkContext 函数

  • 派生一个超时 context
  • 这个 context 将被取消当

    • main 调用取消函数或
    • 超时到或
    • doWorkContext 调用它的取消函数
  • 启动 goroutine 传入派生context执行一些慢处理
  • 等待 goroutine 完成或context被 main goroutine 取消,以优先发生者为准

sleepRandomContext 函数

  • 开启一个 goroutine 去做些缓慢的处理
  • 等待该 goroutine 完成或,
  • 等待 context 被 main goroutine 取消,操时或它自己的取消函数被调用

sleepRandom 函数

  • 随机时间休眠
  • 此示例使用休眠来模拟随机处理时间,在实际示例中,您可以使用通道来通知此函数,以开始清理并在通道上等待它,以确认清理已完成。

Playground: https://play.golang.org/p/grQ... (看起来我使用的随机种子,在 playground 时间没有真正改变,您需要在你本机执行去看随机性)

Github: https://github.com/pagnihotry...

package main

import (
  "context"
  "fmt"
  "math/rand"
  "time"
)

//Slow function
func sleepRandom(fromFunction string, ch chan int) {
  //defer cleanup
  defer func() { fmt.Println(fromFunction, "sleepRandom complete") }()

  //Perform a slow task
  //For illustration purpose,
  //Sleep here for random ms
  seed := time.Now().UnixNano()
  r := rand.New(rand.NewSource(seed))
  randomNumber := r.Intn(100)
  sleeptime := randomNumber + 100
  fmt.Println(fromFunction, "Starting sleep for", sleeptime, "ms")
  time.Sleep(time.Duration(sleeptime) * time.Millisecond)
  fmt.Println(fromFunction, "Waking up, slept for ", sleeptime, "ms")

  //write on the channel if it was passed in
  if ch != nil {
    ch <- sleeptime
  }
}

//Function that does slow processing with a context
//Note that context is the first argument
func sleepRandomContext(ctx context.Context, ch chan bool) {

  //Cleanup tasks
  //There are no contexts being created here
  //Hence, no canceling needed
  defer func() {
    fmt.Println("sleepRandomContext complete")
    ch <- true
  }()

  //Make a channel
  sleeptimeChan := make(chan int)

  //Start slow processing in a goroutine
  //Send a channel for communication
  go sleepRandom("sleepRandomContext", sleeptimeChan)

  //Use a select statement to exit out if context expires
  select {
  case <-ctx.Done():
    //If context is cancelled, this case is selected
    //This can happen if the timeout doWorkContext expires or
    //doWorkContext calls cancelFunction or main calls cancelFunction
    //Free up resources that may no longer be needed because of aborting the work
    //Signal all the goroutines that should stop work (use channels)
    //Usually, you would send something on channel, 
    //wait for goroutines to exit and then return
    //Or, use wait groups instead of channels for synchronization
    fmt.Println("sleepRandomContext: Time to return")
  case sleeptime := <-sleeptimeChan:
    //This case is selected when processing finishes before the context is cancelled
    fmt.Println("Slept for ", sleeptime, "ms")
  }
}

//A helper function, this can, in the real world do various things.
//In this example, it is just calling one function.
//Here, this could have just lived in main
func doWorkContext(ctx context.Context) {

  //Derive a timeout context from context with cancel
  //Timeout in 150 ms
  //All the contexts derived from this will returns in 150 ms
  ctxWithTimeout, cancelFunction := context.WithTimeout(ctx, time.Duration(150)*time.Millisecond)

  //Cancel to release resources once the function is complete
  defer func() {
    fmt.Println("doWorkContext complete")
    cancelFunction()
  }()

  //Make channel and call context function
  //Can use wait groups as well for this particular case
  //As we do not use the return value sent on channel
  ch := make(chan bool)
  go sleepRandomContext(ctxWithTimeout, ch)

  //Use a select statement to exit out if context expires
  select {
  case <-ctx.Done():
    //This case is selected when the passed in context notifies to stop work
    //In this example, it will be notified when main calls cancelFunction
    fmt.Println("doWorkContext: Time to return")
  case <-ch:
    //This case is selected when processing finishes before the context is cancelled
    fmt.Println("sleepRandomContext returned")
  }
}

func main() {
  //Make a background context
  ctx := context.Background()
  //Derive a context with cancel
  ctxWithCancel, cancelFunction := context.WithCancel(ctx)

  //defer canceling so that all the resources are freed up 
  //For this and the derived contexts
  defer func() {
    fmt.Println("Main Defer: canceling context")
    cancelFunction()
  }()

  //Cancel context after a random time
  //This cancels the request after a random timeout
  //If this happens, all the contexts derived from this should return
  go func() {
    sleepRandom("Main", nil)
    cancelFunction()
    fmt.Println("Main Sleep complete. canceling context")
  }()
  //Do work
  doWorkContext(ctxWithCancel)
}

缺陷

  • 如果函数接收 context 参数,确保检查它是如何处理取消通知的。例如,exec.CommandContext 不会关闭读取管道,直到命令执行了进程创建的所有分支(Github 问题:https://github.com/golang/go/... )之前,不关闭读取器管道,这意味着context取消不会立即返回,直到等待cmd.Wait()外部命令的所有分支都已完成处理。如果您使用超时或最后执行时间的最后期限,您可能会发现这不能按预期工作。如果遇到任何此类问题,可以使用执行超时time.After
  • 在Google,我们要求Go程序员将Context参数作为传入和传出请求之间的调用路径上的每个函数的第一个参数传递。

    这就意味着如果您正在编写一个具有可能需要大量时间的函数的库,并且您的库可能会被服务器应用程序使用,那么您必须接受这些函数中的context。当然,我可以context.TODO()随处通过,但这造成程序可读性差,程序看起来不够优雅。

小结

  1. context.Background只应在最高级别使用,作为所有派生context的根。
  2. context.TODO应该用在不确定要使用什么的地方,或者是否将更新当前函数以便将来使用context。
  3. context 取消是建议性的,功能可能需要时间来清理和退出。
  4. context.Value应该很少使用,它永远不应该用于传递可选参数。这使得API隐含并且可能引入错误。相反,这些值应作为参数传递。
  5. 不要把context放在结构中,在函数中显式传递它们,最好是作为第一个参数。
  6. 如果您不确定要使用什么,请不要传递nil,而是使用TODO。
  7. Contextstruct没有cancel方法,因为只有派生context的函数才能取消它。
  8. Context是线程安全的,可以放心的在多个goroutine中传递。

参考:

http://p.agnihotry.com/post/u...

https://www.flysnow.org/2017/...

https://faiface.github.io/pos...

links

阅读 909

推荐阅读
golang开发笔记
用户专栏

微不足道的进步也是进步,学习是一个不断积累的过程。

427 人关注
33 篇文章
专栏主页