Golang 中 context 包详解

1,511 阅读16分钟

主要作用

其主要的作用是在 goroutine 中进行上下文的传递,而在传递信息中又包含了 goroutine 的运行控制、上下文信息传递等功能。

什么是 context

Go 语言的独有的功能之一 Context,最常听说开发者说的一句话就是 “函数的第一个形参真的要传 ctx 吗?”,第二句话可能是 “有没有什么办法不传,就能达到传入的效果?”,听起来非常魔幻。

简单讲:新建一个 协程 后,是不是每次都需要将context(执行上下文) 作为参数 带下去? 不需要。通过Context可以实现。

在 Go 语言中 context 作为一个 “一等公民” 的标准库,许多的开源库都一定会对他进行支持,因为标准库 context 的定位是上下文控制。会在跨 goroutine 中进行传播:

image

本质上 Go 语言是 基于 context 来实现和搭建了各类 goroutine 控制的,并且与 select-case联合,就可以实现进行上下文的截止时间、信号控制、信息传递等跨 goroutine 的操作,是 Go 语言协程的重中之重

context 几乎成为了并发控制和超时控制的标准做法。

context.Context 类型的值可以协调多个 groutine 中的代码执行“取消”操作,并且可以存储键值对。最重要的是它是并发安全的。 与它协作的 API 都可以由外部控制执行“取消”操作,例如:取消一个 HTTP 请求的执行。

场景例子

Go 常用来写后台服务,通常只需要几行代码,就可以搭建一个 http server。

在 Go 的 server 里,通常每来一个请求都会启动若干个 goroutine 同时工作:有些去数据库拿数据,有些调用下游接口获取相关数据……

image.png

共享: 这些 goroutine 需要共享这个请求的基本数据,例如登陆的 token,处理请求的最大超时时间(如果超过此值再返回数据,请求方因为超时接收不到)等等。

超时:当请求被取消或是处理时间太长,这有可能是使用者关闭了浏览器或是已经超过了请求方规定的超时时间,请求方直接放弃了这次请求结果。

这时,所有正在为这个请求工作的 goroutine 需要快速退出,因为它们的“工作成果”不再被需要了。在相关联的 goroutine 都退出后,系统就可以回收相关的资源。

再多说一点,Go 语言中的 server 实际上是一个“协程模型”,也就是说一个协程处理一个请求。例如在业务的高峰期,某个下游服务的响应变慢,而当前系统的请求又没有超时控制,或者超时时间设置地过大,那么等待下游服务返回数据的协程就会越来越多。而我们知道,协程是要消耗系统资源的,后果就是协程数激增,内存占用飙涨,甚至导致服务不可用。更严重的会导致雪崩效应,整个服务对外表现为不可用。

context 包就是为了解决上面所说的这些问题而开发的:在 一组 goroutine 之间传递共享的值、取消信号、deadline……

image.png

简单讲:在Go 里,我们不能直接杀死协程,协程的关闭一般会用 channel+select 方式来控制。但是在某些场景下,例如处理一个请求衍生了很多协程,这些协程之间是相互关联的:需要共享一些全局变量、有共同的 deadline 等,而且可以同时被关闭。再用 channel+select 就会比较麻烦,这时就可以通过 context 来实现。

一句话:context 用来解决 goroutine 之间退出通知、元数据传递的功能。

image

代码举例

func main() {
   parentCtx := context.Background()
   ctx, cancel := context.WithTimeout(parentCtx, 1*time.Millisecond)
   defer cancel()
   select {
   case <-time.After(1 * time.Second):
      fmt.Println("oversleep")
   case <-ctx.Done():
      fmt.Println(ctx.Err())
   }
}
// 执行结果为 context deadline exceeded

通过调用标准库 context.WithTimeout 方法针对 parentCtx 变量设置了超时时间。

ontext.background()context.todo() 区别 是什么?

context.background() 和 context.todo() 都是 Go 语言中的上下文包(context package)中的函数。这两个函数都用于创建一个新的上下文(context),但它们之间有一些重要的区别。

  • context.background() 用于创建一个默认的、无限期的上下文。这个上下文通常用于在没有父上下文的情况下启动新的 goroutine。
  • context.todo() 用于创建一个默认的、无限期的上下文,但它会提示程序员在使用这个上下文之前应该使用其他上下文。这个函数通常用于在编写代码时提醒程序员应该传入正确的上下文,而不是使用 context.todo()

因此,在正式环境中,应该尽量避免使用 context.todo(),并且应该尽可能使用有效的上下文来替代它。

并在随后调用 select-case 进行 context.Done 方法的监听,最后由于达到截止时间。因此逻辑上 select 走到了 context.Errcase 分支,最终输出 context deadline exceeded

除了上述所描述的方法外,标准库 context 还支持下述方法:

  • WithCancel : 基于父级 context, 创建一个可以取消的新 context.
  • WithDeadline : 基于父级 context,创建一个具有截止时间(Deadline) # context,
  • WithTimeout : 基于父级 context,创建一个具有超时时间(Timeout)的新 context.
  • Background: 创建一个空的 context,一般常用于作为根的父级 context
  • TODO : 创通一个空的 context,一般用于未确定时的声明使用。
  • WithValue : 基于某个context创建并存储对应的上下文信息。

context 本质

我们在基本特性中介绍了不少 context 的方法,其基本大同小异。看上去似乎不难,接下来我们看看其底层的基本原理和设计。

func WithXXXX(parent Context, xxx xxx) (Context, CancelFunc);

其返回值分别是 Context 和 CancelFunc,接下来我们将进行分析这两者的作用。

接口

1. Context 接口:

type Context interface { 
    Deadline() (deadline time.Time, ok bool) 
    Done() <-chan struct{} 
    Err() error 
    Value(key interface{}) interface{} 
} 
  • Deadline:获取当前 context 的截止时间。
  • Done:获取一个只读的 channel,类型为结构体。可用于识别当前 channel 是否已经被关闭,其原因可能是到期,也可能是被取消了。
  • Err:获取当前 context 被关闭的原因。
  • Value:获取当前 context 对应所存储的上下文信息。

2. Canceler 接口:

type canceler interface { 
 cancel(removeFromParent bool, err error) 
 Done() <-chan struct{} 
}  
  • cancel:调用当前 context 的取消方法。

  • Done:与前面一致,可用于识别当前 channel 是否已经被关闭。

基础结构

在标准库 context 的设计上,一共提供了四类 context 类型来实现上述接口。分别是 emptyCtxcancelCtxtimerCtx 以及 valueCtx

image.png

emptyCtx

在日常使用中,常常使用到的 context.Background 方法,又或是 context.TODO 方法。

cancelCtx

在调用 context.WithCancel 方法时,我们会涉及到 cancelCtx 类型,其主要特性是取消事件。

本质上是在父节点中创建一个context,然后将context(包括自身所创建的ctx)不断向下带过程。然后在某个环节中context给取消掉。进而会影响到后边的链路调用。

timerCtx

在调用 context.WithTimeout 方法时,我们会涉及到 timerCtx 类型,其主要特性是 TimeoutDeadline 事件。

接下来将会正式生成成为一个 timeCtx 类型,并将其加入到父级 contextchildren 属性中。最后进行当前时间与 Deadline 时间的计算,并通过调用 time.AfterFunc 在到期后自动调用 cancel 方法发起取消事件,自然也就会触发父子级的事件传播。

valueCtx

在调用 context.WithValue 方法时,我们会涉及到 valueCtx 类型,其主要特性是涉及上下文信息传递。

这时候你可能又有疑问了,那多个父子级 context 是如何实现跨 context 的上下文信息获取的?

这秘密其实在上面的 valueCtx 和 Value 方法中有所表现:

image

本质上 valueCtx 类型是一个单向链表,会在调用 Value 方法时先查询自己的节点是否有该值。若无,则会通过自身存储的上层父级节点的信息一层层向上寻找对应的值,直到找到为止。

而在实际的工程应用中,你会发现各大框架,例如:gin、grpc 等。他都是有自己再实现一套上下文信息的传输的二次封装,本意也是为了更好的管理和观察上下文信息。

context 取消事件

我们进一步提出一个疑问点,context 是如何实现跨 goroutine 的取消事件并传播开来的,是如何实现的?

这个问题的答案就在于 WithCancelWithDeadline 都会涉及到 propagateCancel 方法,其作用是构建父子级的上下文的关联关系,若出现取消事件时,就会进行处理:

  • 当父级上下文(parent)的 Done 结果为 nil 时,将会直接返回,因为其不会具备取消事件的基本条件,可能该 contextBackgroundTODO 等方法产生的空白 context

  • 当父级上下文(parent)的 Done 结果不为 nil 时,则发现父级上下文已经被取消,作为其子级,该 context 将会触发取消事件并返回父级上下文的取消原因。

其核心是构建了树形结构

  • 调用 parentCancelCtx 方法找到具备取消功能的父级 context。并将当前 context,也就是 child 加入到 父级 contextchildren 列表中,等待后续父级 context 的取消事件通知和响应。

  • 调用 parentCancelCtx 方法没有找到,将会启动一个新的 goroutine 去监听父子 context 的取消事件通知。

image

也就是说,当发现其能够接受cancel事件,那就将其挂在可执行cancel节点下面。

也就是其在 children 属性的 map\[canceler]struct{} 存储结构上就已经支持了子级关系的查找,也就自然可以进行取消事件传播了。

而具体的取消事件的实际行为,则是在前面提到的 propagateCancel 方法中,会在执行例如cacenl 方法时,会对父子级上下文分别进行状态判断,若满足则进行取消事件,并传播给子级同步取消。

常见场景

RPC调用

在主goroutine上有4个RPC,RPC2/3/4是并行请求的,我们这里希望在RPC2请求失败之后,直接返回错误,并且让RPC3/4停止继续计算。

PipeLine

pipeline模式就是流水线模型,流水线上的几个工人,有n个产品,一个一个产品进行组装。其实pipeline模型的实现和Context并无关系,没有context我们也能用chan实现pipeline模型。但是对于整条流水线的控制,则是需要使用上Context的。这篇文章Pipeline Patterns in Go例子是非常好的说明。这里就大致对这个代码进行下说明。

超时请求

我们发送RPC请求的时候,往往希望对这个请求进行一个超时的限制。当一个RPC请求超过10s的请求,自动断开。

HTTP服务器的request互相传递数据

context还提供了valueCtx的数据结构。

这个valueCtx最经常使用的场景就是在一个http服务器中,在request中传递一个特定值,比如有一个中间件,做cookie验证,然后把验证后的用户名存放在request中。

context的使用

创建context

context包主要提供了两种方式创建context:

  • context.Backgroud()

  • context.TODO()

这两个函数其实只是互为别名,没有差别,官方给的定义是:

  • context.Background 是上下文的默认值,所有其他的上下文都应该从它衍生(Derived)出来。

  • context.TODO 应该只在不确定应该使用哪种上下文时使用;

所以在大多数情况下,我们都使用context.Background作为起始的上下文向下传递。

上面的两种方式是创建根context,不具备任何功能,具体实践还是要依靠context包提供的With系列函数来进行派生:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context

这四个函数都要基于父Context衍生,通过这些函数,就创建了一颗Context树,树的每个节点都可以有任意多个子节点,节点层级可以有任意多个,画个图表示一下:

image

基于一个父Context可以随意衍生,其实这就是一个Context树,树的每个节点都可以有任意多个子节点,节点层级可以有任意多个,每个子节点都依赖于其父节点,例如上图,我们可以基于Context.Background衍生出四个子context:ctx1.0-cancel、ctx2.0-deadline、ctx3.0-timeout、ctx4.0-withvalue,这四个子context还可以作为父context继续向下衍生,即使其中ctx1.0-cancel 节点取消了,也不影响其他三个父节点分支。

创建context方法和context的衍生方法就这些,下面我们就一个一个来看一下他们如何被使用。

WithValue携带数据

我们日常在业务开发中都希望能有一个trace_id能串联所有的日志,这就需要我们打印日志时能够获取到这个trace_id,在python中我们可以用gevent.local来传递,在java中我们可以用ThreadLocal来传递,在Go语言中我们就可以使用Context来传递,通过使用WithValue来创建一个携带trace_idcontext,然后不断透传下去,打印日志时输出即可,来看使用例子:

const (
   KEY = "trace_id"
)

func NewRequestID() string {
   return strings.Replace(uuid.New().String(), "-", "", -1)
}
func NewContextWithTraceID() context.Context {
   ctx := context.WithValue(context.Background(), KEY, NewRequestID())
   return ctx
}

func PrintLog(ctx context.Context, message string) {
   fmt.Printf("%s|info|trace_id=%s|%s", time.Now().Format("2020-01-01 15:04:05"), GetContextValue(ctx, KEY), message)
}

func GetContextValue(ctx context.Context, k string) string {
   v, ok := ctx.Value(k).(string)
   if !ok {
      return ""
   }
   return v
}

func ProcessEnter(ctx context.Context) {
   PrintLog(ctx, "Golang")
}

func main() {
   ProcessEnter(NewContextWithTraceID())
}

//输出结果为 21210-10-10 08:53:16|info|trace_id=6d200bb99efc4e2698d19d74992b507f|Golang

基于context.Background创建一个携带trace_idctx,然后通过context树一起传递,从中派生的任何context都会获取此值,我们最后打印日志的时候就可以从ctx中取值输出到日志中。

目前一些RPC框架都是支持了Context,所以trace_id的向下传递就更方便了。

在使用withVaule时要注意四个事项:

  • 不建议使用context值传递关键参数,关键参数应该显示的声明出来,不应该隐式处理,context中最好是携带签名、trace_id这类值。

  • 因为携带value也是keyvalue的形式,为了避免context因多个包同时使用context而带来冲突,key建议采用内置类型。

  • 上面的例子我们获取trace_id是直接从当前ctx获取的,实际我们也可以获取父context中的value,在获取键值对是,我们先从当前context中查找,没有找到会在从父context中查找该键对应的值直到在某个父context中返回 nil 或者查找到对应的值。

  • context传递的数据中keyvalue都是interface类型,这种类型编译期无法确定类型,所以不是很安全,所以在类型断言时别忘了保证程序的健壮性。

超时控制

通常健壮的程序都是要设置超时时间的,避免因为服务端长时间响应消耗资源,所以一些web框架或rpc框架都会采用withTimeout或者withDeadline来做超时控制,当一次请求到达我们设置的超时时间,就会及时取消,不在往下执行。

withTimeoutwithDeadline作用是一样的,就是传递的时间参数不同而已,他们都会通过传入的时间来自动取消Context,这里要注意的是他们都会返回一个cancelFunc方法,通过调用这个方法可以达到提前进行取消,不过在使用的过程还是建议在自动取消后也调用cancelFunc去停止定时减少不必要的资源浪费。

withTimeoutWithDeadline不同在于WithTimeout将持续时间作为参数输入而不是时间对象,这两个方法使用哪个都是一样的,看业务场景和个人习惯,因为本质withTimout内部也是调用的WithDeadline

func main() {
   HttpHandler()
}

func NewContextWithTimeout() (context.Context, context.CancelFunc) {
   return context.WithTimeout(context.Background(), 3*time.Second)
}

func HttpHandler() {
   ctx, cancel := NewContextWithTimeout()
   defer cancel()
   deal(ctx)
}

func deal(ctx context.Context) {
   for i := 0; i < 10; i++ {
      time.Sleep(1 * time.Second)
      select {
      case <-ctx.Done():
         fmt.Println(ctx.Err())
         return
      default:
         fmt.Printf("deal time is %d\n", i)
      }
   }
}

//deal time is 0
//deal time is 1
//context deadline exceeded

使用起来还是比较容易的,既可以超时自动取消,又可以手动控制取消。这里大家要记的一个坑,就是我们往从请求入口透传的调用链路中的context是携带超时时间的,如果我们想在其中单独开一个goroutine去处理其他的事情并且不会随着请求结束后而被取消的话,那么传递的context要基于context.Background或者context.TODO重新衍生一个传递,否决就会和预期不符合了。

withCancel取消控制

日常业务开发中我们往往为了完成一个复杂的需求会开多个gouroutine去做一些事情,这就导致我们会在一次请求中开了多个goroutine却无法控制他们,这时我们就可以使用withCancel来衍生一个context传递到不同的goroutine中,当我想让这些goroutine停止运行,就可以调用cancel来进行取消。(协程间同步控制)

func main()  {
    ctx,cancel := context.WithCancel(context.Background())
    go Speak(ctx)
    time.Sleep(10*time.Second)
    cancel()
    time.Sleep(1*time.Second)
}

func Speak(ctx context.Context)  {
    for range time.Tick(time.Second){
        select {
        case <- ctx.Done():
            fmt.Println("我要闭嘴了")
            returndefault:
            fmt.Println("balabalabalabala")
        }
    }
}

context的优缺点

context包被设计出来就是做并发控制的,这个包有利有弊。

缺点

  • 影响代码美观,现在基本所有web框架、RPC框架都是实现了context,这就导致我们的代码中每一个函数的一个参数都是context,即使不用也要带着这个参数透传下去,个人觉得有点丑陋。【传递方式】

  • context可以携带值,但是没有任何限制,类型和大小都没有限制,也就是没有任何约束,这样很容易导致滥用,程序的健壮很难保证;还有一个问题就是通过context携带值不如显式传值舒服,可读性变差了。 【携带值过于自由】

  • 可以自定义context,这样风险不可控,更加会导致滥用。

  • context取消和自动取消的错误返回不够友好,无法自定义错误,出现难以排查的问题时不好排查。

  • 创建衍生节点实际是创建一个个链表节点,其时间复杂度为O(n),节点多了会掉支效率变低。

优点

  • 使用context可以更好的做并发控制,能更好的管理goroutine滥用。
  • context的携带者功能没有任何限制,这样我我们传递任何的数据,可以说这是一把双刃剑
  • 网上都说context包解决了goroutinecancelation问题,你觉得呢?

相关连接: