1、Go语言并发之context标准库
Go中的 goroutine 之间没有父与子的关系,也就没有所谓子进程退出后的通知机制,多个 goroutine 都是平行地
被调度,多个 goroutine 如何协作工作涉及通信、同步、通知和退出四个方面。
-
通信:chan 通道当然是 goroutine 之间通信的基础,注意这里的通信主要是指程序的数据通道。
-
同步:不带缓冲的 chan 提供了一个天然的同步等待机制;当然 sync.WaitGroup 也为多个 goroutine 协同工
作提供一种同步等待机制。
-
通知:这个通知和上面通信的数据不一样,通知通常不是业务数据,而是管理、控制流数据。要处理这个也好
办,在输入端绑定两个 chan,一个用于业务流数据,另一个用于异常通知数据,然后通过 select 收敛进行处
理。这个方案可以解决简单的问题,但不是一个通用的解决方案。
-
退出:goroutine 之间没有父子关系,如何通知 goroutine 退出?可以通过增加一个单独的通道,借助通道和
select 的广播机制实现退出。
Go语言在语法上处理某个 goroutine 退出通知机制很简单。但是遇到复杂的并发结构处理起来就显得力不从心。
实际编程中 goroutine 会拉起新的 goroutine,新的 goroutine 又会拉起另一个新的 goroutine,最终形成一个树
状的结构,由于 goroutine 里并没有父子的概念,这个树状的结构只是在程序员头脑中抽象出来的,程序的执行
模型并没有维护这么一个树状结构。怎么通知这个树状上的所有 goroutine 退出?仅依靠语法层面的支持显然比
较难处理。为此 Go1.7 提供了一个标准库 context 来解决这个问题。它提供两种功能:退出通知和元数据传递。
context 库的设计目的就是跟踪 goroutine 调用,在其内部维护一个调用树,并在这些调用树中传递通知和元数
据。
1.1 Context的设计目的
context 库的设计目的就是跟踪 goroutine 调用树,并在这些 gouroutine 调用树中传递通知和元数据。两个目
的:
(1)、退出通知机制:通知可以传递给整个goroutine 调用树上的每一个goroutine。
(2)、传递数据:数据可以传递给整个goroutine 调用树上的每一个goroutine。
1.2 基本数据结构
在介绍 context 包之前,先理解context包的整体工作机制:第一个创建Context的goroutine被称为root节点。
root节点负责创建一个实现Context接口的具体对象,并将该对象作为参数传递到其新拉起的goroutine,下游的
goroutine可以继续封装该对象,再传递到更下游的goroutine。
Context 对象在传递的过程中最终形成一个树状的数据结构,这样通过位干root节点(树的根节点)的Context 对象
就能遍历整个Context 对象树,通知和消息就可以通过root节点传递出去,实现了上游 goroutine 对下游
goroutine的消息传递。
1.2.1 Context接口
Context 是一个基本接口,所有的 Context 对象都要实现该接口,context 的使用者在调用接口中都使用 Context
作为参数类型。
type Context interface{
// 如果Context实现了超时控制,则该方法返回ok true,deadline为超时时间,否则ok为false
Deadline() (deadline time.Time,ok bool)
// 后端被调的goroutine应该监听该方法返回的chan,以便及时释放资源
Done() <-chan struct{}
// Done返回的chan收到通知的时候,才可以访问Err()获知因为什么原因被取消
Err()error
// 可以访问上游 goroutine 传递给下游 goroutine 的值
Value(key interface{}) interface{}
}
1.2.2 canceler接口
canceler 接口是一个扩展接口,规定了取消通知的 Context 具体类型需要实现的接口。
context 包中的具体类型 cancelctx 和 timerCtx 都实现了该接口。
1.2.3 empty Context结构
emptyCtx 实现了 Context 接口,但不具备任何功能,因为其所有的方法都是空实现。其存在的目的是作为
Context 对象树的根(root 节点)。因为context包的使用思路就是不停地调用context 包提供的包装函数来创建具有
特殊功能的Context 实例,每一个Context 实例的创建都以上一个Context对象为参数,最终形成一个树状的结
构。
1.2.4 cancelCtx
cancelCtx 是一个实现了Context接口的具体类型,同时实现了conceler接口。conceler具有退出通知方法。注意
退出通知机制不但能通知己,也能逐层通知其children 节点。
1.2.5 timerCtx
timerCtx 是一个实现了Context接口的具体类型,内部封装了cancelCtx类型实例,同时有一个 deadline 变量,
用来实现定时退出通知。
1.2.6 valueCtx
valueCtx 是一个实现了Context接口的具体类型,内部封装了Context接口类型,同时封装了一个k/v的存储变
量,valueCtx 可用来传递通知信息。
1.3 API函数
下面这两个函数是构造 Context 取消树的根节点对象,根节点对象用作后续 With 包装函数的实参。
func Background() Context
func TODO() Context
With 包装函数用来构建不同功能的 Context 具体对象。
(1)、创建一个带有退出通知的 Context 具体对象,内部创建一个 cancelCtx 的类型实例。
func WithCancel(parent Context)(Context,CancelFunc)
(2)、创建一个带有超时通知的 Context 具体对象,内部创建一个 timerCtx 的类型实例。
func WithDeadline(parent Context,deadline time.Time)(Context,CancelFunc)
(3)、创建一个带有超时通知的 Context 具体对象,内部创建一个 timerCtx 的类型实例。
func WithTimeout(parent Context,timeout time.Duration)(Context,CancelFunc)
(4)、创建一个能够传递数据的 Context 具体对象,内部创建一个 valueCtx 的类型实例。
func WithValue(parent Context,key,val interface{})Context
这些函数都有一个共同的特点—— parent 参数,其实这就是实现 Context 通知树的必备条件。在 goroutine 的
调用链中,Context 的实例被逐层地包装并传递,每层又可以对传讲来的 Context 实例再封装自己所需的功能,
整个调用树需要一个数据结构来维护,这个维护逻辑在这些包装函数内部实现。
1.4 辅助函数
前面描述的 With 开头的构造函数是给外部程序使用的 API 接口函数。Context 具体对象的链条关系是在 With 函
数的内部维护的。
func propagateCancel(parent Context,child canceler)
1.5 context的用法
package main
import (
"context"
"fmt"
"time"
)
// 定义一个新的类型包含Context字段
type otherContext struct {
context.Context
}
func main() {
// 创建一个带有退出通知的Context具体对象:*cancelCtx
ctxA, cancel := context.WithCancel(context.Background())
go work(ctxA, "work1")
// 超时3秒计算
tm := time.Now().Add(3 * time.Second)
// 创建一个带有超时通知的Context具体对象:*timerCtx
ctxB, _ := context.WithDeadline(ctxA, tm)
go work(ctxB, "work2")
oc := otherContext{ctxB}
// 创建一个能够传递数据的Context具体对象:*cancelCtx
ctxC := context.WithValue(oc, "key", "god andes,pass from main ")
go workWithValue(ctxC, "work3")
time.Sleep(10 * time.Second)
cancel()
// 这5秒没有什么作用
time.Sleep(5 * time.Second)
fmt.Println("main stop")
}
// 工作
func work(ctx context.Context, name string) {
for {
select {
case <-ctx.Done():
fmt.Printf("%s get msg to cancel\n", name)
return
default:
fmt.Printf("%s is running \n", name)
time.Sleep(1 * time.Second)
}
}
}
// 根据context传递值
func workWithValue(ctx context.Context, name string) {
for {
select {
case <-ctx.Done():
fmt.Printf("%s get msg to cancel\n", name)
return
default:
value := ctx.Value("key").(string)
fmt.Printf("%s is running value=%s \n", name, value)
time.Sleep(1 * time.Second)
}
}
}
# 程序输出
work3 is running value=god andes,pass from main
work1 is running
work2 is running
work1 is running
work3 is running value=god andes,pass from main
work2 is running
work3 is running value=god andes,pass from main
work2 is running
work1 is running
work1 is running
work3 get msg to cancel
work2 get msg to cancel
work1 is running
work1 is running
work1 is running
work1 is running
work1 is running
work1 is running
work1 get msg to cancel
main stop
上面的输出中:
work1 is running
会输出10次,因为会休眠10秒。
work2 is running
会输出3次,因为超时时间为3秒。
work3 is running
也会输出3次,因为它的context取决于work2的context。
在使用 Context 的过程中,程序在底层实际上维护了两条关系链,理解这个关系链对理解 context 包非常有好
处,两条引用关系链如下。
(1)、children key 构成从根到叶子 Context 实例的引用关系,这个关系在调用 With 函数时进行维护(调用上文
介绍的 propagateCancel(parent Context,child canceler) 函数维护),程序有一层这样的树状结构(本示例
是一个链表结构):
ctxa.children--->ctxb
ctxb.children--->ctxc
这个树提供一种从根节点开始遍历树的方法,context 包的取消广播通知的核心就是基于这一点实现的。取消通
知沿着这条链从根节点向下层节点逐层广播。当然也可以在任意一个子树上调用取消通知,一样会扩散到整棵树。
示例程序中 ctxa 收到退出通知,会通知其绑定 work1,同时会广播给 ctxb 和 ctxc 绑定的 work2 和 work3。同
理,ctxb 收到退出通知,会通知到其绑定的 work2,同时会广播给 ctxc 绑定的 work3。
(2)、在构造 Context 的对象中不断地包裹 Context 实例形成一个引用关系链,这个关系链的方向是相反的,是自
底向上的。示例程序中多个 Context 对象的关系如下:
# 自底向上
ctxc.Context -->oc
ctxc.Context.Context -->ctxb
ctxc.Context.Context.cancelCtx-->ctxa
ctxc.Context.Context.cancelCtx.Context-->new(emptyCtx)//context.Background()
这个关系链主要用来切断当前 Context 实例和上层的 Context 实例之间的关系,比如 ctxb 调用了退出通知或定时
器到期了,ctxb 后续就没有必要在通知广播树上继续存在,它需要找到自己的 parent,然后执行
delete(parent.children,ctxb),把自己从广播树上清理掉。
整个关系链如图所示:
通过上文示例梳理出使用 Context 包的一般流程如下:
(1)、创建一个 Context 根对象。
func Background() Context
func TODO() Context
(2)、包装上一步创建的 Context 对象,使其具有特定的功能。
这些包装函数是 context package 的核心,几乎所有的封装都是从包装函数开始的。原因很简单,使用 context
包的核心就是使用其退出通知广播功能。
func WithCancel(parent Context)(ctx Context,cancel CancelFunc)
func WithTimeout(parent Context,timeout time.Duration)(Context,CancelFunc)
func WithDeadline(parent Context, deadline time.Time)(Context,CancelFunc)
func WithValue(parent Context,key,val interface{}) Context
(3)、将上一步创建的对象作为实参传给后续启动的并发函数(通常作为函数的第一个参数),每个并发函数内部可以
继续使用包装函数对传进来的 Context 对象进行包装,添加自己所需的功能。
(4)、顶端的 goroutine 在超时后调用 cancel 退出通知函数,通知后端的所有 goroutine 释放资源。
(5)、后端的 goroutine 通过 select 监听 Context.Done() 返回的 chan,及时响应前端 goroutine 的退出通知,
一般停止本次处理,释放所占用的资源。
1.6 使用context传递数据的争议
该不该使用context传递数据:
首先要清楚使用 context 包主要是解决 goroutine 的通知退出,传递数据是其一个额外功能。可以使用它传递一
些元信息,总之使用 context 传递的信息不能影响正常的业务流程,程序不要期待在 context 中传递一些必需的
参数等,没有这些参数,程序也应该能正常工作。
在context中传递数据的坏处:
(1)、传递的都是 interface{} 类型的值,编译器不能进行严格的类型校验。
(2)、从 interface{} 到具体类型需要使用类型断言和接口查询,有一定的运行期开销和性能损失。
(3)、值在传递过程中有可能被后续的服务覆盖,且不易被发现。
(4)、传递信息不简明,较晦涩;不能通过代码或文档一眼看到传递的是什么,不利于后续维护。
context应该传递什么数据:
(1)、日志信息。
(2)、调试信息。
(3)、不影响业务主逻辑的可选数据。
context 包提供的核心的功能是多个 goroutine 之间的退出通知机制,传递数据只是一个辅助功能,应谨慎使用
context 传递数据。
1.7 Context常用案例
1.7.1 主协程主动调用cancel()取消子context
package main
import (
"context"
"fmt"
"time"
)
func handler() {
fmt.Println("handler start...", time.Now())
ctx, cancel := context.WithCancel(context.Background())
go do(ctx)
// 子孙携程中的ctx会被取消
cancel()
time.Sleep(5 * time.Second)
fmt.Println("handler end...", time.Now())
}
func do(ctx context.Context) {
i := 1
for {
time.Sleep(1 * time.Second)
select {
case <-ctx.Done():
fmt.Println("done", time.Now())
return
default:
fmt.Printf("work %d seconds: %v\n", i, time.Now())
}
i++
}
}
func main() {
fmt.Println("main start...")
handler()
fmt.Println("main end...")
}
# 程序输出
main start...
handler start... 2023-02-06 12:18:29.3556787 +0800 CST m=+0.002016401
done 2023-02-06 12:18:30.3665436 +0800 CST m=+1.012881301
handler end... 2023-02-06 12:18:34.365822 +0800 CST m=+5.012159701
main end...
通过输出我们可以看出来,在主协程调用了 cancel() 之后,子协程中的 ctx 会被主动关闭掉,延迟时间是1秒,
会看到打印 done。
package main
import (
"context"
"fmt"
"time"
)
func handler() {
fmt.Println("handler start...", time.Now())
ctx, _ := context.WithCancel(context.Background())
go do(ctx)
// 子孙携程中的ctx会被取消
time.Sleep(5 * time.Second)
fmt.Println("handler end...", time.Now())
}
func do(ctx context.Context) {
i := 1
for {
time.Sleep(1 * time.Second)
select {
case <-ctx.Done():
fmt.Println("done", time.Now())
return
default:
fmt.Printf("work %d seconds: %v\n", i, time.Now())
}
i++
}
}
func main() {
fmt.Println("main start...")
handler()
fmt.Println("main end...")
}
# 程序输出
main start...
handler start... 2023-06-10 10:49:42.9315016 +0800 CST m=+0.002174101
work 1 seconds: 2023-06-10 10:49:43.9404127 +0800 CST m=+1.011085201
work 2 seconds: 2023-06-10 10:49:44.941524 +0800 CST m=+2.012196501
work 3 seconds: 2023-06-10 10:49:45.9422753 +0800 CST m=+3.012947801
work 4 seconds: 2023-06-10 10:49:46.9426627 +0800 CST m=+4.013335201
handler end... 2023-06-10 10:49:47.9421403 +0800 CST m=+5.012812801
main end...
从结果看出,我们如果不执行 cancel(),则会在设置的5秒睡眠时间内执行 work。
1.7.2 超时之后,调用cancle()
package main
import (
"context"
"fmt"
"time"
)
func handler() {
fmt.Println("handler start...", time.Now())
// 设置2秒的超时时间
ctx, _ := context.WithTimeout(context.Background(), 2*time.Second)
go do(ctx)
time.Sleep(5 * time.Second)
fmt.Println("handler end...", time.Now())
}
func do(ctx context.Context) {
i := 1
for {
time.Sleep(1 * time.Second)
select {
case <-ctx.Done():
fmt.Println("done", time.Now())
return
default:
fmt.Printf("work %d seconds: %v\n", i, time.Now())
}
i++
}
}
func main() {
fmt.Println("main start...")
handler()
fmt.Println("main end...")
}
# 程序输出
main start...
handler start... 2023-06-10 10:51:17.0507657 +0800 CST m=+0.002568201
work 1 seconds: 2023-06-10 10:51:18.0618317 +0800 CST m=+1.013634201
done 2023-06-10 10:51:19.0620425 +0800 CST m=+2.013845001
handler end... 2023-06-10 10:51:22.0630702 +0800 CST m=+5.014872701
main end...
通过输出可以看出来,在2s超时之后,也就是done会主动打印出来,表明 cancel() 被主动调用了。