Golang 并发编程与 Context

https://toutiao.io/posts/od8xax/preview
独家号 面向信仰编程 作者 Draven 原文链接

Context是 Golang 中非常有趣的设计,它与 Go 语言中的并发编程有着比较密切的关系,在其他语言中我们很难见到类似Context的东西,它不仅能够用来设置截止日期、同步『信号』还能用来传递请求相关的值。

在这一节中就会介绍 Go 语言中这个非常常见的Context接口,我们将从这里开始了解 Go 语言并发编程的设计理念以及实现原理。

概述

Go 语言中的每一个请求的都是通过一个单独的 Goroutine 进行处理的,HTTP/RPC 请求的处理器往往都会启动新的 Goroutine 访问数据库和 RPC 服务,我们可能会创建多个 Goroutine 来处理一次请求,而Context的主要作用就是在不同的 Goroutine 之间同步请求特定的数据、取消信号以及处理请求的截止日期。

golang-context-usage

每一个Context都会从最顶层的 Goroutine 一层一层传递到最下层,这也是 Golang 中上下文最常见的使用方式,如果没有Context,当上层执行的操作出现错误时,下层其实不会收到错误而是会继续执行下去。

golang-without-context

当最上层的 Goroutine 因为某些原因执行失败时,下两层的 Goroutine 由于没有接收到这个信号所以会继续工作;但是当我们正确地使用Context时,就可以在下层及时停掉无用的工作减少额外资源的消耗:

golang-with-context

这其实就是 Golang 中上下文的最大作用,在不同 Goroutine 之间对信号进行同步避免对计算资源的浪费,与此同时Context还能携带以请求为作用域的键值对信息。

接口

Context其实是 Go 语言context包对外暴露的接口,该接口定义了四个需要实现的方法,其中包括:

  1. Deadline方法需要返回当前Context被取消的时间,也就是完成工作的截止日期;
  2. Done方法需要返回一个 Channel,这个 Channel 会在当前工作完成或者上下文被取消之后关闭,多次调用Done方法会返回同一个 Channel;
  3. Err方法会返回当前Context结束的原因,它只会在Done返回的 Channel 被关闭时才会返回非空的值;
    1. 如果当前Context被取消就会返回Canceled错误;
    2. 如果当前Context超时就会返回DeadlineExceeded错误;
  4. Value方法会从Context中返回键对应的值,对于同一个上下文来说,多次调用Value并传入相同的Key会返回相同的结果,这个功能可以用来传递请求特定的数据;
  1. type Context interface {
  2. Deadline() (deadline time.Time, ok bool)
  3. Done() <-chan struct{}
  4. Err() error
  5. Value(key interface{}) interface{}
  6. }

context包中提供的Background、TODO、WithDeadline等方法就会返回实现该接口的私有结构体的,我们会在后面的小节中详细介绍它们的工作原理。

示例

我们可以通过一个例子简单了解一下Context是如何对信号进行同步的,在这段代码中我们创建了一个过期时间为1s的上下文,并将上下文传入handle方法,该方法会使用500ms的时间处理该『请求』:

  1. func main() {
  2. ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
  3. defer cancel()
  4. go handle(ctx, 500*time.Millisecond)
  5. select {
  6. case <-ctx.Done():
  7. fmt.Println("main", ctx.Err())
  8. }
  9. }
  10. func handle(ctx context.Context, duration time.Duration) {
  11. select {
  12. case <-ctx.Done():
  13. fmt.Println("handle", ctx.Err())
  14. case <-time.After(duration):
  15. fmt.Println("process request with", duration)
  16. }
  17. }

所以我们有足够的时间处理该『请求』,而运行上述代码时会打印出如下所示的内容:

  1. $ go run context.go
  2. process request with 500ms
  3. main context deadline exceeded

『请求』被 Goroutine 正常处理没有进入超时的select分支,但是在main函数中的select却会等待Context的超时最终打印出main context deadline exceeded,如果我们将处理『请求』的时间改成1500ms,当前处理的过程就会因为Context到截止日期而被中止:

  1. $ go run context.go
  2. main context deadline exceeded
  3. handle context deadline exceeded

两个函数都会因为ctx.Done()返回的管道被关闭而中止,也就是上下文超时。

相信这两个例子能够帮助各位读者了解Context的使用方法以及基本的工作原理 — 多个 Goroutine 同时订阅ctx.Done()管道中的消息,一旦接收到取消信号就停止当前正在执行的工作并提前返回。

实现原理

Context相关的源代码都在 context.go 这个文件中,在这一节中我们就会从 Go 语言的源代码出发介绍Context的实现原理,包括如何在多个 Goroutine 之间同步信号、为请求设置截止日期并传递参数和信息。

默认上下文

在context包中,最常使用其实还是context.Background和context.TODO两个方法,这两个方法最终都会返回一个预先初始化好的私有变量background和todo:

  1. func Background() Context {
  2. return background
  3. }
  4. func TODO() Context {
  5. return todo
  6. }

这两个变量是在包初始化时就被创建好的,它们都是通过new(emptyCtx)表达式初始化的指向私有结构体emptyCtx的指针,这是包中最简单也是最常用的类型:

  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. }

它对Context接口方法的实现也都非常简单,无论何时调用都会返回nil或者空值,并没有任何特殊的功能,Background和TODO方法在某种层面上看其实也只是互为别名,两者没有太大的差别,不过context.Background()是上下文中最顶层的默认值,所有其他的上下文都应该从context.Background()演化出来。

golang-context-hierarchy

我们应该只在不确定时使用context.TODO(),在多数情况下如果函数没有上下文作为入参,我们往往都会使用context.Background()作为起始的Context向下传递。

取消信号

WithCancel方法能够从Context中创建出一个新的子上下文,同时还会返回用于取消该上下文的函数,也就是CancelFunc,我们直接从WithCancel函数的实现来看它到底做了什么:

  1. func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
  2. c := newCancelCtx(parent)
  3. propagateCancel(parent, &c)
  4. return &c, func() { c.cancel(true, Canceled) }
  5. }

newCancelCtx是包中的私有方法,它将传入的父上下文包到私有结构体cancelCtx{Context: parent}中,cancelCtx就是当前函数最终会返回的结构体类型,我们在详细了解它是如何实现接口之前,先来了解一下用于传递取消信号的propagateCancel函数:

  1. func propagateCancel(parent Context, child canceler) {
  2. if parent.Done() == nil {
  3. return // parent is never canceled
  4. }
  5. if p, ok := parentCancelCtx(parent); ok {
  6. p.mu.Lock()
  7. if p.err != nil {
  8. child.cancel(false, p.err)
  9. } else {
  10. if p.children == nil {
  11. p.children = make(map[canceler]struct{})
  12. }
  13. p.children[child] = struct{}{}
  14. }
  15. p.mu.Unlock()
  16. } else {
  17. go func() {
  18. select {
  19. case <-parent.Done():
  20. child.cancel(false, parent.Err())
  21. case <-child.Done():
  22. }
  23. }()
  24. }
  25. }

该函数总共会处理与父上下文相关的三种不同的情况:

  1. 当parent.Done() == nil,也就是parent不会触发取消事件时,当前函数直接返回;
  2. 当child的继承链上有parent是可以取消的上下文时,就会判断parent是否已经触发了取消信号;
    • 如果已经被取消,当前child就会立刻被取消;
    • 如果没有被取消,当前child就会被加入parent的children列表中,等待parent释放取消信号;
  3. 遇到其他情况就会开启一个新的 Goroutine,同时监听parent.Done()和child.Done()两个管道并在前者结束后立刻调用child.cancel取消子上下文;

这个函数的主要作用就是在parent和child之间同步取消和结束的信号,保证在parent被取消时,child也会收到对应的信号,不会发生状态不一致的问题。

cancelCtx实现的几个接口方法其实没有太多值得介绍的地方,该结构体最重要的方法其实是cancel方法,这个方法会关闭上下文的管道并向所有的子上下文发送取消信号:

  1. func (c *cancelCtx) cancel(removeFromParent bool, err error) {
  2. c.mu.Lock()
  3. if c.err != nil {
  4. c.mu.Unlock()
  5. return
  6. }
  7. c.err = err
  8. if c.done == nil {
  9. c.done = closedchan
  10. } else {
  11. close(c.done)
  12. }
  13. for child := range c.children {
  14. child.cancel(false, err)
  15. }
  16. c.children = nil
  17. c.mu.Unlock()
  18. if removeFromParent {
  19. removeChild(c.Context, c)
  20. }
  21. }

除了WithCancel之外,context包中的另外两个函数WithDeadline和WithTimeout也都能创建可以被取消的上下文,WithTimeout只是context包为我们提供的便利方法,能让我们更方便地创建timerCtx:

  1. func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
  2. return WithDeadline(parent, time.Now().Add(timeout))
  3. }
  4. func WithDeadline(parent Context, d time.Time) (Context, CancelFunc) {
  5. if cur, ok := parent.Deadline(); ok && cur.Before(d) {
  6. return WithCancel(parent)
  7. }
  8. c := &timerCtx{
  9. cancelCtx: newCancelCtx(parent),
  10. deadline: d,
  11. }
  12. propagateCancel(parent, c)
  13. dur := time.Until(d)
  14. if dur <= 0 {
  15. c.cancel(true, DeadlineExceeded) // deadline has already passed
  16. return c, func() { c.cancel(false, Canceled) }
  17. }
  18. c.mu.Lock()
  19. defer c.mu.Unlock()
  20. if c.err == nil {
  21. c.timer = time.AfterFunc(dur, func() {
  22. c.cancel(true, DeadlineExceeded)
  23. })
  24. }
  25. return c, func() { c.cancel(true, Canceled) }
  26. }

WithDeadline方法在创建timerCtx上下文的过程中,判断了上下文的截止日期与当前日期,并通过time.AfterFunc方法创建了定时器,当时间超过了截止日期之后就会调用cancel方法同步取消信号。

timerCtx结构体内部嵌入了一个cancelCtx结构体,也『继承』了相关的变量和方法,除此之外,持有的定时器和timer和截止时间deadline也实现了定时取消这一功能:

  1. type timerCtx struct {
  2. cancelCtx
  3. timer *time.Timer // Under cancelCtx.mu.
  4. deadline time.Time
  5. }
  6. func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
  7. return c.deadline, true
  8. }
  9. func (c *timerCtx) cancel(removeFromParent bool, err error) {
  10. c.cancelCtx.cancel(false, err)
  11. if removeFromParent {
  12. removeChild(c.cancelCtx.Context, c)
  13. }
  14. c.mu.Lock()
  15. if c.timer != nil {
  16. c.timer.Stop()
  17. c.timer = nil
  18. }
  19. c.mu.Unlock()
  20. }

cancel方法不仅调用了内部嵌入的cancelCtx.cancel,还会停止持有的定时器减少不必要的资源浪费。

传值方法

在最后我们需要了解一下如何使用上下文传值,context包中的WithValue函数能从父上下文中创建一个子上下文,传值的子上下文使用私有结构体valueCtx类型:

  1. func WithValue(parent Context, key, val interface{}) Context {
  2. if key == nil {
  3. panic("nil key")
  4. }
  5. if !reflectlite.TypeOf(key).Comparable() {
  6. panic("key is not comparable")
  7. }
  8. return &valueCtx{parent, key, val}
  9. }

valueCtx函数会将除了Value之外的Err、Deadline等方法代理到父上下文中,只会处理Value方法的调用,然而每一个valueCtx内部也并没有存储一个键值对的哈希,而是只包含一个键值对:

  1. type valueCtx struct {
  2. Context
  3. key, val interface{}
  4. }
  5. func (c *valueCtx) Value(key interface{}) interface{} {
  6. if c.key == key {
  7. return c.val
  8. }
  9. return c.Context.Value(key)
  10. }

如果当前valueCtx中存储的键与Value方法中传入的不匹配,就会从父上下文中查找该键对应的值直到在某个父上下文中返回nil或者查找到对应的值。

总结

Go 语言中的Context的主要作用还是在多个 Goroutine 或者模块之间同步取消信号或者截止日期,用于减少对资源的消耗和长时间占用,避免资源浪费,虽然传值也是它的功能之一,但是这个功能我们还是很少用到。

在真正使用传值的功能时我们也应该非常谨慎,不能将请求的所有参数都使用Context进行传递,这是一种非常差的设计,比较常见的使用场景是传递请求对应用户的认证令牌以及用于进行分布式追踪的请求 ID。

Reference

关于图片和转载

知识共享许可协议
本作品采用知识共享署名 4.0 国际许可协议进行许可。 转载时请注明原文链接,图片在使用时请保留图片中的全部内容,可适当缩放并在引用处附上图片所在的文章链接,图片使用 Sketch 进行绘制。

微信公众号

wechat-account-qrcode

关于评论和留言

如果对本文 Golang 并发编程与 Context 的内容有疑问,请在下面的评论系统中留言,谢谢。

原文链接:Golang 并发编程与 Context · 面向信仰编程

Follow: Draveness · GitHub

ft_authoradmin  ft_create_time2019-06-11 15:18
 ft_update_time2019-06-11 15:20