Logo
活死人の行知路

Go并发编程-Context


📅 | 📝 896 字
#go

是什么?

  • 上下文,并发控制技术。与WaitGroup并发控制技术不同,它对于派生 goroutine 有更强的控制力,可以控制多级 goroutine。
  • WaitGroup 控制数量确定的goroutine,context 控制的是数量不容易确定的衍生树状结构的 goroutine。

上下文 又是什么?

与当前场景有关的任何东西都可以认为是上下文。

结构

context 实际实际知识定义接口。

所以这也是“为什么说是安全传递数据”的原因。

4个核心API

  • context.WithValue: 安全传递数据。设置键值对,返回一个新的 context 实例,可以在不同的方法调用之间传递数据,类似传递个map,类似 Map 的结构,里面存放我们需要的任何数据,用的时候拿出来。
  • context.WithCancel
  • context.WithDeadline
  • context.WithTimeout: 上面三个用来链路控制。三者都返回一个可取消的 context 实例,和取消函数,这三者都是用于控制链路

注意:context实例是不可变的,每一次都是新创建的

context.WithValue: 安全传递数据。为什么不用 Map 呢?因为 Map 不是并发安全的。如果要并发安全可以使用 sync.Map 呀,可以但是一般不会这么做,解释原因前先举个下面的场景: 比如需要在请求的执行过程中传递一些数据,但这些数据和业务又没有关系,比如链路追踪的 trace id,就不会作为业务参数传递,就借助一个中介来帮忙传递,业务还是照常执行,只是旁边有个东西,我需要什么东西就从旁边的这个东西取出来(这就是 context 的诞生背景,也是使用场景)。

使用

Context

使用

  1. 通常作为方法或函数的第一个参数,如果仅仅是简单的函数则可以不需要Context,如果是和第三方打交道(比如数据库、Redis、下游RPC、HTTP等),则推荐引入Context,即便不需要也需要往下传。
  2. Context 有两个方法,context.TODO()和 context.Background()。
    • context.TODO():表示现在我还不知道 context 从哪里来,但是感觉将来会有一个
    • context.Background():如果确定我这个方法就是调用链路的起点,就使用 context.Background
    • 如果不知道自己是不是起点,也不知道谁会调用我,就推荐使用 TODO()
  • 中断/取消正在进行的流程(在主的goroutine中取消子goroutine;链式取消)
  • 超时控制(若到达指定时间长度(比如10分钟)后自动取消,若不足指定的时间长度内可手动取消)
  1. 示例:
func SomeBusiness() {
	ctx := context.TODO() // 表示现在我还不知道 context 从哪里来,但是感觉将来会有一个
	// 如果确定我这个方法就是调用链路的起点,就使用 context.Background
	// 如果我不知道我是不是起点,我也不知道谁会调用我,那就推荐使用 TODO

	Step1(ctx)
}

// context 通常使用时一般作为第一个参数
// 比如和第三方打交道的,如数据库,redis,和下游 rpc 和 http 打交道等,即便不需要也需要往下传
func Step1(ctx context.Context) {
	var db *sql.DB
	// 这里需要将 ctx 往下传
	db.ExecContext(ctx, "UPDATE xxx", 1)
}

方法

  • Deadline:返回过期时间,如果ok为false,说明么有设置过期时间,不常用
  • Done:返回一个channel,一般用于监听 Context 实例的信号,比如过期,或者正常关闭,常用
  • Err:返回一个错误用于表达 Context 发生了什么。Canceled => 正常关闭,DeadlineExceeded => 过期超时。比较常用
  • context.Value:取值。

示例

// 自动超时
func TestContextWithTimeoutDeadlineExceeded(t *testing.T) {
	ctx := context.Background() // 起点
	fmt.Println(ctx.Err())      // nil

	timeoutCtx, cancel := context.WithTimeout(ctx, time.Second) // 返回一个重新封装过的 context
	// 紧接着都会写一句下面这句,意思是说我退出本方法(TestContext1)的时候会取消掉,当然也可以主动取消
	defer cancel()

	fmt.Println(timeoutCtx.Err()) // nil
	// 这里为什么要睡2秒,原因是 上面 withTimeout 设置的 1s 超时,这里睡了 2s, 整个方法会在睡到 1s 的时候超时自动取消
	time.Sleep(time.Second * 2)
	// 看下Err的调用效果
	err := timeoutCtx.Err()
	fmt.Println(err) // 超时:context deadline exceeded
	switch err {
	case context.Canceled:
		fmt.Println("context.canceled")
	case context.DeadlineExceeded:
		fmt.Println("context.deadline exceeded") // result
	default:
		fmt.Println(err)
	}
}

// 主动取消
func TestContextWithTimeoutCanceled(t *testing.T) {
	ctx := context.Background()
	timeoutCtx, cancel := context.WithTimeout(ctx, time.Second)
	// 但是这里不到 1s, 而是半秒后手动取消
	time.Sleep(500 * time.Millisecond)
	cancel() // 主动取消

	// 接着查看 err 的值是什么?
	err := timeoutCtx.Err()
	fmt.Println(err) // 主动取消:context canceled
	switch err {
	case context.Canceled:
		fmt.Println("context.canceled") // result
	case context.DeadlineExceeded:
		fmt.Println("context.deadline exceeded")
	default:
		fmt.Println(err)
	}
}

// 总结:
// 所以可以通过 timeoutCtx.Err() 返回的值查看是超时还是主动取消

func TestContextDeadline01a(t *testing.T) {
	ctx := context.Background()
	timeoutCtx, cancel := context.WithTimeout(ctx, time.Second)
	defer cancel()

	// dl 指向预计的过期时间,是个准确的时间点
	// ok: true 表示确实设置过期时间
	dl, ok := timeoutCtx.Deadline()
	fmt.Println(dl, ok) // 2023-06-10 23:47:22.709257 +0800 m=+1.000451976 true
}

func TestContextDeadline01b(t *testing.T) {
	ctx := context.Background()
	// ok: false 表示没有设置超时时间
	dl, ok := ctx.Deadline()
	fmt.Println(dl, ok) // 0001-01-01 00:00:00 +0000 UTC false
}

// context 设置值
func TestContextWithValue(t *testing.T) {
	ctx := context.Background()
	// 不断的进行 WithValue 就是不断的创建新的 context 出来
	// 源码里面因要求 key 还是要可比较的,因为如果是不可比较的也不能设置为 map 的 key
	// 最后返回的是一个新的 context
	valCtx := context.WithValue(ctx, "abc", 123)
	val := valCtx.Value("abc")
	fmt.Println(val) // 123
}

安全传递数据

  • 指在请求执行上下文中线程安全(在请求执行过程中,线程安全传输数据,线程安全是指不会因为两个东西不会对同一个东西并发读写导致错误)的传递数据,依赖于 WithValue 方法。

例如:

  • 分库分表中间件中差怒地 sharding hint
  • orm中间件传递 SQL hint
  • web框架传递上下文
  • 链路追踪 trace id
  • 压力测试标记位

控制链路 父子关系 特点: context的实例之间存在父子关系: 当父亲取消或者超市,所有派生的子context都被取消或者超市 当着key的时候,子context先看自己有没有,,没有则去祖先里面找 控制是从上到下,查找是从下到上

valueCtx实现: valueCtx用于存储 key-value 数据,特点: 典型的装饰器模式:在已有context基础上附加一个存储key-value的功能 只能存储一个key,value:为什么不用map map要求key是comparable的,而我们可能用不是comparable的key context包的设计理念就是将context设计成不可变

方案:

  1. 全局变量
  2. channel
  3. context

场景:CPU持续监控,需要每隔一段时间报告监控信息,使用context的方式中断流程执行

func TestCPUMonitorWithContext01(t *testing.T) {
	wg.Add(1)
	ctx, cancel := context.WithCancel(context.Background())
	go func(ctx context.Context) {
		defer wg.Done()

		fmt.Println("CPU监控中...")
		for {
			select {
			case <-ctx.Done(): // 通过接收 Done 来判断是否已经取消了,如果已经取消,打印信息
				fmt.Println("CPU 监控中断") 
				return
			default:
				time.Sleep(time.Second * 2)
				fmt.Println("CPU数据采集完成")
			}
		}
	}(ctx)
	time.Sleep(time.Second * 6)
	cancel() // 6s后取消,函数中的 ctx 会接受到取消的信号
	wg.Wait()

	fmt.Println("信息监控完成")
}

Context的链式取消

父context被取消,那么这个父context生成的子context也会被取消,可以达成链式取消

func TestCPUMonitorWithContext01b(t *testing.T) {
	// 使用context.cancel的方式
	wg.Add(2)
	ctx, cancel := context.WithCancel(context.Background())
	go func(ctx context.Context) {
		defer wg.Done()
		fmt.Println("CPU、Monitor 监控中...")
		
		// 子goroutine
		go func(ctx context.Context) {
			defer wg.Done()
			for {
				select {
				case <-ctx.Done():
					fmt.Println("Memory 监控中断")
					return
				default:
					time.Sleep(time.Second)
					fmt.Println("Memory数据采集完成")
				}
			}
		}(ctx)
		for {
			select {
			case <-ctx.Done():
				fmt.Println("CPU 监控中断")
				return
			default:
				time.Sleep(time.Second)
				fmt.Println("CPU数据采集完成")
			}
		}
	}(ctx)

	time.Sleep(time.Second * 6)
	cancel()
	wg.Wait()
	fmt.Println("信息监控完成")
}
// context的特性:
// 父context被取消,那么这个父context生成的子context也会被取消,可以达成链式取消
func TestCPUMonitorWithContext01c(t *testing.T) {
	// 使用context.cancel的方式
	wg.Add(2)
	ctx, cancel := context.WithCancel(context.Background())
	go func(ctx context.Context) {
		defer wg.Done()
		fmt.Println("CPU、Monitor 监控中...")
		
		ctx2, _ := context.WithCancel(ctx)
		go func(ctx context.Context) {
			defer wg.Done()
			for {
				select {
				case <-ctx.Done():
					fmt.Println("Memory 监控中断")
					return
				default:
					time.Sleep(time.Second)
					fmt.Println("Memory数据采集完成")
				}
			}
		}(ctx2)
		
		for {
			select {
			case <-ctx.Done():
				fmt.Println("CPU 监控中断")
				return
			default:
				time.Sleep(time.Second)
				fmt.Println("CPU数据采集完成")
			}
		}
	}(ctx)
	time.Sleep(time.Second * 6)
	cancel()
	wg.Wait()
	fmt.Println("信息监控完成")
}
// WithTimeout 超时控制
// 不希望运行时间过长
func TestCPUMonitorTimeout02a(t *testing.T) {
	wg.Add(1)

	// 3秒后过期,即3s后自动取消
	{
		//ctx, _ := context.WithTimeout(context.Background(), 3*time.Second)
		//go func(ctx context.Context){...}(ctx)
	}

	// 如果不足3s,比如等1s钟,1秒后手动取消
	{
		ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
		go func(ctx context.Context) {
			defer wg.Done()
			for {
				select {
				case <-ctx.Done():
					fmt.Println("CPU 监控中断")
					return
				default:
					time.Sleep(time.Second)
					fmt.Println("CPU数据采集完成")
				}
			}
		}(ctx)
		time.Sleep(time.Second)
		// 1s后手动取消
		cancel() // cancel可以采用默认的方式,也可以随时手动取消,但是取消一定要在超时之前取消,之后是没有用的
	}

	wg.Wait()
	fmt.Println("信息监控完成")
}

功能 context 包就用来做两件事:

  1. 安全传递数据
  2. 控制链路 安全传递数据,是指在请求执行上下文中安全传递数据,依赖于 WithValue 方法 线程安全:不会因为两个东西同时对一个变量进行并发读写而导致变量错误,主要依赖于 WithValue方法

父子关系

  • 特点:context的实例之间存在父子关系。

当父亲取消或者超时,所有派生的子 context 都被取消或者超时 当找 key 的时候,子 context 先看自己有没有,没有则去祖先里面找

  • 控制是从上到下,查找是从下到上

举例

// ================================ 父子关系 - 控制关系 ================================
func TestParentCtx(t *testing.T) {
	ctx := context.Background()
	dlCtx, cancel := context.WithDeadline(ctx, time.Now().Add(time.Minute)) // 设置截止到具体的时间点结束

	childCtx := context.WithValue(dlCtx, "key", 123)

	// err 会受下面的cancel影响
	// 这是因为上级(WithDeadline),即父亲级别已经cancel了,所以儿子(childCtx.Err())也会 cancel 掉
	// 这也是链路控制的原因,上层调用的时候传给 context 下层,只要下层处理这个context,上层就会发送个信号给下层,这个信号就是取消,
	// 至于取消代表什么含义,需要根据具体业务来决定
	// cancel 只是一个信号,究竟 cancel 什么,就需要看监听了这个信号的对象要去做什么
	// 怎么知道是哪一个去cancel的呢?答案是不知道,我们是不会也不应该知道是谁在上层哪个在控制下层
	// 正常来说下层不应该关心是那层cancel的,我在下层,别人在上层,别人控制我,我不应该知道谁在控制我,因为我是被控制的,类似木偶
	cancel()

	err := childCtx.Err()
	fmt.Println(err) // context canceled
}
// ================================ 父子关系 - 查找关系(自下而上) ================================
func TestParentValueCtx(t *testing.T) {
	ctx := context.Background()
	// 父亲
	childCtx := context.WithValue(ctx, "key1", 123)
	// 儿子的儿子
	ccCtx := context.WithValue(childCtx, "key2", 124)
	// 像是一个继承关系,key2是儿子的,儿子不会继承给父亲,所以父亲拿不到儿子的key
	val := childCtx.Value("key2")
	fmt.Println(val) // nil

	// 像是一个继承关系,key1是父亲的,儿子会继承自父亲,所以儿子可以查到父亲的key1
	val = ccCtx.Value("key1")
	fmt.Println(val) // 123
	// 继承是从上到下的顺序
	// 这里就有出现问题了?比如后辈设置了一个值,但是上层祖辈想拿到设置的值,需要怎么设置呢?需要使用一些奇怪的方法,就是放一个map进去
}

// 父辈想拿到后辈设置的值-map
// 这种方式不到逼不得已不要用,比较危险,因为已经改变了context不可变性的约束,因为map是可变的,map的引用虽然没有变,但是map的内容也就是map存储的内容是可以被改变的
// 另一个危险的就是在Map里面存储指针
// 如果是跨服务,就只能通过网络协议,比如http的header来传递context
func TestParentValueMapCtx(t *testing.T) {
	ctx := context.Background()
	// 父亲
	childCtx := context.WithValue(ctx, "map", map[string]string{})
	// 孙子
	ccChild := context.WithValue(childCtx, "key1", "value1")
	m := ccChild.Value("map").(map[string]string) // 这边要做下类型断言
	m["key1"] = "val1"

	val := childCtx.Value("key1") // 父级拿子级的key是拿不到的
	fmt.Println(val)              // nil

	// 像是一个继承关系,key1是父亲的,儿子会继承自父亲,所以儿子可以拿到父亲的key1
	val = childCtx.Value("map")
	fmt.Println(val) // map[key1:val1]
}

源码学习-valueCtx实现

WithValue 典型的装饰器模式,已经有了一个实现,在原来的基础上装饰一下,在上面加点功能,装饰器强调的在已有的基础上增加新的额外功能。

DB.conn超时控制

首先直接检查一次context.Context有没有超市。

这种体检检测的用法比较常见,比如RPC链路超市控制就可以先看看context有没有超时。 如果超时则可以不发送请求,直接返回超时响应。

// conn returns a newly-opened or cached *driverConn.
func (db *DB) conn(ctx context.Context, strategy connReuseStrategy) (*driverConn, error) {
	
}
  • 先检查 context 有没有超时,如果超时直接返回。如果没有超时才回往后执行。
  • 从源码可以看到什么时候才会将超时的connection关闭呢?当需要使用的时候,这里采用的延时关闭的策略。当我们从连接池里拿出来的时候,会检查有没有过期,如果过期就关掉。没有过期才会给你使用,这就是延迟关闭的机制。 比如有10分钟没有使用连接,那10分钟连接还是活着的,即使超时时间只有3秒,有点像Redis的缓存过期,redis的key过期是我们主动删除掉它吗?一方面它有定时删除的机制,另一方面是在我们取key的时候去检查有没有过期。

http Request 使用 context 作为字段类型

  • http.Request 本身就是 request-scope 的
  • httpRequest 里面的ctx依旧设计为不可变的,我们智能创建一个新的http.Request

bad example

type Example struct {
	ctx context.Context
}

不建议在结构体里面使用context,因为context是线程安全的,就不应该放到结构体里面,但是还是可以看到有些地方放到结构体里面了,比如http.Request。

// ================================ Example: 控制业务超时 http request ================================
// Example: 修改context
func TestRequest(t *testing.T) {
	//http.NewRequestWithContext()

	//req := http.Request{}
	// 返回了一个新的context
	//req = req.WithContext()
}

cancelCtx 实现

cancelCtx 也是典型的装饰器模式:在现有的Context基础上,加上取消的功能。

核心实现:

  • Done方法是通过类似于double-check的机制写的,这种原子操作和锁结合的用法比较罕见(需要思考是否可以换成读写锁?)
  • 利用children来维护了所有的衍生节点,难点在于它是如何维护这个衍生节点的。

children: 核心是儿子吧自己加进去父亲的children里面。

但是因为Context里面存在非常多的层级,所以父亲不一定是cancelCtx,因此本质上是找最近属于cancelCtx类型的祖先,然后儿子把自己加进去。

cancel就是遍历children,挨个调用cancel。然后儿子调用孙在的cancel,子子孙孙无穷匮也。

面试要点

  • context.Context 使用场景:上下文传递和超时控制
  • 原理:
  • 父亲如何控制儿子:通过儿子主动加入到父亲的children里面,父亲只需要遍历就可以。
  • valueCtx 和 timeCtx 的原理