go语言基础-----19-----Context使用原则、接口、派生上下文(select的多路复用可以参考这里理解更好)

1 Go语言Context介绍

为什么需要 Context ?

  • 每一个处理都应该有个超时限制。
  • 需要在调用中传递这个超时。
  • 比如开始处理请求的时候我们说是 3 秒钟超时。
  • 那么在函数调用中间,这个超时还剩多少时间了?
  • 需要在什么地方存储这个信息,这样请求处理中间可以停止。
  • Context是协程安全的。代码中可以将单个Context传递给任意数量的goroutine,并在取消该Context时可以将信号传递给所有的goroutine。

2 Context接口

Context接口的文件位置位于go/src/context/路径下的context.go文件。
去掉注释后,实际就是下面的内容:

type Context interface {
	Deadline() (deadline time.Time, ok bool)
	Done() <-chan struct{}
	Err() error
	Value(key interface{}) interface{}
}
  • 1)Deadline方法: 是获取设置的截止时间的意思,第一个返回是指截止时间,到了这个时间点,Context会自动发起取消请求。第二个返回是指,当ok==false时表示没有设置截止时间,如果需要取消的话,需要调用取消函数进行取消。
  • 2)Done方法: 返回一个只读的chan,类型为struct{}。 我们在goroutine中,如果该方法返回的chan可以读取,则意味着parent context已经发起了取消请求,我们通过Done方法收到这个信号后,就应该做清理操作,然后退出goroutine,释放资源。
  • 3)Err方法: 返回取消的错误原因,因为什么Context被取消。
  • 4)Value方法: 获取该Context上绑定的值,是一个键值对,所以要通过一个Key才可以获取对应的值,这个值一般是线程安全的。

3 Background()和TODO()

Go语言内置两个函数:Background() 和 TODO(),这两个函数分别返回一个实现了 Context 接口的background 和 todo。

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

4 Context的继承衍生

看了上面两点后,可能你认为直接定义一个Background或者TODO变量,然后调用Deadline、Done、Err、Value方法即可。
但是实际不是这样的,我们看源码看到,虽然Background()和TODO()返回的对象实现了这四个接口的方法,但是都是直接返回nil,相当于只是声明了这几个方法。 此时Background()和TODO()返回的对象相当于C++的抽象基类,Deadline、Done、Err、Value这4个函数相当于C++的纯虚函数(或者叫虚函数也没问题)。
他们的最终作用都是一样的,都是为了派生而准备。

下图看到:定义了backgroup、todo对象,通过函数Background()和TODO()进行返回。
在这里插入图片描述

然后再看emptyCtx这个类型,它实现了四个方法,内部均是没有做处理的,所以必定需要继承来实现它们。Context才能被用户使用。
在这里插入图片描述

4.1 相关取消的函数介绍

那么,go是如何继承这些内容去提供给用户使用的呢,go是通过下面封装好的四个函数给用户去使用Context的。
1 WithCancel

/* 
 * 参数parent:父Context,一般是Background()和TODO()。
 * 
 * 返回值:第一个返回子Context。  第二个返回取消函数。
*/
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)

2 WithDeadline

/* 
 * 参数parent:父Context,一般是Background()和TODO()。
 * 参数deadline:函数执行的截止时间。例如此时调用函数fun(),想要设置过3s后不管是否阻塞是否成功,都要自动返回,那么传time.Now().Add(3*time.Second)即可。
 * 
 * 返回值:第一个返回子Context。  第二个返回取消函数。
*/
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)

3 WithTimeout

/* 
 * 参数parent:父Context,一般是Background()和TODO()。
 * 参数timeout:函数多久会自动截止的时间,表时间间隔,上面deadline 表时间刻。例如此时调用函数fun(),想要设置过3s后不管是否阻塞是否成功,都要自动返回,那么直接传3*time.Second即可。 
 * 
 * WithCancel、WithDeadline与WithTimeout可以认为是一样的,第一个只是没有时间要求,第二、三是一样的,参数传法不一样而已。
 * 
 * 返回值:第一个返回子Context。  第二个返回取消函数。
*/
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)

4 WithValue

/* 
 * 参数parent:父Context,一般是Background()和TODO()。
 * 参数key:想要绑定键值对的key。
 * 参数val:想要绑定键值对的value。
 * 
 * 返回值:第一个返回子Context。
 * 
 * WithValue函数和取消Context无关,它是为了生成一个绑定了一个键值对数据的Context,这个绑定的数据可以通过Context.Value方法访问到。
*/
func WithValue(parent Context, key interface{}, val interface{}) Context

以WithCancel函数为例,讲一下go是如何继承接口去给用户提供Context使用的。

  • 1)首先看到WithCancel函数内部。我们看到newCancelCtx函数,参数为parent,此时的parent一般是Background()和TODO()返回的对象,所以基本是这里作继承的处理了。所以往第2点看。
    在这里插入图片描述
  • 2)下图看到,直接返回了一个cancelCtx对象,那么我们猜想这个结构体里面必定继承了Context这些接口的,实际看到第二张图确实也是,在结构体里面它是一个匿名接口对象。 这样这个返回的cancelCtx对象就继承了这些接口,然后看回上面的WithCancel函数,propagateCancel函数应该是对parent这些接口进行封装、与返回的cancelCtx对象进行处理,我们不关心。
    最后看到返回了cancelCtx对象以及这个对象的取消函数。所以最终通过这个WithCancel函数,继承了这些内容提供给用户处理,从而可以实现方法超时的处理。
    在这里插入图片描述
    在这里插入图片描述

4.2 注意事项

  • 1)在调用上面四个函数时( WithCancel、WithDeadline、WithTimeout、WithValue),需要加context,注意这个是包名,而不是对象名。例如:
ctx, cancel1 := context.WithCancel(context.Background())

context是这个WithCancel所在包的包名,不是对象的名字。定义了Context对象后,是找不到WithCancel这个方法的,因为Context只有Deadline、Done、Err、Value这4个方法。

  • 2 )当我们调用了WithCancel、WithDeadline、WithTimeout,通过它们的返回值获取到取消函数,调用后,仍需要Context抽象基类对象的原接口Done去处理,只有调用它后我们才知道是否已经超时或者调用者主动取消了调用。具体看下面的例子体会。

4.3 相关例子

1 测试WithCancel。

package main

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

func main() {
	// 1. 通过相应的函数的返回值,获取Context与取消函数。
	ctx, cancel1 := context.WithCancel(context.Background())

	// 2. 开启协程,通过参数将Context传入协程。Context是协程安全的。
	go func(ctx context.Context) {
		for {
			select {
			// 4. 等待取消事件的到来。
			case v := <-ctx.Done():
				fmt.Println("监控退出,停止了..., v: ", v, ", err:", ctx.Err())
				return
			default:
				time.Sleep(2 * time.Second)
				fmt.Println("goroutine监控中...")
				// time.Sleep(2 * time.Second)
			}
		}
	}(ctx)

	// 3. 人为启动取消函数,相当于认为接口超时了,那么上面的ctx.Done()就会收到信号,协程会停止。
	time.Sleep(5 * time.Second)
	fmt.Println("可以了,通知监控停止")
	cancel1()
	fmt.Println("通知监控停止完成")

	// 等待协程的停止,因为cancel1()发送信号后,main可能直接退出了,协程就无法打印。
	time.Sleep(5 * time.Second)
}

结果,分析:

  • 1)首先协程因为添加default所以select是非阻塞,并且default是每次事件处理会睡两秒,mian睡5秒,所以协程打印了两次"goroutine监控中…"后,在第4s再次触发default事件,也就是总共触发3次default事件,此时同样会睡两秒,然后main在第5秒会触发取消,虽然触发了,但是仍会等待default睡醒再执行,因为触发取消它相当于把这个事件放进这个队列里面,只有处理完当前的事件,才会取下一个事件处理。 所以必定是先打印完第3个"goroutine监控中…"后,再打印"监控退出,停止了…, v: {} , err: context canceled"这句话。

这涉及到select的多路复用思想,select实现多路复用是根据回调来实现的,当有事件到来,会把事件放到一个数组中去,然后轮询的从这个数组中取事件进行处理,这也就是为什么上面会先打印"goroutine监控中…",再打印"监控退出,停止了…, v: {} , err: context canceled"。

具体参考可以参考我网络编程里面的select相关的文章。例如:
网络编程之IO复用机制(多路IO转接)之select实现IO复用的代码实现03
在这里插入图片描述

2 测试WithDeadline。
实际代码和上面是差不多的。
2.1 首先测试不提前调用取消函数,即让其根据超时时间自动退出协程的例子。

package main

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

func main() {

	// 1. 通过相应的函数的返回值,获取Context与取消函数。
	ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(3*time.Second))
	// ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)

	// 2. 开启协程,通过参数将Context传入协程。Context是协程安全的。
	// var wg sync.WaitGroup
	go func(ctx context.Context) {
		// wg.Add(1)
		// defer wg.Done()
		for {
			select {
			// 4. 等待取消事件的到来。
			case <-ctx.Done():
				fmt.Println("监控退出,停止了..., err:", ctx.Err())
				return
			default:
				time.Sleep(2 * time.Second)
				fmt.Println("goroutine监控中...")
				// time.Sleep(2 * time.Second)
			}
		}
	}(ctx)

	// cancel() // 手动取消,可以不用等待超时,可以让协程直接退出。

	// 3. 人为启动取消函数,相当于认为接口超时了,那么上面的ctx.Done()就会收到信号,协程会停止。
	time.Sleep(5 * time.Second)
	fmt.Println("可以了,通知监控停止")
	cancel()
	fmt.Println("通知监控停止完成")

	// wg.Wait() // 等待协程退出

	// 等待协程的停止,因为cancel1()发送信号后,main可能直接退出了,协程就无法打印。
	time.Sleep(5 * time.Second)
}

结果并分析:

  • 1)虽然我主函数睡了5秒,但是因为我设置的超时时间是3秒,所以协程的前两秒在执行default,第2秒后同样执行default,然后睡眠,同上面的道理,虽然第3秒时超时信号事件到来,但是根据多路复用原理,会先放在数组当中,处理完这次的default才会从数组取事件处理,故前两行打印"goroutine监控中…",第3行打印监控退出,停止了…, err: context deadline exceeded。
  • 2)然后main睡醒5秒后就正常往下执行了,不过看到,即使超时信号已经处理了,我们还是调用了cancel(),程序没有报错,但是在实际开发不建议这样处理,最好把cancel()那行注释掉。
    在这里插入图片描述

2.2 测试手动调用cancel()提前退出的例子。
很简单,只需要将睡眠5秒的上面那个cancel()去掉注释即可,其余代码不变。即:

cancel() // 手动取消,可以不用等待超时,可以让协程直接退出。

结果:
那么就相当于直接退出了,啥也不干。
在这里插入图片描述

3 测试WithTimeout。
与测试WithDeadline过程是一样的,只是在开头换了个函数而已,结果也是一样的,这里就不列出来了,很简单的。
即只需要将WithDeadline的代码换成以下:

// 1. 通过相应的函数的返回值,获取Context与取消函数。
// ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(3*time.Second))
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)

4 测试WithValue。
WithValue函数和取消Context无关,它是为了生成一个绑定了一个键值对数据的Context,这个绑定的数据可以
通过Context.Value方法访问到。

很简单,就是上面的例子,然后添加了键值对的处理而已,看下面的例子:

package main

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

var key string = "name"
var key2 string = "name1"

func main() {
	// 1. 通过相应的函数的返回值,获取Context与取消函数。
	ctx, cancel := context.WithCancel(context.Background())

	// 2. 通过WithValue像Context添加键值对,并返回新的子Context
	valueCtx := context.WithValue(ctx, key, "key【监控1】")
	valueCtx2 := context.WithValue(valueCtx, key2, "key【监控2】") // 可以有多个key

	// 3. 开启协程,通过参数将Context传入协程。Context是协程安全的。
	go watch(valueCtx2) // 传的时候,注意传最后的valueCtx2才能获取到key2.

	// 4. 人为启动取消函数,相当于认为接口超时了,那么上面的ctx.Done()就会收到信号,协程会停止。
	time.Sleep(5 * time.Second)
	fmt.Println("可以了,通知监控停止")
	cancel()
	fmt.Println("通知监控停止完成")

	// 等待协程的停止,因为cancel1()发送信号后,main可能直接退出了,协程就无法打印。
	time.Sleep(5 * time.Second)
}

func watch(ctx context.Context) {
	for {
		select {
		// 5. 等待取消事件的到来。
		case <-ctx.Done():
			// 通过Context可以取出里面的键值对
			fmt.Println(ctx.Value(key), "监控退出,停止了...")
			fmt.Println(ctx.Value(key2), "监控退出,停止了...")
			return
		default:
			// 通过Context可以取出里面的键值对
			fmt.Println(ctx.Value(key), "goroutine监控中...")
			time.Sleep(2 * time.Second)
		}
	}
}

结果,不分析了,看完上面的分析,这里肯定看得懂,因为不难。
在这里插入图片描述

4 使用sync.WaitGroup让main根据协程结束实际的情况来阻塞程序
这种处理非常方便, 就不需要再使用time.Sleep操作,更何况我们无法知道协程的实际结束时间,time.Sleep的秒数就是不确定的,所以使用sync.WaitGroup处理是有意义的。

代码也很简单:

package main

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

func work(ctx context.Context, wg *sync.WaitGroup) {
	defer wg.Done()
	for {
		select {
		case <-ctx.Done():
			fmt.Println("监控退出,停止了...")
			return
		default:
			fmt.Println("hello")
			time.Sleep(time.Second)
		}
	}
}

func main() {

	// 1. 通过相应的函数的返回值,获取Context与取消函数。
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) // 所有协程会根据这个超时,从ctx.Done()收到信号然后return退出。
	defer cancel()                                                          // 在建立之后,立即 defer cancel() 是一个好习惯。

	// 2. 定义一个sync.WaitGroup对象。
	// 这个对象在这里的意义是:
	// 虽然没有多个写协程在写通道chan,但是在main中可以按照协程的实际结束时间来等待,代替前面在main末尾的time.Sleep(5 * time.Second)操作。
	var wg sync.WaitGroup
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go work(ctx, &wg)
	}
	time.Sleep(time.Second)

	// 3. 可以按照协程的结束时间来使main结束,不需要在time.Sleep(5 * time.Second)。
	wg.Wait()
}

结果,分析,前面会各个协程会不断的打印hello,然后最终打印10行协程退出。因为程序开了10协程。

这是打印的末尾截图,前面还有挺多hello因为太长,没有截图下来,不过不影响我们分析程序。
在这里插入图片描述

5 Derived contexts派生上下文

Context包提供了从现有Context值派生新Context值的函数。这些值形成一个树:当一个Context被取消时,从它派生的所有Context也被取消

看下面的例子,这里强调,想看懂结果,必须把Context的派生图给画出来,养成好的习惯。

package main

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

func work(ctx context.Context, str string) {
	for {
		select {
		case <-ctx.Done():
			fmt.Println("退出 ", str)
			return
		}
	}
}

func main() {
	// 1. 构建了一个Context树,最好使用图画出来。
	// 下面的树是:
	// 1<--2<--3<--4
	// 			|<--5<--6
	ctx1 := context.Background()
	ctx2, cancel2 := context.WithCancel(ctx1)
	ctx3, cancel3 := context.WithTimeout(ctx2, time.Second*5)
	ctx4, cancel4 := context.WithTimeout(ctx3, time.Second*3)
	ctx5, cancel5 := context.WithTimeout(ctx3, time.Second*6)
	ctx6 := context.WithValue(ctx5, "userID", 12)

	// 2. 开启协程,通过参数将Context传入协程。Context是协程安全的。
	go work(ctx1, "ctx1")
	go work(ctx2, "ctx2")
	go work(ctx3, "ctx3")
	go work(ctx4, "ctx4")
	go work(ctx5, "ctx5")
	go work(ctx6, "ctx6")

	// 3. 取消相应的Context。注意,当一个Context被取消时,从它派生的所有Context也被取消。
	time.Sleep(1 * time.Second)
	cancel2() // 从2派生的有:3、4、5、6。所以加上本身ctx2,那么2、3、4、5、6协程都会收到信号退出。
	// cancel5() // 从5派生的有:6。所以加上本身ctx5,那么5、6协程都会收到信号退出。
	time.Sleep(5 * time.Second)

	// 下面是使用它而已,没有太大作用,否则会报编译错误-未使用该变量。
	cancel3()
	cancel4()
	cancel5()
	cancel2()
}

结果:
在这里插入图片描述

例如再把上面的代码cancel2()注释掉,cancel5()不注释,其余代码不变,即:

// cancel2() // 从2派生的有:3、4、5、6。所以加上本身ctx2,那么2、3、4、5、6协程都会收到信号退出。
cancel5() // 从5派生的有:6。所以加上本身ctx5,那么5、6协程都会收到信号退出。

结果,注意,打印的倒数两行是因为放在编译报错调用的那四行cancle导致的,你可以添加打印来隔离就行。
在这里插入图片描述

  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值