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

    
    
  1. func main() {
  2. stop := make( chan bool)
  3. go func() {
  4. for {
  5. select {
  6. case <-stop:
  7. fmt.Println( "监控退出,停止了...")
  8. return
  9. default:
  10. fmt.Println( "goroutine监控中...")
  11. time.Sleep( 2 * time.Second)
  12. }
  13. }
  14. }()
  15. time.Sleep( 10 * time.Second)
  16. fmt.Println( "可以了,通知监控停止")
  17. stop<- true
  18. //为了检测监控过是否停止,如果没有监控输出,就表示停止了
  19. time.Sleep( 5 * time.Second)
  20. }

采用channle + select 这种方式来实现主动通知,有两个致命的缺点:

  • 只能通知一个groutine结束,无法应对很多goroutine都需要结束的情况
  • 无法应对goroutine又衍生出其他更多的goroutine的情况

上述这两种场景其实在业务中非常的常见

  • 场景1:比如一个网络请求Request,每个Request都需要开启一个goroutine做一些事情,这些goroutine又可能会开启其他的goroutine,具体表现在Go的Server中,通常每一个请求都会启动若干个goroutine同时工作,有些去数据库拿数据,有些调用下游接口获取相关数据,这些goroutine需要共享这个请求的基本数据,例如登录token,处理请求的最大超时时间等等,当请求被取消或是处理时间太长,这时,所有正在为这个请求工作的goroutine都需要快速退出,因为他们的工作成果不再被需要了

为应对上述场景,并且使得goroutine是可追踪的,context应运而生

三.Context 如何使用?

1.context控制多个goroutine


    
    
  1. func main() {
  2. ctx, cancel := context.WithCancel(context.Background())
  3. go watch(ctx, "【监控1】")
  4. go watch(ctx, "【监控2】")
  5. go watch(ctx, "【监控3】")
  6. time.Sleep( 10 * time.Second)
  7. fmt.Println( "可以了,通知监控停止")
  8. cancel()
  9. //为了检测监控过是否停止,如果没有监控输出,就表示停止了
  10. time.Sleep( 5 * time.Second)
  11. }
  12. func watch(ctx context.Context, name string) {
  13. for {
  14. select {
  15. case <-ctx.Done():
  16. fmt.Println(name, "监控退出,停止了...")
  17. return
  18. default:
  19. fmt.Println(name, "goroutine监控中...")
  20. time.Sleep( 2 * time.Second)
  21. }
  22. }
  23. }

上述样例,context控制了三个goroutine,当context cancle之后,这三个goroutine便都退出了

2.传递共享数据


    
    
  1. package main
  2. import (
  3. "context"
  4. "fmt"
  5. )
  6. func main() {
  7. ctx := context.Background()
  8. process(ctx)
  9. ctx = context.WithValue(ctx, "traceId", "qcrao-2019")
  10. process(ctx)
  11. }
  12. func process(ctx context.Context) {
  13. traceId, ok := ctx.Value( "traceId").( string)
  14. if ok {
  15. fmt.Printf( "process over. trace_id=%s\n", traceId)
  16. } else {
  17. fmt.Printf( "process over. no trace_id\n")
  18. }
  19. }

3.取消goroutine,防止goroutine泄露


    
    
  1. func gen(ctx context.Context) <- chan int {
  2. ch := make( chan int)
  3. go func() {
  4. var n int
  5. for {
  6. select {
  7. case <-ctx.Done():
  8. return
  9. case ch <- n:
  10. n++
  11. time.Sleep(time.Second)
  12. }
  13. }
  14. }()
  15. return ch
  16. }
  17. func main() {
  18. ctx, cancel := context.WithCancel(context.Background())
  19. defer cancel() // 避免其他地方忘记 cancel,且重复调用不影响
  20. for n := range gen(ctx) {
  21. fmt.Println(n)
  22. if n == 5 {
  23. cancel()
  24. break
  25. }
  26. }
  27. // ……
  28. }

如果只需要五个整数,在n==5时,直接break了没有cancle,那么就会存在goroutine泄露的问题!

四.Context 底层原理解析

1.Context的接口分析和实现

1,接口分析

    
    
  1. type Context interface {
  2. Deadline() (deadline time.Time, ok bool)
  3. Done() <- chan struct{}
  4. Err() error
  5. Value(key interface{}) interface{}
  6. }
  • Deadline() : 获取设置的截止时间,第一个返回值是截止时间,到底这个时间点,context会自动发起取消请求,第二个返回值ok==false时表示没有设置截止时间,如果需要取消的话需要调用函数cancle进行取消
  • Done() : 返回一个只读的chan,类型为struct{},我们在goroutine中,如果此方法返回的chan可读,则意味着parent.context已发起取消请求,我们通过Done方法收到这个信号后,就应该做清理操作,然后退出goroutine,释放资源
  • Err() : 返回取消的错误原因,即因为什么context被取消
  • Value(key) : 返回该context上绑定的值,是kv键值对,线程安全

它们都是幂等的。也就是说连续多次调用同一个方法,得到的结果都是相同的。

经典用法如下:


    
    
  1. func Stream(ctx context.Context, out chan<- Value) error {
  2. for {
  3. v, err := DoSomething(ctx)
  4. if err != nil {
  5. return err
  6. }
  7. select {
  8. case <-ctx.Done():
  9. return ctx.Err()
  10. case out <- v:
  11. }
  12. }
  13. }
2.接口实现

context根据其父子context关系,可以抽象成一颗树,节点就是context

context接口并不需要我们实现,GO已经内置了两个了,可以使用这两个做完最顶层的父context,从而衍生出更多的子context

内置的根context:

  • background : 主要用于main函数,初始化以及测试代码中,作为context这个树结构的最顶层根context
  • todo : 目前还不知道具体的使用场景,当你也不知道应该使用什么context的时候,可以使用这个

    
    
  1. var (
  2. background = new(emptyCtx)
  3. todo = new(emptyCtx)
  4. )
  5. func Background() Context {
  6. return background
  7. }
  8. func TODO() Context {
  9. return todo
  10. }

background和todo二者的本质都是emptyCtx结构

  • emptyCtx:一个不可取消,没有设置截止时间,没有携带任何值的Context

    
    
  1. type emptyCtx int
  2. func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
  3. return
  4. }
  5. func (*emptyCtx) Done() <- chan struct{} {
  6. return nil
  7. }
  8. func (*emptyCtx) Err() error {
  9. return nil
  10. }
  11. func (*emptyCtx) Value(key interface{}) interface{} {
  12. return nil
  13. }

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系列函数了


    
    
  1. func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
  2. func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
  3. func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
  4. 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键值对的值

    
    
  1. var key string= "name"
  2. func main() {
  3. ctx, cancel := context.WithCancel(context.Background())
  4. //附加值
  5. valueCtx:=context.WithValue(ctx,key, "【监控1】")
  6. go watch(valueCtx)
  7. time.Sleep( 10 * time.Second)
  8. fmt.Println( "可以了,通知监控停止")
  9. cancel()
  10. //为了检测监控过是否停止,如果没有监控输出,就表示停止了
  11. time.Sleep( 5 * time.Second)
  12. }
  13. func watch(ctx context.Context) {
  14. for {
  15. select {
  16. case <-ctx.Done():
  17. //取出值
  18. fmt.Println(ctx.Value(key), "监控退出,停止了...")
  19. return
  20. default:
  21. //取出值
  22. fmt.Println(ctx.Value(key), "goroutine监控中...")
  23. time.Sleep( 2 * time.Second)
  24. }
  25. }
  26. }

在上面的样例中,我们生成了一个新的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间传递

参考:

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值