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间传递
参考: