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导致的,你可以添加打印来隔离就行。