go Context 指北

在上一篇文章中,讲了很多跟 Context 相关的东西,我们也知道了 go 里面 Context 的一些比较常见的用法、使用场景,比如超时控制、变量共享等,

但是对于 go Context 本身还没有太多的讲解,可能看起来会有点费解,今天就来详细说说 Context 的设计以及其用法。

context.Context 模型

在开始之前,我们先来看看这张图,这张图涵盖了所有创建 context.Context 的方法:

首先,是最上层的 context.Background()context.TODO(),看过源码的同学应该知道,这两个方法返回的 Context 是一样的,都是 new(emptyCtx)

而这个 emptyCtx 其实是没有任何实际功能的,但是他们又是最重要的,因为创建 Context 只有这两个方法,其他的几个方法都是从这里创建的 Context 派生的。

我们一般会使用 context.Background() 来创建一个最顶级的 Context,比如,go 的 http 服务器中 request.Context() 方法的那个 Context 就是通过 context.Background() 创建的。

context.TODO() 往往用在需要 Context 的地方,但是我们还没确定使用一个什么样的 Context 的时候。

其次,中间的 context.Context 表示通过 context.Background() 或者 context.TODO() 方法创建的 Context

这个 Context 往往就是一个请求中的根 Context,所有子协程里面的 Context 都是从这个 Context 派生的,又或者是直接使用了这个 Context

然后,从父 Context 创建新的 Context 的几个方法需要详细说一下:

  • WithCancel: 这个方法返回一个新的 Context,同时返回一个 CancelFunc,通过调用 CancelFunc,我们可以在子协程中的 context.Done() 方法接收到取消的信号,从而作出相应的操作(比如清理、中止执行等)。
  • WithDeadline: 这个方法也会返回一个新的 Context,同时也返回了一个 CancelFunc,本质上来说,这两个返回值跟 WithCancel 的两个返回值并无二致。我们通过 WithDeadline 返回的 CancelFunc 也是可以给子协程发送取消信号的。但是通过 WithDeadline 创建的 Context,会有一个定时器在运行,到了指定时间如果我们的子协程依然没有结束,同样也会收到取消的信号,这个定时器的作用就是在指定时间后执行 CancelFunc
  • WithTimeout: 这个其实跟 WithDeadline 是一样的,只是参数上有点不一样,最终效果都是在一定时间后发送取消信号。
  • WithValue: 这个方法只是返回一个带有我们传递变量的新的 Context,没有其他什么特别的功能了。

所以,除了基础的 context.Background()context.TODO(),对于怎么基于这两个基础的 Context 创建新的 Context,可以简单总结如下:

  • 如果我们只是想有一个机制可以取消子协程的执行,可以使用 WithCancel,拿到 CancelFunc 之后,在我们需要的时候调用 CancelFunc 就可以给子 Context 传递取消信号。
  • 如果我们想对子协程进行超时控制,可以使用 WithDeadline 或者 WithTimeout,这两个方法的本质上都是启动一个定时器,在到达一定时间后,会给子协程发送取消信号。但是除了定时器,它们还返回了一个 CancelFunc,这意味着我们在到达定时器指定的时间之前,也可以手动调用 CancelFunc 来发送取消信号。
  • 如果我们只是想给子协程传递一些数据,从而实现变量共享的话,可以使用 WithValue

实际使用中的 Context

我们再来看一张图,上面的描述可能会比较抽象,下面这个图展示了实际使用中的 Context

根结点的 Context 只有两种创建方式 context.Background() 或者 context.TODO(),在我们做一些 io 操作的时候,比如 rpc 调用,数据库查询等,

我们会需要做一些超时控制,这个时候我们就会需要新建一个有超时控制功能的 Context(使用 context.WithDeadline 或者 context.WithTimeout),

假设是上图的 child 2,然后 child 2 这个 Context 所在的 goroutine 里面也需要做一些 io 操作,然后也需要限制这些操作的超时时间,

然后在 child 2 的基础上再通过 context.WithTimeout 创建了一个新的 Context,假设是 child 2-2

需要注意的是,这里每一级都是一个新的 Context 实例,而不是在原有 Context 上增加或者修改其属性。

假设 child 2Deadline 到了,这个时候 child 2 的定时器会调用 CancelFunc 来给子 Context 发送取消信号。
child 2-2 里的 select 语句的 context.Done() 得以返回,从而开始执行清理操作,然后中止协程的执行。

在这个过程中,取消信号的传播是从上往下一级级有序传递的,每一级的 Context 会给那些从其派生的 Context 传传递取消信号,直到叶子结点。

需要注意的是,虽然信号传播是从上往下的,但是不代表子协程需要等待父协程的 context.Done() 里面的逻辑执行完再执行,因为我们之前也说过,

在 go 里面,协程是平等的,父子协程的执行是同时进行的。

我们可以看看下面的例子,有点啰嗦,大概看一下就好:

主要是想通过这个例子说明,在调用 CancelFunc 的时候,所有子孙 Context 都能接收到这个信号(当然它的父 Context 不会收到)。 这也跟我们实际的应用场景一致。

go 复制代码
package main

import (
	"context"
	"fmt"
	"sync"
	"time"
)

func main() {
	var wg sync.WaitGroup
	wg.Add(4)

	ctx, cancel := context.WithCancel(context.Background())

	go func(c context.Context) {
		ctx1, _ := context.WithCancel(c)

		go func(c1 context.Context) {
			ctx2, _ := context.WithCancel(c1)

			go func(c2 context.Context) {
				select {
				case <-c2.Done():
					fmt.Println("ctx2 done.")
					wg.Done()
				}
			}(ctx2)

			select {
			case <-c1.Done():
				fmt.Println("ctx1 done.")
				wg.Done()
			}
		}(ctx1)

		select {
		case <-c.Done():
			fmt.Println("ctx1 done.")
			wg.Done()
		}
	}(ctx)

	// main ctx
	go func() {
		time.Sleep(time.Second)
		// 父协程通过调用 CancelFunc 发送了取消信号
		cancel()
		wg.Done()
	}()

	wg.Wait()
	
	// 输出:
	// ctx2 done.
	// ctx1 done. 
	// ctx1 done.
}

整个过程大概如下图:

实际使用中的 goroutine

在实际的场景中,goroutine 类似 Context,也是树状的结构,每一个协程都可以启动新的协程,同样子协程也可以启动新的协程,最终会如下图这样:

同样的,而在父协程里面通过 Context 发送取消信号的时候,所有子孙协程都能感知得到,所以虽然看起来这棵树可能变得有点庞大,但是也不是完全不可控的。

go 中监控协程的一个工具

我们现在直到了,go 的协程里面可以启动新的协程,最终可能会有非常多的协程,但是到底有多少呢?

对于这个问题,go 官方的标准库已经给我们提供了一个工具 net/http/pprof,具体使用方式如下:

go 复制代码
package main

import (
	"net/http"
	_ "net/http/pprof"
	"time"
)

func main() {
	// 启动之后,在 localhost:6060 可以看到当前进程的一些指标,比如当前的协程有多少个
	go func() {
		http.ListenAndServe("localhost:6060", nil)
	}()

	ch := make(chan struct{}, 1)

	go func() {
		time.Sleep(time.Second * 120)
		ch <- struct{}{}
	}()

	<-ch
}

通过 pprof 我们可以知道应用的健康状况,如协程数量等,这不是本文重点,不赘述了。

总结

本文主要讲述了如下内容:

  • 我们先是讲解了创建Context 的几种方式,其中,根 Context 只有两种创建方式,分别是 context.Background()context.TODO(),其他种类的 Context 可以通过 context.WithXXX() 创建。
  • 在 go 里面,如果我们只是想要取消一个协程,那么我们可以通过 WithCancel 来实现,如果要进行超时控制,可以使用 WithTimeoutWithDeadline
  • Context 是一个树状结构,每一个 Context 都可以作为父 Context 创建新的 Context,然后在调用 CancelFunc 或者超时的时候,会由父到子传递取消的信号。
  • Context 也可以用来传递参数,比如我们可以通过 WithValue 来传递参数,然后在子协程里面通过 Value 来获取参数。
  • 最后,我们讲解了如何通过 pprof 来监控协程的数量。
相关推荐
励志成为嵌入式工程师34 分钟前
c语言简单编程练习9
c语言·开发语言·算法·vim
捕鲸叉1 小时前
创建线程时传递参数给线程
开发语言·c++·算法
A charmer1 小时前
【C++】vector 类深度解析:探索动态数组的奥秘
开发语言·c++·算法
码农小旋风1 小时前
详解K8S--声明式API
后端
Peter_chq1 小时前
【操作系统】基于环形队列的生产消费模型
linux·c语言·开发语言·c++·后端
Yaml42 小时前
Spring Boot 与 Vue 共筑二手书籍交易卓越平台
java·spring boot·后端·mysql·spring·vue·二手书籍
小小小妮子~2 小时前
Spring Boot详解:从入门到精通
java·spring boot·后端
hong1616882 小时前
Spring Boot中实现多数据源连接和切换的方案
java·spring boot·后端
记录成长java3 小时前
ServletContext,Cookie,HttpSession的使用
java·开发语言·servlet
前端青山3 小时前
Node.js-增强 API 安全性和性能优化
开发语言·前端·javascript·性能优化·前端框架·node.js