深度解密Go语言之context

什么是context

  • context中文译作“上下文”,准确的说它是goroutine的上下文,包含goroutine的运行状态,环境,现场等信息等
  • context主要用来在goroutine之间传递上下文信息,包括:取消信号,超时时间,截止时间,K-V等
  • 随着context包的引入,标准库中很多接口因此都加上了context参数,列如database/sql包。context几乎成为了并发控制和超时控制的标准做法。

context.Context 类型的值可以协调多个goroutine中的代码执行“取消”操作,并且可以存储键值对。最重要的是它是并发安全的。

与它协作的API都可以有外部控制执行“取消”操作,例如:取消一个HTTP请求的执行

为什么有context

  • Go常用来写后台服务,通常只需要几行代码,就可以搭建一个http server
  • 在Go的server里,通常每来一个请求都会启动若干个goroutine同时工作:有些去数据库拿数据,有些调用下游接口获取相关数据…
    在这里插入图片描述

这些goroutine需要共享这个请求的基本数据。列如登陆的token,处理请求最大的超时时间(如果超过此值再返回数据,请求因为超时接收不到)等等。当请求被取消或是处理时间太长,这可能是使用者关闭了浏览器或是已经超过了请求方规定的超时时间,请求方直接放弃这次请求结果,这时,所有正在为这个请求工作的goroutine需要快速退出,因为它们的“工作成果”不再被需要。在相关联的goroutine都退出后,系统就可以回收相关的资源。

再多说一点,Go 语言中的 server 实际上是一个“协程模型”,也就是说一个协程处理一个请求。例如在业务的高峰期,某个下游服务的响应变慢,而当前系统的请求又没有超时控制,或者超时时间设置地过大,那么等待下游服务返回数据的协程就会越来越多。而我们知道,协程是要消耗系统资源的,后果就是协程数激增,内存占用飙涨,甚至导致服务不可用。更严重的会导致雪崩效应,整个服务对外表现为不可用,这肯定是 P0 级别的事故。这时,肯定有人要背锅了。

其实前面描述的 P0 级别事故,通过设置“允许下游最长处理时间”就可以避免。例如,给下游设置的 timeout 是 50 ms,如果超过这个值还没有接收到返回数据,就直接向客户端返回一个默认值或者错误。例如,返回商品的一个默认库存数量。注意,这里设置的超时时间(并发超时)和创建一个 http client(网络连接超时) 设置的读写超时时间不一样,这里不详细展开。可以去看看参考资料 【Go在今日头条的实践】一文,有很精彩的论述。

context 包就是为了解决上面所说的这些问题而开发的:在 一组 goroutine 之间传递共享的值、取消信号、deadline(截止日期)……
在这里插入图片描述

用简练一些的话来说,在Go 里,我们不能直接杀死协程,协程的关闭一般会用 channel+select 方式来控制。但是在某些场景下,例如处理一个请求衍生了很多协程,这些协程之间是相互关联的:需要共享一些全局变量、有共同的 deadline 等,而且可以同时被关闭。再用 channel+select 就会比较麻烦,这时就可以通过 context 来实现。

一句话:context用来解决goroutine之间退出通知元数据传递的功能

context 底层实现原理:

整体概括:

在这里插入图片描述

类型名称作用
Context接口定义了context接口的四个方法
emptyCtxint实现了Context接口,它其实是个空的context
CancelFunc函数取消函数
canceler接口context取消接口,定义了两个方法
cancelCtx结构体可以被取消
cancelCtx结构体超时会取消
valueCtx结构体可以存储k-v对
Background函数返回一个非nil空的context,它永远不会被取消,没有值,也没有截止日期。它通常用于主函数,初始化和测试,并作为传入请求的顶级(父级)context
TODO函数返回一个空的context,常用于重构时期,没有合适的context可用
WithCancel函数基于父 context,生成一个可以取消的 context
newCancelCtx函数创建一个可取消的context
propagateCancel函数向下传递 context 节点间的取消关系
parentCancelCtx函数找到第一个可取消的父节点
removeChild函数去掉父节点的孩子节点
init函数初始化
WithDeadline函数创建一个有deadline 的context
WithTimeout函数创建一个有timeout的context
withValue函数创建一个存储k-v对的context
整体类图如下:

在这里插入图片描述

接口

context
  • Context是一个接口,定义了四个方法,它们都是幂等性的。也就是说连续多次调用一个方法,得到的结果都是相同的
    现在可以直接看源码:
type Context interface{
//当context被取消或者到了 deadline,返回一个被关闭的channel
Done() <-chan struct{}
//在channel Done 关闭后 返回 context 取消原因
Err() error
//返回context是否会被取消以及自动取消时间(即deadline)
Deadline() (deadline time.Time, ok bool)
//获取key对应的value
Value(key interface{})interface{}
}

Done()返回一个channel,可以表示context被取消的信号:当这个channel被关闭时,说明context被取消了。注意,这是一个只读的channel。我们知道读一个关闭的channel会读出相应类型的零值。并且源码里没有地方会向这个channel里面塞值。换句话说这是一个 receive-only 的 channel。因此在子协程里读这个channel,除非被关闭,否则读不出来任何东西。也正是利用这一点,子协程从channel里读出了值(零值)后,就可以做一些收尾工作,尽快退出。

Err()返回一个错误,表示channel被关闭的原因。例如被取消,还是超时。

Deadline()返回context的截止时间,通过此时间,函数就可以决定是否进行接下来的操作,如果时间太短,就可以不往下做了,否则浪费系统资源,当然,也可以用这个deadline来设置一个I/O超时时间。

Value()获取之前设置的key对应的value

canceler

再来看另一个接口:

type canceler interface {
	cancel(removeFromParent bool, err error)
	Done() <-chan struct{}
}

实现了上面定义的两个方法的 Context,就表明该 Context 是可取消的。源码中有两个类型实现了 canceler 接口:*cancelCtx 和 *timerCtx。注意是加了 * 号的,是这两个结构体的指针实现了 canceler 接口。

Context 接口设计成这个样子的原因:
  • 取消操作应该是建议性,而非强制性:
    -caller(调用方) 不应该去关心、干涉 callee(被调用函数) 的情况,决定如何以及何时 return 是 callee(被调用函数) 的责任。caller(调用方) 只需发送“取消”信息,callee(被调用函数) 根据收到的信息来做进一步的决策,因此接口并没有定义 cancel 方法。
  • 取消”操作应该可传递:
    “取消” 某个函数时,和它相关联的其他函数也应该被“取消”。因此,Done()方法返回一个只读的channel,所有相关函数监听此channel。一旦channel关闭,通过channel的“广播机制”,所有的监听者都能收到。

结构体:

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
}

看这段源码,非常 happy。因为每个函数都实现的异常简单,要么是直接返回,要么是返回 nil。

所以,这实际上是一个空的 context,永远不会被 cancel,没有存储值,也没有 deadline。

它被包装成:

var (
	background = new(emptyCtx)
	todo       = new(emptyCtx)
)

通过下面两个导出的函数(首字母大写)对外公开:

func Background() Context {
	return background
}

func TODO() Context {
	return todo
}

background 通常用在 main 函数中,作为所有 context 的根节点。

todo 通常用在并不知道传递什么 context的情形。例如,调用一个需要传递 context 参数的函数,你手头并没有其他 context 可以传递,这时就可以传递 todo。这常常发生在重构进行中,给一些函数添加了一个 Context 参数,但不知道要传什么,就用 todo “占个位置”,最终要换成其他 context。

cancelCtx

type cancelCtx struct {
	Context

	mu       sync.Mutex            // protects following fields
	done     chan struct{}         // created lazily, closed by first cancel call
	children map[canceler]struct{} // set to nil by the first cancel call
	err      error                 // set to non-nil by the first cancel call
}

这是一个可以取消的 Context,实现了 canceler 接口。它直接将接口 Context 作为它的一个匿名字段,这样,它就可以被看成一个 Context。

先来看 Done() 方法的实现:

func (c *cancelCtx) Done() <-chan struct{} {
	c.mu.Lock()
	if c.done == nil {
		c.done = make(chan struct{})
	}
	d := c.done
	c.mu.Unlock()
	return d
}

c.done 是“懒汉式”创建,只有调用了 Done() 方法的时候才会被创建。再次说明,函数返回的是一个只读的 channel,而且没有地方向这个 channel 里面写数据。所以,直接调用读这个 channel,协程会被 block 住。一般通过搭配 select 来使用。一旦关闭,就会立即读出零值。

Err() 和 String() 方法比较简单,不多说。推荐看源码,非常简单。
接下来,我们重点关注 cancel() 方法的实现:

func (c *cancelCtx) cancel(removeFromParent bool, err error) {
//必须传err
	if err == nil {
		panic("context: internal error: missing cancel error")
	}
	c.mu.Lock()
	if c.err != nil {
		c.mu.Unlock()
		return // 已经被其其它协程取消
	}
	//给err字段赋值
	c.err = err
	//关闭channel,通知其他协程
	if c.done == nil {
		c.done = closedchan
	} else {
		close(c.done)
	}
	//遍历所有子节点
	for child := range c.children {
		// NOTE: acquiring the child's lock while holding parent's lock.
		//递归地取消所有子节点
		child.cancel(false, err)
	}
	//将子节点置为空
	c.children = nil
	c.mu.Unlock()

	if removeFromParent {
	//从父节点中移除自己
		removeChild(c.Context, c)
	}
}

总体来看,concel()方法的功能就是关闭channel:c.done;递归地取消它的所有子节点;从父节点删除自己。达到的效果是通过关闭channel,将取消信号传递给了它的所有子节点。goroutine接收到取消信号的方式就是select语句中的读c.done被选中

我们再来看创建一个可取消的 Context 的方法:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	c := newCancelCtx(parent)
	propagateCancel(parent, &c)
	return &c, func() { c.cancel(true, Canceled) }
}

// newCancelCtx returns an initialized cancelCtx.
func newCancelCtx(parent Context) cancelCtx {
	return cancelCtx{Context: parent}
}

这是一个暴露给用户的方法,传入一个父 Context(这通常是一个 background,作为根节点),返回新建的 context,新 contextdone channel 是新建的(前文讲过)。

当 WithCancel 函数返回的 CancelFunc 被调用或者是父节点的 done channel 被关闭(父节点的 CancelFunc 被调用),此 context(子节点) 的 done channel 也会被关闭。

注意传给 WithCancel 方法的参数,前者是 true,也就是说取消的时候,需要将自己从父节点里删除。第二个参数则是一个固定的取消错误类型:

var Canceled= errors.New("context canceled")

以上省略了好大一部分的讲解~~
因为看不懂,后续再来补充~
原文链接

timerCtx

timerCtx 基于 cancelCtx,只是多了一个 time.Timer 和一个 deadline。Timer 会在 deadline 到来时,自动取消 context。

type timerCtx struct {
	cancelCtx
	timer *time.Timer // Under cancelCtx.mu.
	deadline time.Time
}

timerCtx 首先是一个 cancelCtx,所以它能取消。看下 cancel() 方法:

func (c *timerCtx) cancel(removeFromParent bool, err error) {
	//直接调用cancelCtx的取消方法
	c.cancelCtx.cancel(false, err)
	if removeFromParent {
		// 从父节点中删除子节点
		removeChild(c.cancelCtx.Context, c)
	}
	c.mu.Lock()
	if c.timer != nil {
	//关掉定时器,这样,在deadline 到来时 不会再次取消
		c.timer.Stop()
		c.timer = nil
	}
	c.mu.Unlock()
}

创建 timerCtx 的方法:

func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
	return WithDeadline(parent, time.Now().Add(timeout))
}

WithTimeout 函数直接调用了 WithDeadline,传入的 deadline 是当前时间加上 timeout 的时间,也就是从现在开始再经过 timeout 时间就算超时。也就是说, WithDeadline 需要用的是绝对时间。重点来看它:

func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	if cur, ok := parent.Deadline(); ok && cur.Before(d) {
		// The current deadline is already sooner than the new one.
		//如果父节点context的deadline早于指定时间。直接构建一个可取消的context
		//原因是一旦父节点超时,自动调用cancel函数,子节点也会随之取消。
		// 所以不用单独处理子节点的计时器到了之后,自动调用cancel函数
		return WithCancel(parent)
	}
	// 构建 timerCtx
	c := &timerCtx{
		cancelCtx: newCancelCtx(parent),
		deadline:  d,
	}
	//挂靠在父节点上
	propagateCancel(parent, c)
	//计算当前距离 deadline 的时间
	dur := time.Until(d)
	if dur <= 0 {
	//直接取消
		c.cancel(true, DeadlineExceeded) // deadline has already passed
		return c, func() { c.cancel(false, Canceled) }
	}
	c.mu.Lock()
	defer c.mu.Unlock()
	if c.err == nil {
	//d时间后 timer会自动调用 cancel函数。自动取消
		c.timer = time.AfterFunc(dur, func() {
			c.cancel(true, DeadlineExceeded)
		})
	}
	return c, func() { c.cancel(true, Canceled) }
}

也就是说仍然要把子节点挂靠到父节点,一旦父节点取消了,会把取消信号向下传递到子节点,子节点随之取消。

有一个特殊情况是,如果要创建的这个子节点的 deadline 比父节点要晚,也就是说如果父节点是时间到自动取消,那么一定会取消这个子节点,导致子节点的 deadline 根本不起作用,因为子节点在 deadline 到来之前就已经被父节点取消了。

valueCtx

type valueCtx struct {
	Context
	key, val interface{}
}

它实现了两个方法:

func (c *valueCtx) String() string {
	return contextName(c.Context) + ".WithValue(type " +
		reflectlite.TypeOf(c.key).String() +
		", val " + stringify(c.val) + ")"
}

func (c *valueCtx) Value(key interface{}) interface{} {
	if c.key == key {
		return c.val
	}
	return c.Context.Value(key)
}

由于它直接将Context作为匿名字段,因此只管它实现了2个方法,其方法继承自父context。但它仍然是一个context,这是Go语言的一个特点

创建 valueCtx 的函数:

func WithValue(parent Context, key, val interface{}) Context {
	if parent == nil {
		panic("cannot create context from nil parent")
	}
	if key == nil {
		panic("nil key")
	}
	if !reflectlite.TypeOf(key).Comparable() {
		panic("key is not comparable")
	}
	return &valueCtx{parent, key, val}
}

对 key 的要求是可比较,因为之后需要通过 key 取出 context 中的值,可比较是必须的。

通过层层传递 context,最终形成这样一棵树:
在这里插入图片描述
和链表有点像,只是它的方向相反:Context 指向它的父节点,链表则指向下一个节点。通过 WithValue 函数,可以创建层层的 valueCtx,存储 goroutine 间可以共享的变量。

取值的过程,实际上是一个递归查找的过程:

func (c *valueCtx) Value(key interface{}) interface{} {
	if c.key == key {
		return c.val
	}
	return c.Context.Value(key)
}

它会顺着链路一直往上找,比较当前节点的 key是否是要找的 key,如果是,则直接返回 value。否则,一直顺着 context 往前,最终找到根节点(一般是 emptyCtx),直接返回一个 nil。所以用 Value 方法的时候要判断结果是否为 nil。

WithValue 创建 context 节点的过程实际上就是创建链表节点的过程。两个节点的 key 值是可以相等的,但它们是两个不同的 context 节点。查找的时候,会向上查找到最后一个挂载的 context 节点,也就是离得比较近的一个父节点 context。所以,整体上而言,用 WithValue 构造的其实是一个低效率的链表。

如何使用context:

context 使用起来非常方便。源码里对外提供了一个创建根节点 context 的函数:

func Background() Context {
	return background
}

background 是一个空的context ,它不能被取消,没有值,也没有超时时间。

有了根节点 context,又提供了四个函数创建子节点 context:

func WithCancel(parent Context)(ctx Context,cancel CancelFunc)
func WithDeadline(parent Context,deadline time.Time) (Context,CancelFunc)
func WithTimeout(parent Context,timeout time.Duration)(Contex,CancelFunc)
func WithValue(parent Context,key,val interface{})Context

context会在函数传递间传递。只需要在适当的时间调用cancel函数向goroutine发出取消信号或者调用Value函数取出context中的值

在官方博客里,对于使用 context 提出了几点建议:

  • 不要将 Context 塞到结构体里。直接将 Context 类型作为函数的第一参数,而且一般都命名为 ctx。
  • 不要向函数传入一个 nil 的 context,如果你实在不知道传什么,标准库给你准备好了一个 context:todo。
  • 不要把本应该作为函数参数的类型塞到 context 中,context 存储的应该是一些共同的数据。例如:登陆的 session、cookie 等。
  • 同一个 context 可能会被传递到多个 goroutine,别担心,context 是并发安全的(当取消一个goroutine,其他的goroutine也会跟着取消掉)。

使用场景

  • 并发多服务调用情况下,比如一个请求进来,启动3个goroutine进行 RpcA 、RpcB 、RpcC三个服务的调用。这时候只要有其中一个服务错误,就返回错误,同时取消另外两个Rpc服务。可以通过 WithCancel 方法来实现。
  • 超时请求,比如对Http、Rpc进行超时限制,可以通过 WithDeadline 和 WithTimeout 来实现。
  • 携带数据,比如一个请求的用户信息,一般业务场景,我们会有一个专门的中间件来校验用户信息,然后把用户信息注入到context中,或者共享给派生出来的多个goroutine使用,可以通过 WithValue 方法实现。
    原文链接
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值