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间传递

参考:

posted @ 2022-01-14 10:59  西*风  阅读(632)  评论(0编辑  收藏  举报