乐趣区

Go之context包的分析

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

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

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

先决条件

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

  • goroutine
  • channel

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

  • 目录
退出移动版