前言
context是Go语言1.7版本加入官方库,官方常用于处理单个请求的多个goroutine与请求域的数据、截止时间、同步信号和传递请求值等相关的操作。学习context,有利于大家更好的使用goroutine,提升对并发编程的理解。
一、为什么需要context
在典型的http服务中,每一个http请求的request都会启动一个goroutine处理请求,此请求后续还可能操作数据库、缓存、日志等,此时由最早的goroutine启动后续的多个goroutine,这样就使用多个goroutine处理一个request请求,而context就是在几个不同goroutine直接同步数据、取消信号以及处理请求截至日期。
context最常规的做法就是从goroutine开始,一层层地把信息传递到最下层。如果没有context,就可能发生上层的已经因为报错而结束,但是下层的goroutine却还在继续。
如果有了context,当上层goroutine发生错误而结束时,可以很快地同步信息到其下层的goroutine,这样可以及时停止下层goroutine,避免无谓的系统消耗。
二、context
1.context接口
context包的核心接口,其定义如下
// 上下文携带截止日期、取消信号以及跨 API 边界的其他值。
// Context 的方法可以被多个 goroutine 同时调用。
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
Context接口内定义了四个方法,分别如下。
- Deadline():需要返回当前Context被取消的时间,也就是截止时间。
- Done():需要返回一个channel,该channel会在工作完成或者Context被取消时关闭,多次调用Done方法返回的是同一个channel。
- Err():用于返回当前Context结束的原因,仅在Done方法返回的channel被关闭时才返回非空值,这里包含两种错误,如果当前Context被取消,则返回Canceled错误;如果当前Context超时,则返回DeadlineExceeded错误。
- Value():用来取得当前Context上绑定的值,是一个键值对,所以参数是一个key值,多次调用该方法而参数相同的话,返回的结果也相同。
虽然Context是一个接口,但是标准包里面实现了其他的两个方法:Background方法和TODO方法,可通过这两个方法来使用Context。在介绍这两个方法之前,需要先介绍一下Context的实现。
Context在数据结构上是一种单向继承关系,最开始的Context起到类似于初始化的作用,里面有一些数据,下一层的Context会继承上一层的Context,新的Context可以有children,children就是在上一层的Context外面再套一层,新扩的一层可以存储与自己相关的数据。这种多层结构可以像启动goroutine一样扩展很多层。
理解了Context的分层模式,就可以方便地理解Background和TODO方法了,这两个方法用于返回私有化的变量background和todo,这两个变量就存储于最顶层的parent Context中,后续的Context都是衍生自这个parent,形成树状层次。当一个parent Context被取消时,继承自它的所有Context都会被取消。
下面来看一下这两个方法在源码中的实现:
func Background() Context {
return background
}
func TODO() Context {
return todo
}
background和todo两个私有变量是在context包初始化的时候就定义好的,Background和TODO这两个方法也没有什么差别,可以理解为二者互为别名,只是Background方法是每个Context的顶层默认值,用于main函数,以及初始化、测试等代码中,它作为根Context是不可以被取消的。而TODO方法则是在不确定的时候使用的,但现实中很少使用。
background和todo这两个私有变量其实是两个指针,指向emptyCtx结构体实例。emptyCtx的定义如下:
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
}
可以看到,本质上background和todo是不携带任何信息的Context,不可取消,没有截止时间;而衍生出来的Context都继承自这个根Context。
2.context退出与传递
前面介绍了Context的分层模式,那么Context是如何实现退出和传递的呢?退出和传递靠context包提供的With系列函数实现的。下面来看一下With系列函数,一共有四个:
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {}
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {}
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {}
func WithValue(parent Context, key, val interface{}) Context {}
这些函数都接收一个Context类型的参数parent,并返回一个Context类型的值,这样就层层创建出不同的节点。父节点创建Context,并传递给子节点。下面介绍一下各个函数的作用。
- WithCancel:parent Context根据参数创建一个新的children Context,同时还返回一个取消该children Context的函数CancelFunc。其主要作用是在parent和children之间同步取消或结束信号,确定parent被取消时,其children也会收到信号而被取消。其实现的原理是所有的children都被保存在一个map中,如果是Context执行了Done方法会返回done channel,此时是正常结束所以返回以后就完结了;而如果是通过Err方法结束,则会遍历Context的所有children并关闭其channel。
- WithDeadline:与WithCancel类似,指定一个截止时间参数,到了截止时间会自动取消该Context。当截止时间发生后,子context将退出。因此子context的退出有3种时机,一种是父context退出;一种是超时退出;一种是主动调用cancel函数退出。
- WithTimeout:与WithDeadline类似,指定超时时间。
- WithValue:该函数的作用是生成一个绑定了一个键值对数据的Context,这个绑定的数据可以通过该Context的Value方法访问。该方法可以完成追踪功能,需要通过Context传递数据时可以使用该方法。
Context对象的生存周期一般仅为一个请求的处理周期,即针对一个请求创建一个Context变量(它是上下文树结构的根)。在请求处理结束后,撤销此变量,释放资源。
每次创建一个协程时,可以将原有的上下文传递给这个子协程,或者新创建一个子上下文传递给这个协程。上下文能灵活地存储不同类型、不同数目的值,并且使多个协程安全地读写其中的值。当通过父Context对象创建子上下文对象时,即可获得子上下文的一个取消函数,这样父上下文对象的创建环境就获得了对子上下文的撤销权。
在协程中,childCtx是preCtx的子context,其设置的超时时间为300ms。但是preCtx的超时时间为100 ms,因此父context退出后,子context会立即退出,实际的等待时间只有100ms。
三、context应用
在平时协程控制当中,我们常见应用采用通道、上下文以及sync包,通过这三者,完全可以达到完美控制协程运行的目的。
- 使用sync.WaitGroup,它用于线程总同步,会等待一组线程集合完成,才会继续向下执行,这对监控所有子协程全部完成的情况特别有用,但要控制某个协程就无能为力了。
- 使用通道来传递消息,一个协程发送通道信号,另一个协程通过select得到通道信息,这种方式可以满足协程之间的通信,控制协程运行。但如果协程数量达到一定程度,就很难把控了。或者这两个协程还和其他协程也有类似通信,例如A与B,B与C,如果A发信号B退出了,C有可能等不到B的通道信号而被遗忘。
- 使用上下文来传递消息,上下文是层层传递机制,根节点完全控制了子节点,根节点(父节点)可以根据需要选择自动还是手动结束子节点。而每层节点所在的协程就可以根据信息来决定下一步的操作。
这里主要介绍使用上下文控制协程的运行,两个协程都可以收到cancel()发出的信号,B方法不结束协程可反复接收取消信息。
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
)
func A(ctx context.Context) string {
ctx = context.WithValue(ctx, "funcA", "A")
go B(ctx)
// 监听上层的ctx
select {
case <-ctx.Done():
fmt.Println("A Done")
default:
log.Println("func A: default")
}
return "AA"
}
func B(ctx context.Context) string {
ctx = context.WithValue(ctx, "funcB", "B")
go C(ctx)
// 监听自己上层的ctx
select {
case <-ctx.Done():
fmt.Println("B Done")
return "B"
default:
log.Println("func B: default")
}
return "BB"
}
func C(ctx context.Context) string {
ctx = context.WithValue(ctx, "funcCA", ctx.Value("funcA"))
ctx = context.WithValue(ctx, "funcCB", ctx.Value("funcB"))
// 监听自己上层的ctx
select {
case <-ctx.Done():
fmt.Println("C Done")
return "C"
default:
log.Println("func C: default")
}
return "CC"
}
执行结果如下:
下面代码用上下文嵌套控制3个协程A,B,C。在主程序发出cancel信号后,每个协程都能接收根上下文的Done()信号而退出。
package main
import (
"context"
"fmt"
"log"
"os"
"os/signal"
"syscall"
"time"
)
func A(ctx context.Context) string {
ctx = context.WithValue(ctx, "funcA", "A")
go B(ctx)
// 监听上层的ctx
select {
case <-ctx.Done():
fmt.Println("A Done")
default:
log.Println("func A: default")
}
return "AA"
}
func B(ctx context.Context) string {
ctx = context.WithValue(ctx, "funcB", "B")
go C(ctx)
// 监听自己上层的ctx
select {
case <-ctx.Done():
fmt.Println("B Done")
return "B"
default:
log.Println("func B: default")
}
return "BB"
}
func C(ctx context.Context) string {
ctx = context.WithValue(ctx, "funcCA", ctx.Value("funcA"))
ctx = context.WithValue(ctx, "funcCB", ctx.Value("funcB"))
// 监听自己上层的ctx
select {
case <-ctx.Done():
fmt.Println("C Done")
return "C"
default:
log.Println("func C: default")
}
return "CC"
}
func main() {
// 新建一个ctx
timeout := time.Second * 5
ctx, _ := context.WithTimeout(context.Background(), timeout)
log.Println("funcA 执行完成,返回:", A(ctx))
select {
case <-ctx.Done():
log.Println("context Done")
break
}
// 监听系统退出信号
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
}
执行结果如下(打印结果不一定是这样顺序输出):