在上一篇文章中,讲了很多跟 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 2
的 Deadline
到了,这个时候 child 2
的定时器会调用 CancelFunc
来给子 Context
发送取消信号。
child 2-2
里的 select
语句的 context.Done()
得以返回,从而开始执行清理操作,然后中止协程的执行。
在这个过程中,取消信号的传播是从上往下一级级有序传递的,每一级的 Context
会给那些从其派生的 Context
传传递取消信号,直到叶子结点。
需要注意的是,虽然信号传播是从上往下的,但是不代表子协程需要等待父协程的
context.Done()
里面的逻辑执行完再执行,因为我们之前也说过,在 go 里面,协程是平等的,父子协程的执行是同时进行的。
我们可以看看下面的例子,有点啰嗦,大概看一下就好:
主要是想通过这个例子说明,在调用 CancelFunc
的时候,所有子孙 Context
都能接收到这个信号(当然它的父 Context
不会收到)。 这也跟我们实际的应用场景一致。
go
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
,具体使用方式如下:
go
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
来实现,如果要进行超时控制,可以使用WithTimeout
或WithDeadline
。 Context
是一个树状结构,每一个Context
都可以作为父Context
创建新的Context
,然后在调用CancelFunc
或者超时的时候,会由父到子传递取消的信号。Context
也可以用来传递参数,比如我们可以通过WithValue
来传递参数,然后在子协程里面通过Value
来获取参数。- 最后,我们讲解了如何通过
pprof
来监控协程的数量。