前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >Go Context解析 A Brief Inquiry Into Go Context

Go Context解析 A Brief Inquiry Into Go Context

原创
作者头像
takeonme.
修改2021-11-26 17:58:54
9253
修改2021-11-26 17:58:54
举报
文章被收录于专栏:tech with leo

什么是context

Package context defines the Context type, which carries deadlines,

cancellation signals, and other request-scoped values across API boundaries

and between processes.

在context的package中如此介绍context,很容易可以看出context的三个主要功能

  • 携带截止时间
  • 携带取消信号
  • 在携带请求相关的值

作用范围为api边界和进程之间

为什么需要context

从context的介绍中就可以看出context的主要是为了进行协程取消或者并发控制,传值为一额外功能。

在Go语言的wiki中如此介绍 Go is syntactically similar to C, but with memory safety, garbage collection, structural typing,and CSP-style concurrency. 最后一项处理并发也就是Go语言的一大特性,这也就能理解为什么Go需要context来作为并发控制中重要的一环。

众所周知,Go语言有四个进行并发控制的工具

  • 全局变量
  • channel
  • waitgroup
  • context

要理解为什么context也是其中不可或缺的一环,不妨可以提出一个疑问: 如果没有context的话,会怎么样呢?

代码语言:txt
复制
// withoutCancel 不取消协程
func withoutCancel() {
	go func() {
		go func() {
			defer fmt.Println("child dead")
			for {
				fmt.Println("child running...")
				time.Sleep(1 * time.Second)
			}
		}()
		for i := 0; i < 2; i++ {
			fmt.Println("father running...")
			time.Sleep(1 * time.Second)
		}
		defer fmt.Println("father dead")
	}()
	time.Sleep(5 * time.Second)
}
output:
father running...
child running...
child running...
father running...
father dead
child running...
child running...
child running...
child running...

很明显这边father协程在结束后,child协程仍然在运行中,且最后并没有进行正常退出,那么是否有什么替代品来进行协程取消呢?

全局变量?明显不可,如果服务每次请求的控制都由同一个全局变量来控制,那很容易就会发生阻塞。

waitgroup是被设计用来控制多协程同步的,似乎也不适合用来控制单个协程。

那只能用channel来控制单个协程了,试下呗

代码语言:txt
复制
// withChannel 使用通道取消协程
func withChannel() {
	go func() {
		c := make(chan bool)
		defer fmt.Println("father dead")
		go func() {
			defer fmt.Println("child dead")
			for {
				select {
				case <-c:
					return
				default:
					fmt.Println("child running...")
					time.Sleep(1 * time.Second)
				}
			}
		}()
		for i := 0; i < 2; i++ {
			time.Sleep(1 * time.Second)
			fmt.Println("father running...")
		}
		c <- true
	}()
	time.Sleep(5 * time.Second)
}
output:
child running...
father running...
child running...
child running...
father running...
child dead
father dead

这里看起来效果不错,father协程通过往channel中放入一个值来通知child协程结束。

如果有多个协程呢?往一个channel内塞多个值显然不可能,多个消费协程会进行争抢无法进行有效管理。如果是一个协程对应一个channel,新建多个channel的资源消耗不说,多个channel的管理与使用在协程数量变多后将会变得异常混乱复杂。而context可以很简单优雅的解决这一问题。

context源码及设计

Interface

先看下context的接口

代码语言:txt
复制
type Context interface {
    Deadline() (deadline time.Time, ok bool) // 返回context被取消的时间
    Done() <-chan struct{} // 返回一个channel,这个channel会在当前工作完成或者上下文被取消后关闭
    Err() error // 返回context结束的原因
    Value(key interface{}) interface{}
}

在context包中有4个重要的类分别实现了这个interface

  • emptyCtx
  • valueCtx
  • cancelCtx
  • timerCtx

这四个类分别实现了context的强大功能

emptyCtx

代码语言:txt
复制
type emptyCtx int

emptyCtx就是一个空的context,无法被取消,没有截止时间,没有值。

context.Background() 和 context.TODO()就是返回一个emptyCtx

valueCtx

代码语言:txt
复制
type valueCtx struct {
	Context
	key, val interface{}
}

valueCtx是一个用来携带key-value的context,可以看到一个valueCtx中只存了一个key和一个value,那么context是如何实现存储多个kv的呢,可以看下 func WithValue是怎么实现的

代码语言:txt
复制
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}
}

可以看出来每个valueCtx中还存有一个parentCtx,当context中需要存多个值时,实际上就是个valueCtx的linked list。context.Value()实际上就是通过遍历linked list来实现kv的查找的, 看下context.Value()的实现就可以很容易验证出来。

代码语言:txt
复制
func (c *valueCtx) Value(key interface{}) interface{} {
	if c.key == key {
		return c.val
	}
	return c.Context.Value(key)
}

cancelCtx

代码语言:txt
复制
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
}

cancelCtx主要用来实现context 取消的功能。和valueCtx一样,一个cancelCtx中包含一个parentCtx,说明cancelCtx也拥有继承关系,另外其中还包含children map,看起来是一个树状的结构,等会可以观察下如何使用children。以及一个channel来实现context.Done()中返回的channel,一个mutex保证并发安全,以及一个err来记录取消的原因。

WithCancel()function可以生成一个cancelCtx,看下是如何实现的

代码语言:txt
复制
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) }
}
func newCancelCtx(parent Context) cancelCtx {
	return cancelCtx{Context: parent}
}

可以看到和valueCtx一样,先把parentCtx存到cancelCtx中,再进行一个propagateCancel 传播取消的动作。看下propagateCancel是如何实现的

代码语言:txt
复制
func propagateCancel(parent Context, child canceler) {
	done := parent.Done()
	if done == nil {
		return // parent is never canceled
	}

	select {
	case <-done:
		// parent is already canceled
		child.cancel(false, parent.Err())
		return
	default:
	}

	p, ok := parentCancelCtx(parent) ok {
		p.mu.Lock()
		if p.err != nil {
			// parent has already been canceled
			child.cancel(false, p.err)
		} else {
			if p.children == nil {
				p.children = make(map[canceler]struct{})
			}
			p.children[child] = struct{}{}
		}
		p.mu.Unlock()
	} else {
		atomic.AddInt32(&goroutines, +1)
		go func() {
			select {
			case <-parent.Done():
				child.cancel(false, parent.Err())
			case <-child.Done():
			}
		}()
	}
}

前几步很简单,是对parent是否已经结束状态的一些检查。

p, ok := parentCancelCtx(parent)是从parent中一直往上寻找,看parent是不是也是继承自另外一个cancelCtx

如果parent确实是继承自一个cancelCtx,那么就把新的child也挂载到此cancelCtx的children map下。

如果没有,则新建一个协程监听parent是否取消,如果parent取消,child也会取消

可以看出来 cancelCtx其实是一个树状的结构,当parentCtx取消后,children map中的child ctx也都会进行取消的动作。

figma-cancelCtx.png
figma-cancelCtx.png

timerCtx

代码语言:txt
复制
type timerCtx struct {
	cancelCtx
	timer *time.Timer // Under cancelCtx.mu.

	deadline time.Time
}

了解了cancelCtx之后timerCtx就非常简单,一个timerCtx内包含了一个cancelCtx来执行取消的操作,一个计时器以及一个时间来记录结束时间

代码语言:txt
复制
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.
		return WithCancel(parent)
	}
	c := &timerCtx{
		cancelCtx: newCancelCtx(parent),
		deadline:  d,
	}
	propagateCancel(parent, c)
	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 {
		c.timer = time.AfterFunc(dur, func() {
			c.cancel(true, DeadlineExceeded)
		})
	}
	return c, func() { c.cancel(true, Canceled) }
}

创建一个timerCtx也很容易,可以看出大部分和创建一个cancelCtx几乎一样,只要再新建一个timer计时器来进行cancel的操作即可。

思考: context真的是个好设计吗?

至此相信你应该以及知道context是如何实现的了。

context一直是被认为一个小而美的设计,context包确实也以一种巧妙的方式实现了context的这些功能。但是任然存在一些值得斟酌的点,以下观点并非全部来自个人,仅供讨论

Context everywhere! Context在go代码中像病毒一样蔓延,即使是不需要的代码也需要传递context。

相信你曾经问过或者被问过一个问题,这个函数里context要传什么呢?回答:传context.TODO()就行了,没啥原因,传就行了。

正是因为context在go代码中到处蔓延,所以才会出现context.TODO()这种让人匪夷所思的东西

valueCtx是否有必要?为什么用一个链表实现了map?

在context包一开始就有介绍写到 Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions.

相信大家在readability考试中看到过把整个结构体塞到context中来传递的行为,但是如何定义哪些数据可以放到context中呢,哪些又不可以呢?我们现在的各种日志插件,error插件,已经把这些信息也存到context中,这些也并非所谓的request-scoped data,这也是值得探讨的一个点。 Last but not least

ctx context.Context这个写法符合readability吗?

Reference

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 什么是context
  • 为什么需要context
  • context源码及设计
    • Interface
      • emptyCtx
        • valueCtx
          • cancelCtx
            • timerCtx
            • 思考: context真的是个好设计吗?
            • Reference
            领券
            问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档