0%

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 不会收到)。 这也跟我们实际的应用场景一致。

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
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,具体使用方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 来监控协程的数量。