前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Go进阶(3):上下文context

Go进阶(3):上下文context

作者头像
黄规速
发布2023-02-27 16:21:36
5970
发布2023-02-27 16:21:36
举报

一、背景


在 Go http包的Server中,每一个请求在都有一个对应的 goroutine去处理。请求处理函数通常会启动额外的goroutine用来访问后端服务,比如数据库和RPC服务。一个上游服务通常需要访问多个下游服务,比如终端用户的身份认证信息、验证相关的token、请求的截止时间。 当一个请求被取消或超时时,所有用来处理该请求的 goroutine 都应该迅速退出,然后系统才能释放这些 goroutine 占用的资源。

传统方案一:使用sync.WaitGroup

问题:只有所有的goroutine都结束了才算结束,只要有一个goroutine没有结束, 那么就会一直等,这显然对资源的释放是缓慢的

代码语言:javascript
复制
var wg sync.WaitGroup

func run(task string) {
    fmt.Println(task, "start。。。")
    time.Sleep(time.Second * 2)
    // 每个goroutine运行完毕后就释放等待组的计数器
    wg.Done()
}

func main() {
    wg.Add(2)			// 需要开启几个goroutine就给等待组的计数器赋值为多少,这里为2
    for i := 1; i < 3; i++ {
        taskName := "task" + strconv.Itoa(i)
				go run(taskName)
    }
    // 等待,等待所有的任务都释放 等待组计数器值为 0
    wg.Wait()
    fmt.Println("所有任务结束。。。")
}

/*
  -----------------------运行结果----------------------------
				task2 start。。。
				task1 start。。。
				所有任务结束。。。
*/

上面例子中:

一个任务结束了必须等待另外一个任务也结束了才算全部结束了,先完成的必须等待其他未完成的,所有的goroutine都要全部完成才OK。 等待组比较适用于好多个goroutine协同做一件事情的时候,因为每个goroutine做的都是这件事情的一部分,只有全部的goroutine都完成,这件事情才算完成; 缺点: 实际生产中,需要我们主动的通知某一个goroutine结束。wg我们可以设置全局变量,在我们需要通知goroutine要停止的时候,我们为全局变量赋值,但是这样我们必须保证线程安全,不可避免的我们要为全局变量加锁,在便利性及性能上稍显不足

传统方案二:使用Channel+select

通过在main goroutine中像chan中发送关闭停止指令,并配合select,从而达到关闭goroutine的目的,这种方式显然比等待组优雅的多,但是在goroutine中在嵌套goroutine的情况就变得异常复杂。

代码语言:javascript
复制
func main() {
    stop := make(chan bool)
    // 开启goroutine
    go func() {
        for {
            select {
            case <- stop:
                fmt.Println("任务1 结束了。。。")
                return 
            default:
                fmt.Println(" 任务1 正在运行中。")
                time.Sleep(time.Second * 2)
            }
        }
    }()
    
    // 运行10s后停止
    time.Sleep(time.Second * 10)
    fmt.Println("需要停止任务1。。。")
  	stop <- true
    time.Sleep(time.Second * 1)
}

/*
		------------------执行结果---------------------------------
				任务1 正在运行中...
				任务1 正在运行中...
				任务1 正在运行中...
				任务1 正在运行中...
				任务1 正在运行中...
				任务1 正在运行中...
				需要停止任务1...
				任务1 结束了...
*/

 这个方案缺点:

如果有很多 goroutine 都需要控制结束和如果这些 goroutine 又衍生了其它更多的goroutine比较麻烦。

二、go上下文context


Context 在 Go1.7 之后就加入到了Go语言标准库中,准确说它是 Goroutine 的上下文,包含 Goroutine 的运行状态、环境、现场等信息。

1、什么是 Context

Context 也叫作“上下文”,是一个比较抽象的概念,一般理解为程序单元的一个运行状态、现场、快照。其中上下是指存在上下层的传递,上会把内容传递给下,程序单元则指的是 Goroutine。 每个 Goroutine 在执行之前,都要先知道程序当前的执行状态,通常将这些执行状态封装在一个 Context 变量中,传递给要执行的 Goroutine 中。

当一个goroutine在衍生一个goroutine时,context可以跟踪到子goroutine,从而达到控制他们的目的; 在网络编程下,当接收到一个网络请求 Request,在处理 Request 时,我们可能需要开启不同的 Goroutine 来获取数据与逻辑处理,即一个请求 Request,会在多个 Goroutine 中处理。而这些 Goroutine 可能需要共享 Request 的一些信息,同时当 Request 被取消或者超时的时候,所有从这个 Request 创建的所有 Goroutine 也应该被结束。

随着 Context 包的引入,标准库中很多接口因此加上了 Context 参数,例如 database/sql 包,Context 几乎成为了并发控制和超时控制的标准做法。

Context接口定义了四个需要实现的方法,其中包括:

  1. Deadline — 返回 context.Context 被取消的时间,也就是完成工作的截止日期;
  2. Done — 返回一个 Channel,这个 Channel 会在当前工作完成或者上下文被取消后关闭,多次调用 Done 方法会返回同一个 Channel;
  3. Err — 返回 context.Context 结束的原因,它只会在 Done 方法对应的 Channel 关闭时返回非空的值;
    1. 如果 context.Context 被取消,会返回 Canceled 错误;
    2. 如果 context.Context 超时,会返回 DeadlineExceeded 错误;
  4. Value — 从 context.Context 中获取键对应的值,对于同一个上下文来说,多次调用 Value 并传入相同的 Key 会返回相同的结果,该方法可以用来传递请求特定的数据;
代码语言:javascript
复制
type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key interface{}) interface{}
}

context 包中提供的 context.Backgroundcontext.TODOcontext.WithDeadlinecontext.WithValue 函数会返回实现该接口的私有结构体,我们会在后面详细介绍它们的工作原理。 

2、设计原理

       在 Goroutine 构成的树形结构中对信号进行同步以减少计算资源的浪费是 context.Context 的最大作用。Go 服务的每一个请求都是通过单独的 Goroutine 处理的2,HTTP/RPC 请求的处理器会启动新的 Goroutine 访问数据库和其他服务。

       如下图所示,我们可能会创建多个 Goroutine 来处理一次请求,而 context.Context 的作用是在不同 Goroutine 之间同步请求特定数据、取消信号以及处理请求的截止日期。

Context 与 Goroutine 树

        每一个 context.Context 都会从最顶层的 Goroutine 一层一层传递到最下层。context.Context 可以在上层 Goroutine 执行出现错误时,将信号及时同步给下层。

不使用 Context 同步信号:当最上层的 Goroutine 因为某些原因执行失败时,下层的 Goroutine 由于没有接收到这个信号所以会继续工作;

使用 Context 同步信号:但是当我们正确地使用 context.Context 时,就可以在下层及时停掉无用的工作以减少额外资源的消耗:

3、例子

 我们可以通过一个代码片段了解 context.Context 是如何对信号进行同步的。在这段代码中,我们创建了一个过期时间为 1s 的上下文,并向上下文传入 handle 函数,该方法会使用 500ms 的时间处理传入的请求:

代码语言:javascript
复制
func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
	defer cancel()

	go handle(ctx, 500*time.Millisecond)
	select {
	case <-ctx.Done():
		fmt.Println("main", ctx.Err())
	}
}

func handle(ctx context.Context, duration time.Duration) {
	select {
	case <-ctx.Done():
		fmt.Println("handle", ctx.Err())
	case <-time.After(duration):
		fmt.Println("process request with", duration)
	}
}

因为过期时间大于处理时间,所以我们有足够的时间处理该请求,运行上述代码会打印出下面的内容:

代码语言:javascript
复制
$ go run context.go
process request with 500ms
main context deadline exceeded

        handle 函数没有进入超时的 select 分支,但是 main 函数的 select 却会等待 context.Context 超时并打印出 main context deadline exceeded。

如果我们将处理请求时间增加至 1500ms,整个程序都会因为上下文的过期而被中止,:

代码语言:javascript
复制
 go run context.go
main context deadline exceeded
handle context deadline exceeded

相信这两个例子能够帮助各位读者理解 context.Context 的使用方法和设计原理 — 多个 Goroutine 同时订阅 ctx.Done() 管道中的消息,一旦接收到取消信号就立刻停止当前正在执行的工作。

使用 Context 的注意事项:

  • 不要把 Context 放在结构体中,要以参数的方式显示传递;
  • 以 Context 作为参数的函数方法,应该把 Context 作为第一个参数;
  • 给一个函数方法传递 Context 的时候,不要传递 nil,如果不知道传递什么,就使用 context.TODO;
  • Context 的 Value 相关方法应该传递请求域的必要数据,不应该用于传递可选参数;
  • Context 是线程安全的,可以放心的在多个 Goroutine 中传递。

三、context基础结构


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

1、emptyCtx (默认上下文)

context包中最常用的方法还是 context.Background、context.TODO,这两个方法分别返回一个实现了 Context 接口的 background 和 todo,都会返回预先初始化好的私有变量 background 和 todo,它们会在同一个 Go 程序中被复用:

代码语言:javascript
复制
var ( 
 background = new(emptyCtx) 
 todo       = new(emptyCtx) 
) 
 
func Background() Context { 
 return background 
} 
 
func TODO() Context { 
 return todo 
} 

Background() :主要用于 main 函数、初始化以及测试代码中,作为 Context 这个树结构的最顶层的 Context,也就是根 Context。 TODO():它目前还不知道具体的使用场景,在不知道该使用什么 Context 的时候,可以使用这个。 background 和 todo 本质上都是 emptyCtx 结构体类型的基本封装,是一个不可取消,没有设置截止时间,没有携带任何值的 Context。

这两个私有变量都是通过 new(emptyCtx) 语句初始化的,它们是指向私有结构体 context.emptyCtx 的指针,这是最简单、最常用的上下文类型。 而 emptyCtx 类型本质上是实现了 Context 接口:

代码语言:javascript
复制
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 的实现非常简单,因为他是空 context 的定义,因此没有 deadline,更没有 timeout,可以认为就是一个基础空白 context 模板。

从源代码来看,context.Background 和 context.TODO 也只是互为别名,没有太大的差别,只是在使用和语义上稍有不同:

  •  context.Background是上下文的默认值,所有其他的上下文都应该从它衍生出来;
  • context.TODO 应该仅在不确定应该使用哪种上下文时使用;

在多数情况下,如果当前函数没有上下文作为入参,我们都会使用 context.Background 作为起始的上下文向下传递。

2、 cancelCtx 类型:取消信号

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

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

一旦我们执行返回的取消函数,当前上下文以及它的子上下文都会被取消,所有的 Goroutine 都会同步收到这一取消信号。

Context 子树的取消

首先 main goroutine 创建并传递了一个新的 context 给 goroutine a,此时 goroutine a 的 context 是 main goroutine context 的子集:

传递过程中,goroutine a 再将其 context 一个个传递给了 goroutine c、d、e。最后在运行时 goroutine c 调用了 cancel 方法。使得该 context 以及其对应的子集均接受到取消信号,对应的 goroutine 也进行了响应。

 cancelCtx 类型:

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

该结构体所包含的属性也比较简单,主要是 children 字段,其包含了该 context 对应的所有子集 context,便于在后续发生取消事件的时候进行逐一通知和关联。

context 是如何实现跨 goroutine 的取消事件并传播开来的,是如何实现的?

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

1)WithCancel 函数实现

我们直接从 context.WithCancel 函数的实现来看它到底做了什么:

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

context.WithCancel 基本逻辑如下:

  •    context.newCancelCtx将传入的上下文包装成私有结构体 context.cancelCtx;
  •    context.propagateCancel 会构建父子上下文之间的关联,当父上下文被取消时,子上下文也会被取消。

2)、接着我们propagateCancel的实现

代码语言:javascript
复制
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 {
		p.mu.Lock()
		if p.err != nil {
			child.cancel(false, p.err)
		} else {
			p.children[child] = struct{}{}
		}
		p.mu.Unlock()
	} else {
		go func() {
			select {
			case <-parent.Done():
				child.cancel(false, parent.Err())
			case <-child.Done():
			}
		}()
	}
}

context.propagateCancel 总共与父上下文相关的三种不同的情况:

  1. parent.Done() == nil,也就是 parent 不会触发取消事件时,当前函数会直接返回;
  2. child 的继承链包含可以取消的上下文时,会判断 parent 是否已经触发了取消信号;
    • 如果已经被取消,child 会立刻被取消;
    • 如果没有被取消,child 会被加入 parentchildren 列表中,等待 parent 释放取消信号;
  3. 当父上下文是开发者自定义的类型、实现了 context.Context 接口并在 Done() 方法中返回了非空的管道时;
    1. 运行一个新的 Goroutine 同时监听 parent.Done()child.Done() 两个 Channel;
    2. parent.Done() 关闭时调用 child.cancel 取消子上下文;

context.propagateCancel 的作用是在 parent 和 child 之间同步取消和结束的信号,保证在 parent 被取消时,child 也会收到对应的信号,不会出现状态不一致的情况。

3) context.cancelCtx.cancel方法实现:

context.cancelCtx 实现的几个接口方法也没有太多值得分析的地方,该结构体最重要的方法是 context.cancelCtx.cancel,该方法会关闭上下文中的 Channel 并向所有的子上下文同步取消信号:

代码语言:javascript
复制
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
	c.mu.Lock()
	if c.err != nil {
		c.mu.Unlock()
		return
	}
	c.err = err
	if c.done == nil {
		c.done = closedchan
	} else {
		close(c.done)
	}
	for child := range c.children {
		child.cancel(false, err)
	}
	c.children = nil
	c.mu.Unlock()

	if removeFromParent {
		removeChild(c.Context, c)
	}
}

4)context.WithDeadline 和 context.WithTimeout实现

除了 context.WithCancel 之外,context 包中的另外两个函数 context.WithDeadline 和 context.WithTimeout 也都能创建可以被取消的计时器上下文 context.timerCtx:

代码语言:javascript
复制
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
	return WithDeadline(parent, time.Now().Add(timeout))
}

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
	if cur, ok := parent.Deadline(); ok && cur.Before(d) {
		return WithCancel(parent)
	}
	c := &timerCtx{
		cancelCtx: newCancelCtx(parent),
		deadline:  d,
	}
	propagateCancel(parent, c)
	dur := time.Until(d)
	if dur <= 0 {
		c.cancel(true, DeadlineExceeded) // 已经过了截止日期
		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) }
}

       context.WithDeadline 在创建 context.timerCtx 的过程中判断了父上下文的截止日期与当前日期,并通过 time.AfterFunc 创建定时器,当时间超过了截止日期后会调用 context.timerCtx.cancel 同步取消信号。        context.timerCtx 内部不仅通过嵌入 context.cancelCtx 结构体继承了相关的变量和方法,还通过持有的定时器 timer 和截止时间 deadline 实现了定时取消的功能:

代码语言:javascript
复制
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) {
	c.cancelCtx.cancel(false, err)
	if removeFromParent {
		removeChild(c.cancelCtx.Context, c)
	}
	c.mu.Lock()
	if c.timer != nil {
		c.timer.Stop()
		c.timer = nil
	}
	c.mu.Unlock()
}

context.timerCtx.cancel 方法不仅调用了 context.cancelCtx.cancel,还会停止持有的定时器减少不必要的资源浪费。

3、valueCtx 类型:传值方法

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

context 包中的 context.WithValue 能从父上下文中创建一个子上下文,传值的子上下文使用 context.valueCtx 类型:

代码语言:javascript
复制
func WithValue(parent Context, key, val interface{}) Context {
	if key == nil {
		panic("nil key")
	}
	if !reflectlite.TypeOf(key).Comparable() {
		panic("key is not comparable")
	}
	return &valueCtx{parent, key, val}
}

 context.valueCtx结构体也非常的简单,核心就是键值对:

代码语言:javascript
复制
type valueCtx struct {
	Context
	key, val interface{}
}

context.valueCtx结构体会将除了 Value 之外的 Err、Deadline 等方法代理到父上下文中,它只会响应 context.valueCtx.Value 方法,该方法的实现也很简单:

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

context.valueCtx.Value 方法基本就是要求可比较,接着就是存储匹配:

如果 context.valueCtx 中存储的键值对与 context.valueCtx.Value 方法中传入的参数不匹配,就会从父上下文中查找该键对应的值直到某个父上下文中返回 nil 或者查找到对应的值。

那多个父子级 context 是如何实现跨 context 的上下文信息获取的?

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

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

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

四、总结


Go 语言中的 context.Context 的主要作用还是在多个 Goroutine 组成的树中同步取消信号以减少对资源的消耗和占用,虽然它也有传值的功能,但是这个功能我们还是很少用到。

在真正使用传值的功能时我们也应该非常谨慎,使用 context.Context 传递请求的所有参数一种非常差的设计,比较常见的使用场景是传递请求对应用户的认证令牌以及用于进行分布式追踪的请求 ID。

 参考:

1、Go 语言并发编程与 Context | Go 语言设计与实现

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2023-02-21,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

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

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、背景
    • 传统方案一:使用sync.WaitGroup
      • 传统方案二:使用Channel+select
      • 二、go上下文context
        • 1、什么是 Context
          • 2、设计原理
            • 3、例子
            • 三、context基础结构
              • 1、emptyCtx (默认上下文)
                • 2、 cancelCtx 类型:取消信号
                  • 1)WithCancel 函数实现
                  • 2)、接着我们propagateCancel的实现
                  • 3) context.cancelCtx.cancel方法实现:
                  • 4)context.WithDeadline 和 context.WithTimeout实现
                • 3、valueCtx 类型:传值方法
                • 四、总结
                领券
                问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档