前言

Go 1.7 版本之前,context 还是非编制的,它存在于 golang.org/x/net/context 包中。
后来,Golang 团队发现 context 还挺好用的,就把 context 收编了,在 Go 1.7 版本正式纳入了标准库。

为什么需要Context

当一个协程(goroutine)开启后,我们是无法强制关闭它的。
常见的关闭协程的原因有如下几种:

  1. goroutine 自己跑完结束退出(正常关闭,本文不讨论)。
  2. 主进程 crash 退出,goroutine 被迫退出(属异常关闭,应优化代码)。
  3. 通过通道发送信号,引导协程的关闭(属于开发者手动控制协程的方法)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
func main() {
// 定义通知 goroutine 停止的 chanel
stopSingal := make(chan bool)

// 创建 5 个 goroutine
for i := 1; i <= 5; i++ {
go monitor(stopSingal, i)
}

// 等待时间
time.Sleep(1 * time.Second)

// 关闭所有 goroutine
close(stopSingal)

// 等待5s,若此时屏幕没有输出 <正在监控中> 就说明所有的goroutine都已经关闭
time.Sleep(5 * time.Second)

fmt.Println("主程序退出!!")

}

func monitor(ch chan bool, number int) {
for {
select {
case v := <-ch:
// 仅当 ch 通道被 close,或者有数据发过来(无论是true还是false)才会走到这个分支
fmt.Printf("监控器%v,接收到通道值为:%v,监控结束。\n", number, v)
return
default:
fmt.Printf("监控器%v,正在监控中...\n", number)
time.Sleep(2 * time.Second)
}
}
}
1
2
3
4
5
6
7
8
9
10
11
监控器1,正在监控中...
监控器5,正在监控中...
监控器4,正在监控中...
监控器2,正在监控中...
监控器3,正在监控中...
监控器2,接收到通道值为:false,监控结束。
监控器1,接收到通道值为:false,监控结束。
监控器3,接收到通道值为:false,监控结束。
监控器5,接收到通道值为:false,监控结束。
监控器4,接收到通道值为:false,监控结束。
主程序退出!!

上面的例子,在我们定义一个无缓冲通道时,要实现对所有的 goroutine 进行关闭,可以使用 close 关闭通道,然后在所有的 goroutine 里不断检查通道是否关闭*(前提你得约定好,该通道你只会进行 close 而不会发送其他数据,否则发送一次数据就会关闭一个goroutine,这样会不符合咱们的预期,所以最好你对这个通道再做一层封装做个限制)*来决定是否结束 goroutine。

所以你看到这里,我做为初学者还是没有找到使用 Context 的必然理由,我只能说 Context 是个很好用的东西,使用它方便了我们在处理并发时候的一些问题,但是它并不是不可或缺的。

换句话说,它解决的并不是 能不能 的问题,而是解决 更好用 的问题。

简单使用Context

此处的代码,我们先实现一个简单的 Context Demo(我使用 Context 对上面的例子进行了一番改造),然后详细分析其中的代码意义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func main() {
ctx, cancel := context.WithCancel(context.Background())

for i :=1 ; i <= 5; i++ {
go monitor(ctx, i)
}

time.Sleep( 1 * time.Second)
// 关闭所有 goroutine
cancel()

// 等待5s,若此时屏幕没有输出 <正在监控中> 就说明所有的goroutine都已经关闭
time.Sleep( 5 * time.Second)

fmt.Println("主程序退出!!")

}

func monitor(ctx context.Context, number int) {
for {
select {
// 其实可以写成 case <- ctx.Done()
// 这里仅是为了让你看到 Done 返回的内容
case v :=<- ctx.Done():
fmt.Printf("监控器%v,接收到通道值为:%v,监控结束。\n", number,v)
return
default:
fmt.Printf("监控器%v,正在监控中...\n", number)
time.Sleep(2 * time.Second)
}
}
}

代码分析

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

以 context.Background() 为 parent context 定义一个可取消的 context

1
case <- ctx.Done():

然后你可以在所有的goroutine 里利用 for + select 搭配来不断检查 ctx.Done() 是否可读,可读就说明该 context 已经取消,你可以清理 goroutine 并退出了。

1
cancel()

当你想到取消 context 的时候,只要调用一下 cancel 方法即可。这个 cancel 就是我们在创建 ctx 的时候返回的第二个值。

该程序的运行结果如下

1
2
3
4
5
6
7
8
9
10
监控器3,正在监控中...
监控器4,正在监控中...
监控器1,正在监控中...
监控器2,正在监控中...
监控器2,接收到通道值为:{},监控结束。
监控器5,接收到通道值为:{},监控结束。
监控器4,接收到通道值为:{},监控结束。
监控器1,接收到通道值为:{},监控结束。
监控器3,接收到通道值为:{},监控结束。
主程序退出!!

根Context 是什么?

创建 Context 必须要指定一个 父 Context,当我们要创建第一个Context时该怎么办呢?
不用担心,Go 已经帮我们实现了2个,我们代码中最开始都是以这两个内置context作为最顶层的parent context,衍生出更多的子Context。

1
2
3
4
5
6
7
8
9
10
11
12
var (
background = new(emptyCtx)
todo = new(emptyCtx)
)

func Background() Context {
return background
}

func TODO() Context {
return todo
}

Background:主要用于main函数、初始化以及测试代码中,作为Context这个树结构的最顶层的Context,也就是根Context,它不能被取消。
TODO:如果我们不知道该使用什么Context的时候,可以使用这个,但是实际应用中,暂时还没有使用过这个TODO。
他们两个本质上都是emptyCtx结构体类型,是一个不可取消,没有设置截止时间,没有携带任何值的Context。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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
}

Context 的继承衍生

上面在定义我们自己的 Context 时,我们使用的是 WithCancel 这个方法。除它之外,context 包还有其他几个 With 系列的函数.

1
2
3
4
func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key, val interface{}) Context

这四个函数有一个共同的特点,就是第一个参数,都是接收一个 父context。

WithCancel

WithCancel 返回带有新Done通道的父节点的副本。当调用返回的cancel函数或当关闭父上下文的Done通道时,将关闭返回上下文的Done通道,无论先发生什么情况。

取消此上下文将释放与其关联的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func main() {
ctx, cancel := context.WithCancel(context.Background())

for i := 1; i <= 5; i++ {
go job1(ctx, i)
}

time.Sleep(1 * time.Second)

// 关闭所有 goroutine
cancel()

// 等待5s,若此时屏幕没有输出 <正在监控中> 就说明所有的goroutine都已经关闭
time.Sleep(5 * time.Second)

fmt.Println("主程序退出!!")
}

func job1(ctx context.Context, number int) {
for {
select {
// 其实可以写成 case <- ctx.Done()
// 这里仅是为了让你看到 Done 返回的内容
case v := <-ctx.Done():
fmt.Printf("监控器%v,接收到通道值为:%v,监控结束。\n", number, v)
return
default:
fmt.Printf("监控器%v,正在监控中...\n", number)
time.Sleep(2 * time.Second)
}
}
}
1
2
3
4
5
6
7
8
9
10
11
监控器5,正在监控中...
监控器2,正在监控中...
监控器3,正在监控中...
监控器4,正在监控中...
监控器1,正在监控中...
监控器2,接收到通道值为:{},监控结束。
监控器4,接收到通道值为:{},监控结束。
监控器5,接收到通道值为:{},监控结束。
监控器1,接收到通道值为:{},监控结束。
监控器3,接收到通道值为:{},监控结束。
主程序退出!!

WithDeadline

WithDeadline 返回父上下文的副本,并将deadline调整为不迟于d。如果父上下文的deadline已经早于d,则WithDeadline(parent, d)在语义上等同于父上下文。当截止日过期时,当调用返回的cancel函数时,或者当父上下文的Done通道关闭时,返回上下文的Done通道将被关闭,以最先发生的情况为准。

取消此上下文将释放与其关联的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
func main() {
ctx01, cancel := context.WithCancel(context.Background())
ctx02, cancel := context.WithDeadline(ctx01, time.Now().Add(1 * time.Second))

defer cancel()

for i :=1 ; i <= 5; i++ {
go job2(ctx02, i)
}

time.Sleep(5 * time.Second)
if ctx02.Err() != nil {
fmt.Println("监控器取消的原因: ", ctx02.Err())
}

fmt.Println("主程序退出!!")
}

func job2(ctx context.Context, number int) {
for {
select {
case <- ctx.Done():
fmt.Printf("监控器%v,监控结束。\n", number)
return
default:
fmt.Printf("监控器%v,正在监控中...\n", number)
time.Sleep(2 * time.Second)
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
监控器5,正在监控中...
监控器3,正在监控中...
监控器4,正在监控中...
监控器2,正在监控中...
监控器1,正在监控中...
监控器1,监控结束。
监控器3,监控结束。
监控器2,监控结束。
监控器4,监控结束。
监控器5,监控结束。
监控器取消的原因: context deadline exceeded
主程序退出!!

WithTimeout

WithTimeout 返回WithDeadline(parent, time.Now().Add(timeout))

取消此上下文将释放与其相关的资源,因此代码应该在此上下文中运行的操作完成后立即调用cancel,通常用于数据库或者网络连接的超时控制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
func main() {
ctx01, cancel := context.WithCancel(context.Background())

// 此处定义1秒
ctx02, cancel := context.WithTimeout(ctx01, 1 * time.Second)

defer cancel()

for i :=1 ; i <= 5; i++ {
go job3(ctx02, i)
}

time.Sleep(5 * time.Second)
if ctx02.Err() != nil {
fmt.Println("监控器取消的原因: ", ctx02.Err())
}

fmt.Println("主程序退出!!")
}

func job3(ctx context.Context, number int) {
for {
select {
case <- ctx.Done():
fmt.Printf("监控器%v,监控结束。\n", number)
return
default:
fmt.Printf("监控器%v,正在监控中...\n", number)
time.Sleep(2 * time.Second)
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
监控器5,正在监控中...
监控器4,正在监控中...
监控器2,正在监控中...
监控器3,正在监控中...
监控器1,正在监控中...
监控器4,监控结束。
监控器1,监控结束。
监控器2,监控结束。
监控器5,监控结束。
监控器3,监控结束。
监控器取消的原因: context deadline exceeded
主程序退出!!

WithValue

WithValue 返回父节点的副本,其中与key关联的值为val。

仅对API和进程间传递请求域的数据使用上下文值,而不是使用它来传递可选参数给函数。

所提供的键必须是可比较的,并且不应该是string类型或任何其他内置类型,以避免使用上下文在包之间发生冲突。WithValue的用户应该为键定义自己的类型。为了避免在分配给interface{}时进行分配,上下文键通常具有具体类型struct{}。或者,导出的上下文关键变量的静态类型应该是指针或接口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
func main() {
ctx01, cancel := context.WithCancel(context.Background())
ctx02, cancel := context.WithTimeout(ctx01, 1* time.Second)
ctx03 := context.WithValue(ctx02, "item", "CPU")

defer cancel()

for i :=1 ; i <= 5; i++ {
go job4(ctx03, i)
}

time.Sleep(5 * time.Second)
if ctx02.Err() != nil {
fmt.Println("监控器取消的原因: ", ctx02.Err())
}

fmt.Println("主程序退出!!")
}

func job4(ctx context.Context, number int) {
for {
select {
case <- ctx.Done():
fmt.Printf("监控器%v,监控结束。\n", number)
return
default:
// 获取 item 的值
value := ctx.Value("item")
fmt.Printf("监控器%v,正在监控 %v \n", number, value)
time.Sleep(2 * time.Second)
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
监控器5,正在监控 CPU 
监控器1,正在监控 CPU
监控器4,正在监控 CPU
监控器3,正在监控 CPU
监控器2,正在监控 CPU
监控器3,监控结束。
监控器1,监控结束。
监控器5,监控结束。
监控器4,监控结束。
监控器2,监控结束。
监控器取消的原因: context deadline exceeded
主程序退出!!