前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >go context详解

go context详解

原创
作者头像
Johns
修改2022-06-30 10:24:06
1.9K0
修改2022-06-30 10:24:06
举报
文章被收录于专栏:代码工具

背景

在介绍context之前, 必须知道:

  • main 函数退出,所有协程都会退出
代码语言:go
复制
func main() {
   // 合起来写
   go func() {
      i := 0
      for {
         i++
         fmt.Printf("goroutine 1: i = %d\n", i)
         time.Sleep(time.Second)
      }
   }()
   time.Sleep(3 * time.Second)
   fmt.Println("Main exit")
}

输出:

代码语言:text
复制
goroutine 1: i = 1
goroutine 1: i = 2
goroutine 1: i = 3
Main exit

Process finished with exit code 0
  • 协程无父子关系,即在一个协程开启新的协程,该协程退出,不影响新的协程
代码语言:go
复制
func main() {
   // 合起来写
   go func() {
      go func() {
         i := 0
         for {
            fmt.Printf("goroutine 2: i = %d\n", i)
            time.Sleep(time.Second)
            i++
         }
      }()

      j := 0
      for {
         j++
         fmt.Printf("goroutine 1: i = %d\n", j)
         time.Sleep(500* time.Millisecond)

         // 跑个3次就退出循环
         if j == 3 {
            break
         }
      }

      fmt.Println("goroutine 1 exit")
   }()

   // 永远堵塞main
   select {}
   fmt.Println("Main exit")
}

输出:

代码语言:text
复制
goroutine 1: i = 1
goroutine 2: i = 0
goroutine 1: i = 2
goroutine 1: i = 3
goroutine 2: i = 1
goroutine 1 exit
goroutine 2: i = 2
goroutine 2: i = 3
goroutine 2: i = 4
...

在 Go 服务器中,每个传入的请求都在其自己的 goroutine 中处理。请求处理程序通常会启动额外的 goroutine 来访问数据库和 RPC 服务等后端。处理请求的一组 goroutine 通常需要访问特定于请求的值,例如最终用户的身份、授权令牌和请求的截止日期。当请求被取消或超时时,所有处理该请求的 goroutines 都应该快速退出,以便系统可以回收它们正在使用的任何资源。

于是, Google开发了一个context包,可以轻松地将请求范围的值、取消信号和截止日期跨 API 边界传递给处理请求所涉及的所有 goroutine。该软件包作为context公开可用 。

context使用

当我们的上一级goroutine停止时, 我们希望它下级的所有goroutine也能收到这个通知及时停止, 防止资源浪费.

代码语言:go
复制
func main() {
   go func() {
      ctx, cancel := context.WithCancel(context.Background())
      defer cancel()

      go func(ctx context.Context) {
         i := 0
         for {
            select {
            case <-ctx.Done():
               fmt.Printf("goroutine 2 exit")
               return
            default:
               fmt.Printf("goroutine 2: i = %d\n", i)
               time.Sleep(time.Second)
               i++
            }

         }
      }(ctx)


	    for j := 0; j <= 3; j++ {
	       fmt.Printf("goroutine 1: i = %d\n", j)
	       time.Sleep(500 * time.Millisecond)
	    }

      fmt.Println("goroutine 1 exit")
   }()

   // 永远堵塞main
   select {}
   fmt.Println("Main exit")
}

输出:

代码语言:text
复制
goroutine 1: i = 0
goroutine 2: i = 0
goroutine 1: i = 1
goroutine 2: i = 1
goroutine 1: i = 2
goroutine 1: i = 3
goroutine 1 exit
goroutine 2: i = 2
goroutine 2 exit

源码分析

前面我们介绍过go里面的Goroutine都是平等的, Goroutine之间不会有显示的父子关系, 如果我们想在父Goroutine里面取消子Goroutine的运行, 一般我们有2种方案:

  • 通过channel解决
  • 通过Goroutine构成的树形结构中对信号进行同步以减少计算资源的浪费, 也就是context方案
    image.png
    image.png

context也是借助channel实现的, 只不过context封装了一层树形关系, 同时帮我们自动处理向子Goroutine信号层层传递的工作, 而且这种信号传递在context是单向的, 即只能从上层goroutine往下层的goroutine传递(父goroutine往子goroutine传递)

Go context 使用嵌入类,以类似继承的方式组织几个 Context 类: emptyCtxvalueCtxcancelCtxtimerCtx

image.png
image.png

Context

我们先看一下Context这个接口的定义:

代码语言:go
复制
type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key interface{}) interface{}
}

emptyCtx

emptyCtx重命名了一个int类型, 并对Context接口进行了空实现.

context.Background()context.TODO() 返回的都是 emptyCtx 的实例。但其语义略有不同。前者作为 Context 树的根节点,后者通常在不知道用啥时用。

代码语言:go
复制
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
}

func (e *emptyCtx) String() string {
	switch e {
	case background:
		return "context.Background"
	case todo:
		return "context.TODO"
	}
	return "unknown empty Context"
}

// 预先初始化好的emptyCtx
var (
	background = new(emptyCtx)
	todo       = new(emptyCtx)
)

func Background() Context {
	return background
}

func TODO() Context {
	return todo
}

valueCtx

valueCtx 嵌入了一个 Context 接口以进行 Context 派生,并且附加了一个 KV 对。从 context.WithValue 函数可以看出,每附加一个键值对,都得套上一层新的 valueCtx。在使用 Value(key interface) 接口访问某 Key 时,会沿着 Context 树回溯链不断向上遍历所有 Context 直到 emptyCtx

  1. 如果遇到 valueCtx 实例,则比较其 key 和给定 key 是否相等
  2. 如果遇到其他 Context 实例,就直接向上转发。
代码语言:go
复制
type valueCtx struct {
	Context
	key, val interface{}
}

func (c *valueCtx) Value(key interface{}) interface{} {
	if c.key == key {
		return c.val
	}
	return c.Context.Value(key)
}

func WithValue(parent Context, key, val interface{}) Context {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	if key == nil {
		panic("nil key")
	}
	if !reflectlite.TypeOf(key).Comparable() {
		panic("key is not comparable")
	}
	return &valueCtx{parent, key, val}
}

cancelCtx

context 包中核心实现在 cancelCtx 中,包括构造树形结构、进行级联取消。

代码语言:go
复制
type cancelCtx struct {
	Context

	mu       sync.Mutex            // protects following fields
	done     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) Value(key interface{}) interface{} {
	// cancelCtx的value会返回自身, 这个地方主要是为了在构建树的时候能够快速找到最近的包含cancelCtx实现的节点
	if key == &cancelCtxKey {
		return c
	}
	return c.Context.Value(key)
}

func (c *cancelCtx) Done() <-chan struct{} {
	c.mu.Lock()
	if c.done == nil {
		c.done = make(chan struct{})
	}
	d := c.done
	c.mu.Unlock()
	return d
}

func (c *cancelCtx) Err() error {
	c.mu.Lock()
	err := c.err
	c.mu.Unlock()
	return err
}
回溯链

回溯链是各个 context 包在实现时利用 go 语言嵌入(embedding)的特性来构造的,主要用于:

  1. Value() 函数被调用时沿着回溯链向上查找匹配的键值对。
  2. 复用 Value() 的逻辑查找最近 cancelCtx 祖先,以构造 Context 树。 在 valueCtxcancelCtxtimerCtx 中只有 cancelCtx 直接valueCtxtimerCtx 都是通过嵌入实现,调用该方法会直接转发到 cancelCtx 或者 emptyCtx )实现了非空 Done() 方法,因此 done := parent.Done() 会返回第一个祖先 cancelCtx 中的 done channel。但如果 Context 树中有第三方实现的 Context 接口的实例时,parent.Done() 就有可能返回其他 channel。 因此,如果 p.done != done ,说明在回溯链中遇到的第一个实现非空 Done() Context 是第三方 Context ,而非 cancelCtx
代码语言:go
复制
// parentCancelCtx 返回 parent 的第一个祖先 cancelCtx 节点
func parentCancelCtx(parent Context) (*cancelCtx, bool) {
  done := parent.Done() // 调用回溯链中第一个实现了 Done() 的实例(第三方Context类/cancelCtx)
  if done == closedchan || done == nil {
    return nil, false
  }
  p, ok := parent.Value(&cancelCtxKey).(*cancelCtx) // 回溯链中第一个 cancelCtx 实例
  if !ok {
    return nil, false
  }
  p.mu.Lock()
  ok = p.done == done
  p.mu.Unlock()
  if !ok { // 说明回溯链中第一个实现 Done() 的实例不是 cancelCtx 的实例
    return nil, false
  }
  return p, true
}
树构建

Context 树的构建是在调用 context.WithCancel() 调用时通过 propagateCancel 进行的。

代码语言:go
复制
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
  c := newCancelCtx(parent)
  propagateCancel(parent, &c)
  return &c, func() { c.cancel(true, Canceled) }
}

Context 树,本质上可以细化为 canceler*cancelCtx*timerCtx)树,因为在级联取消时只需找到子树中所有的 canceler ,因此在实现时只需在树中保存所有 canceler 的关系即可(跳过 valueCtx),简单且高效。

代码语言:go
复制
// A canceler is a context type that can be canceled directly. The
// implementations are *cancelCtx and *timerCtx.
type canceler interface {
  cancel(removeFromParent bool, err error)
  Done() <-chan struct{}
}

具体实现为,沿着回溯链找到第一个实现了 Done() 方法的实例,

  1. 如果为 canceler 的实例,则其必有 children 字段,并且实现了 cancel 方法(canceler),将该 context 放进 children 数组即可。此后,父 cancelCtx 在 cancel 时会递归遍历所有 children,逐一 cancel。
  2. 如果为非 canceler 的第三方 Context 实例,则我们不知其内部实现,因此只能为每个新加的子 Context 启动一个守护 goroutine,当 父 Context 取消时,取消该 Context。 需要注意的是,由于 Context 可能会被多个 goroutine 并行访问,因此在更改类字段时,需要再一次检查父节点是否已经被取消,若父 Context 被取消,则立即取消子 Context 并退出。
代码语言:go
复制
func propagateCancel(parent Context, child canceler) {
  done := parent.Done()
  if done == nil {
    return // 父节点不可取消
  }

  select {
  case <-done:
    // 父节点已经取消
    child.cancel(false, parent.Err())
    return
  default:
  }

  if p, ok := parentCancelCtx(parent); ok { // 找到一个 cancelCtx 实例
    p.mu.Lock()
    if p.err != nil {
      // 父节点已经被取消
      child.cancel(false, p.err)
    } else {
      if p.children == nil {
        p.children = make(map[canceler]struct{}) // 惰式创建
      }
      p.children[child] = struct{}{}
    }
    p.mu.Unlock()
  } else {                                // 找到一个非 cancelCtx 实例
    atomic.AddInt32(&goroutines, +1)
    go func() {
      select {
      case <-parent.Done():
        child.cancel(false, parent.Err())
      case <-child.Done(): 
      }
    }()
  }
}
image.png
image.png
image.png
image.png
级联取消

下面是级联取消中的关键函数 cancelCtx.cancel 的实现。在本 cancelCtx 取消时,需要级联取消以该 cancelCtx 为根节点的 Context 树中的所有 Context,并将根 cancelCtx 从其从父节点中摘除,以让 GC 回收该 cancelCtx 子树所有节点的资源。

cancelCtx.cancel 是非导出函数,不能在 context 包外调用,因此持有 Context 的内层过程不能自己取消自己,须由返回的 CancelFunc (简单的包裹了 cancelCtx.cancel )来取消,其句柄一般为外层过程所持有。

代码语言:go
复制
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
  if err == nil { // 需要给定取消的理由,Canceled or DeadlineExceeded
    panic("context: internal error: missing cancel error")
  }
  
  c.mu.Lock()
  if c.err != nil {
    c.mu.Unlock()
    return // 已经被其他 goroutine 取消
  }
  
  // 记下错误,并关闭 done
  c.err = err
  if c.done == nil {
    c.done = closedchan
  } else {
    close(c.done)
  }
  
  // 级联取消
  for child := range c.children {
    // NOTE: 持有父 Context 的同时获取了子 Context 的锁
    child.cancel(false, err)
  }
  c.children = nil
  c.mu.Unlock()

  // 子树根需要摘除,子树中其他节点则不再需要
  if removeFromParent {
    removeChild(c.Context, c)
  }
}

timerCtx

timerCtx 在嵌入 cancelCtx 的基础上增加了一个计时器 timer,根据用户设置的时限到点取消。

代码语言:go
复制
type timerCtx struct {
  cancelCtx
  timer *time.Timer // Under cancelCtx.mu

  deadline time.Time
}

func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
  return c.deadline, true
}

func (c *timerCtx) cancel(removeFromParent bool, err error) {
  // 级联取消子树中所有 Context
  c.cancelCtx.cancel(false, err)
  
  if removeFromParent {
    // 单独调用以摘除此节点,因为是摘除 c,而非 c.cancelCtx
    removeChild(c.cancelCtx.Context, c)
  }
  
  // 关闭计时器
  c.mu.Lock()
  if c.timer != nil {
    c.timer.Stop()
    c.timer = nil
  }
  c.mu.Unlock()
}

设置超时取消是在 context.WithDeadline() 中完成的。如果祖先节点时限早于本节点,只需返回一个 cancelCtx 即可,因为祖先节点到点后在级联取消时会将其取消。

代码语言:go
复制
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), // 使用一个新的 cancelCtx 实现部分 cancel 功能
		deadline:  d,
	}
	propagateCancel(parent, c) // 构建 Context 取消树,注意传入的是 c 而非 c.cancelCtx
	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() {
			c.cancel(true, DeadlineExceeded)
		})
	}
	return c, func() { c.cancel(true, Canceled) }
}

go常用组件对context的依赖情况

组件名称

组件功能

是否需要显示传递context

主要作用

go-redis

go redis客户端

连接超时/异常取消

go-sql-driver

go mysql 驱动

连接超时/异常取消, 参数传递

log

go 原生log组件

-

logrus

go 结构化log组件

-

zap

go 标准的log组件

-

kafka-go

go kafka客户端组件

超时/异常取消, 参数传递

gin

go 服务框架

超时/异常取消, 参数传递

go-kit

go 服务框架

超时/异常取消, 参数传递

  • 常见的组件中或多或少使用context来实现跨goroutine的超时/异常取消, 参数传递功能
  • 相比在参数中传递用于控制流程的自定义管道变量, Context 可以更方便地串联、管理多个 Goroutine

总结

  • context通过集成chan使其拥有了跨goroutine通信的能力
  • context通过集成time.Timer和time.Time组件实现了在超时发送通知/记录截止时间的功能
  • context 通过持有key, val interface{}使用拥有了传递数据的能力
  • context的Context指针和child map分别记录了自己的上一级(父级, 有且只有一个)和下一级(子集, 一般会多个) 简单地来说, context 主要用来在 goroutine 之间传递上下文信息,包括:取消信号、超时时间、截止时间、k-v 等。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 背景
  • context使用
  • 源码分析
    • Context
      • emptyCtx
        • valueCtx
          • cancelCtx
            • timerCtx
            • go常用组件对context的依赖情况
            • 总结
            领券
            问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档