Golang-Context扫盲与原理解析
Golang-Context扫盲与原理解析
一.什么是Context?
- context是一个包,是Go1.7引入的标注库,中文译做上下文,准确的说是goroutine的上下文,包含goroutine的运行状态,环境,现场等信息。
- context主要用于在goroutine之间传递上下文信息,比如取消信号,超时时间,截止时间,kv等。
二.为什么要有Context?
在Go中,控制并发有两种经典的方式,一个是WaitGroup,另外一个就是context
- WaitGroup:控制多个groutine同时完成,这是等待的方式,等那些必要的goroutine都工作完了我才能工作
- Context:主动通知某一个groutine结束,这是主动通知的方式,通知某些groutine你不要再工作了
其实主动通知的方式,除了context,还有一种方式也可以实现
- channle + select
func main() {
stop := make(chan bool)
go func() {
for {
select {
case <-stop:
fmt.Println("监控退出,停止了...")
return
default:
fmt.Println("goroutine监控中...")
time.Sleep(2 * time.Second)
}
}
}()
time.Sleep(10 * time.Second)
fmt.Println("可以了,通知监控停止")
stop<- true
//为了检测监控过是否停止,如果没有监控输出,就表示停止了
time.Sleep(5 * time.Second)
}
采用channle + select 这种方式来实现主动通知,有两个致命的缺点:
- 只能通知一个groutine结束,无法应对很多goroutine都需要结束的情况
- 无法应对goroutine又衍生出其他更多的goroutine的情况
上述这两种场景其实在业务中非常的常见
- 场景1:比如一个网络请求Request,每个Request都需要开启一个goroutine做一些事情,这些goroutine又可能会开启其他的goroutine,具体表现在Go的Server中,通常每一个请求都会启动若干个goroutine同时工作,有些去数据库拿数据,有些调用下游接口获取相关数据,这些goroutine需要共享这个请求的基本数据,例如登录token,处理请求的最大超时时间等等,当请求被取消或是处理时间太长,这时,所有正在为这个请求工作的goroutine都需要快速退出,因为他们的工作成果不再被需要了
为应对上述场景,并且使得goroutine是可追踪的,context应运而生
三.Context 如何使用?
1.context控制多个goroutine
func main() {
ctx, cancel := context.WithCancel(context.Background())
go watch(ctx,"【监控1】")
go watch(ctx,"【监控2】")
go watch(ctx,"【监控3】")
time.Sleep(10 * time.Second)
fmt.Println("可以了,通知监控停止")
cancel()
//为了检测监控过是否停止,如果没有监控输出,就表示停止了
time.Sleep(5 * time.Second)
}
func watch(ctx context.Context, name string) {
for {
select {
case <-ctx.Done():
fmt.Println(name,"监控退出,停止了...")
return
default:
fmt.Println(name,"goroutine监控中...")
time.Sleep(2 * time.Second)
}
}
}
上述样例,context控制了三个goroutine,当context cancle之后,这三个goroutine便都退出了
2.传递共享数据
package main
import (
"context"
"fmt"
)
func main() {
ctx := context.Background()
process(ctx)
ctx = context.WithValue(ctx, "traceId", "qcrao-2019")
process(ctx)
}
func process(ctx context.Context) {
traceId, ok := ctx.Value("traceId").(string)
if ok {
fmt.Printf("process over. trace_id=%s\n", traceId)
} else {
fmt.Printf("process over. no trace_id\n")
}
}
3.取消goroutine,防止goroutine泄露
func gen(ctx context.Context) <-chan int {
ch := make(chan int)
go func() {
var n int
for {
select {
case <-ctx.Done():
return
case ch <- n:
n++
time.Sleep(time.Second)
}
}
}()
return ch
}
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // 避免其他地方忘记 cancel,且重复调用不影响
for n := range gen(ctx) {
fmt.Println(n)
if n == 5 {
cancel()
break
}
}
// ……
}
如果只需要五个整数,在n==5时,直接break了没有cancle,那么就会存在goroutine泄露的问题!
四.Context 底层原理解析
1.Context的接口分析和实现
1,接口分析
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key interface{}) interface{}
}
- Deadline() : 获取设置的截止时间,第一个返回值是截止时间,到底这个时间点,context会自动发起取消请求,第二个返回值ok==false时表示没有设置截止时间,如果需要取消的话需要调用函数cancle进行取消
- Done() : 返回一个只读的chan,类型为struct{},我们在goroutine中,如果此方法返回的chan可读,则意味着parent.context已发起取消请求,我们通过Done方法收到这个信号后,就应该做清理操作,然后退出goroutine,释放资源
- Err() : 返回取消的错误原因,即因为什么context被取消
- Value(key) : 返回该context上绑定的值,是kv键值对,线程安全
它们都是幂等的。也就是说连续多次调用同一个方法,得到的结果都是相同的。
经典用法如下:
func Stream(ctx context.Context, out chan<- Value) error {
for {
v, err := DoSomething(ctx)
if err != nil {
return err
}
select {
case <-ctx.Done():
return ctx.Err()
case out <- v:
}
}
}
2.接口实现
context根据其父子context关系,可以抽象成一颗树,节点就是context
context接口并不需要我们实现,GO已经内置了两个了,可以使用这两个做完最顶层的父context,从而衍生出更多的子context
内置的根context:
- background : 主要用于main函数,初始化以及测试代码中,作为context这个树结构的最顶层根context
- todo : 目前还不知道具体的使用场景,当你也不知道应该使用什么context的时候,可以使用这个
var (
background = new(emptyCtx)
todo = new(emptyCtx)
)
func Background() Context {
return background
}
func TODO() Context {
return todo
}
background和todo二者的本质都是emptyCtx结构
- emptyCtx:一个不可取消,没有设置截止时间,没有携带任何值的Context
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
}
2.Context接口和类型间的关系
类图如下:
图来自此:https://blog.csdn.net/kevin_tech/article/details/119901843
通过上面的类图,我们可以获取以下信息:
- 除了Context接口外还定义了一个叫做canceler的接口,带取消功能的Context canclerCtx便是实现了这个接口
- emptyCtx 什么属性也没有,啥也不能干
- valueCtx 只能携带一个键值对,且自身要已付在上一级的Context上
- timerCtx 继承自canclerCtx 他们都是带取消功能的Context
- 除了emptyCtx,其他类型的Context都依附在上级Context上
3.Context的继承衍生
有了根Context,那么如何衍生出更多的子Context呢?这个就要靠Context包为我们提供的With系列函数了
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
这四个with系列的函数,都有一个parent参数,也就是父Context,我们要基于这个父Context创建出子Context,可以理解为子Context对父Context的继承,也可以理解为基于父Context的衍生。这四个with系列的函数,只是创建子Context的条件不同而已
通过这四个函数,我们就可以创建出一颗Context树,树的每个节点都可以有多个任意的子节点,节点的层级可以有任意多个
-
WithCancle : 传入一个父Context,返回一个子Context以及一个取消函数(用来取消Context)
-
WithDeadline : 传入一个父Context和一个截止时间,同样返回一个子Context和一个取消函数,意味着到了这个截止时间,会自动取消Context,当然我们也可以通过取消函数提前进行取消
-
WithTimeout : 和WithDeadline差不多,表示超时自动取消,是多少时间后自动取消Context的意思
-
WithValue : 和取消Context无关,它是为了生成绑定了一个键值对数据的Context,这个数据可通过Context.value访问
可以注意到上述几个函数都会返回一个取消函数,CancelFunc
- CancelFunc: 取消一个Context,以及这个Context节点下的所有子Context,不管有多少层,不管有多少数量
4.Context的数据传递与使用
- 我们通过context.WithValue函数生成一个context,通过.Value函数获取Context键值对的值
var key string="name"
func main() {
ctx, cancel := context.WithCancel(context.Background())
//附加值
valueCtx:=context.WithValue(ctx,key,"【监控1】")
go watch(valueCtx)
time.Sleep(10 * time.Second)
fmt.Println("可以了,通知监控停止")
cancel()
//为了检测监控过是否停止,如果没有监控输出,就表示停止了
time.Sleep(5 * time.Second)
}
func watch(ctx context.Context) {
for {
select {
case <-ctx.Done():
//取出值
fmt.Println(ctx.Value(key),"监控退出,停止了...")
return
default:
//取出值
fmt.Println(ctx.Value(key),"goroutine监控中...")
time.Sleep(2 * time.Second)
}
}
}
在上面的样例中,我们生成了一个新的Context,这个新的Context带有这个键值对,在使用的时候,可以通过Value的方法读取,ctx.Value(key)
五.Context FQA
1.Context使用事项
在官方博客里,对于使用 context 提出了几点建议:
- Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx.
- Do not pass a nil Context, even if a function permits it. Pass context.TODO if you are unsure about which Context to use.
- Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.
- The same Context may be passed to functions running in different goroutines; Contexts are safe for simultaneous use by multiple goroutines.
翻译一下:
- 不要将Context塞到结构体里,直接将Context类型作为函数的第一参数,而且一般都命名为ctx
- 不要向函数传入一个nil的Context,如果你实在不知道传什么,标准库给你准备好了一个context:todo
- 不要把本应该作为函数参数的数据塞入到context中,context存储的应该是一些共同数据,比如登录的session,cookie等
- 同一个context可能会被传递到多个goroutine,别担心,context是并发安全的
2.到底有几类的Context?
- 类型一,emptyCtx,Context的源头
- 类型二,cancelCtx,cancle机制的灵魂
- 类型三,timerCtx,cancle机制场景的补充
- 类型四,valueCtx,传值需要
这几类的context组成了一颗context树!
3.context存储值的底层是一个Map吗?
- 不是
- 每一个KV映射都对应一个valueCtx,是一个个节点,当传递多个值时就要构建多个valueCtx,同时这也是context不能从底向上传递值的原因
- 在调用value获取键值对的值的时候,会首先在本context寻找对应key,如果没有找到则会在父context中递归寻找
4. Context 是如何实现数据共享的?
图来自:https://blog.csdn.net/kevin_tech/article/details/119901843
- 数据共享即:元数据在任务间的传递
- 其实现的Value方法能够在整个Context树链路上查找指定键的值,直到回溯到根Context,也就是emptyCtx,这也是emptyCtx什么功能也不提供的原因,因为他是作为根节点而存在的。
- 每次要在Context链路上增加携带的KV时,都要在上级Context的基础上新建一个ValueCtx存储KV,而且只能增加不能修改,读取KV也是一个幂等操作,所以Context就这样实现了并发安全的数据共享机制,并且全程无锁,不会影响性能
5. Context 是如何实现以下三点的?
- 上层任务取消后,所有的下层任务都会被取消
- 中间某一层的任务取消后,只会将当前任务的下层任务取消,而不会影响上层的任务已经同级任务
分析如下:
- 首先在 创建带取消功能的Context时还是要在父Context节点的基础上创建,从而保持整个Context链路的连续性,除此之外,还会在Context链路中找到上一个带取消功能的Context,把自己加入到他的children列表里,这样在整个Context链路中,除了父子Context之间有之间关联外,可取消的Context还会通过维护自身携带的Children属性建立与自己下级可取消的Context的关联,具体可参考下图
图来自:https://blog.csdn.net/kevin_tech/article/details/119901843
- 通过上图的这种设计,如果要在整个任务链路上取消某个canclerCtx时,就既能做到取消自己,也能通知下级CancelCtx进行取消,同时还不会影响到上级和同级的其他节点。
五.总结
context主要用于父子goroutine之间同步取消信号,本质上是一种协程的调度方式,另外有两点需要注意:
-
context的取消操作是无侵入的,上游任务仅仅使用context通知下游任务不再被需要,但不会直接干涉下游任务的执行,由下游任务自己决定后续的操作。
-
context是并发安全的,因为context本身是不可变的,可以放心在多个goroutine间传递
参考: