借鉴context的源码技巧

什么是context

在传统服务中,处理一个请求通常会连带着发起多个对其他服务的请求,这通常可能会导致一个请求传播到多个goroutine中。

image-20221226165058376

如上图所示,对于这种场景,如果request由于某些异常原因导致提前返回了结果,此时则需要通知所有传播到的goroutine用于取消请求,否则在异常情况下很容易引起请求堆积,导致雪崩。因此golang提供了context包用于解决当一个请求需要传播到多个goroutine的情况,使用context需要将其作为相关函数第一个参数传入,并通过Done()方法监听取消信号。

Context接口说明如下:

type Context interface {
    // 返回Context的超时时间(超时返回场景)
    Deadline() (deadline time.Time, ok bool)

    // 返回一个channel,当此channel被关闭时表示request已经被取消。
    Done() <-chan struct{}

    // 返回Context取消的原因
    Err() error

    // 返回Context上下文数据
    Value(key interface{}) interface{}
}

context是各个goroutine间信号传递的一种约定,当函数第一个参数是ctx时,并且请求可能会阻塞时,则需要监听ctx.Done()返回channel,当在上级goroutine通过context取消请求时,Done()返回的channel便会被close,例如在设计一个连接池时:

func (p *Pool) Get(ctx context.Context)(net.Conn,error){
  //...

  //没有空闲的连接,并且已经达到最大连接数情况
  //阻塞等待其他客户端归还conn
  select{
    //如果token返回,说明有其他客户端归还conn
    case <-p.token:
      //返回conn
      //...
    case <-ctx.Done()
      //上游调用context#cancel()取消请求,返回
      return
  }
}

如上,如果没有context的取消机制,则Get会一直阻塞直到有客户端归还连接,这种没有超时机制的请求对于线上的系统无疑是一种灾难,而通过context提供的取消功能,可以方便的管理异步阻塞的请求。


context被设计为线程安全的结构体,可以传播在多个goroutine中,同时对于在需要多个不同context的场景,context提供了parent-children机制,例如:

  1. 服务端收到请求,准备处理请求,服务端预期这个请求的总体处理时间不能超过500ms
  2. 第一步需要查询redis,查询redis时预期处理时间不能超过100ms
  3. 如果redis查询失败,则第二步需要查询mysql,查询mysql时预期处理时间不能超过200ms
  4. 第三步请求其他模块,设置超时时间为200ms
  5. 返回结果。

对于以上场景,仅用一个context完全无法满足要求,如果只是简单的设置500ms,那对于redis来说,本来100ms之后就能发现问题,却直接延后到了500ms,耗时增加了5倍。

因此对于context包提供的结构体来说,每个context可以基于函数传入的context作为parent派生出新的context,派生出的context与传入的context具有父子关系,随着调用链的传播与context的派生,会逐渐形成context树。

image-20221226170753867

在一颗树种,任何一个节点发起cancel()方法,其都会传播给所有的子节点。子节点的cancel()不会导致父节点cancel().这种传播机制完美的解决了每个函数需要定制自己的context的需求。


context同时提供了Value方法用来传递请求上下文的参数,例如traceId。对于Value()方法来说,子context可以访问所有父contextValue(),但是父context无法访问子Value()

如何使用context

context.Context是对外暴露的一个接口,具体实现的struct都没有对外暴漏,而是提供了4种创建函数:

//设置会超时的日期,当超过这个日期后会自动取消请求
WithDeadline(parent Context, d time.Time)

//返回一个可以主动取消的context,通过调用cancel可以取消请求
WithCancel(parent Context) (ctx Context, cancel CancelFunc)

//可以传入一个key-value对,子context可以通过key获取对应的value
//Value主要是传入和request生命周期相同的参数,典型的便是trace
//目前很多log框架都有通过context获取trace的方案
WithValue(parent Context, key, val any)

//可以设置超时时间,当超执行时间超过这个时间会自动取消请求
WithTimeout(parent Context, timeout time.Duration)

在所有的context构造函数中,都需要传入一个parent;因此在context包中提供了两种通用的根节点:

//顶层context,通常作为context的起点。
context.Background()

//当不知道需要使用什么context时,一般用于在开发过程中还没有上下文时,通过TODO作为一个占位符。
context.TODO()

作为context树的根节点,使用方式如下:

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)

context的使用场景非常简单,但是使用context需要注意以下几点:

  1. 在官方注释中有说明,context应该作为函数的第一个参数传入,context不能保存在结构体中,避免goroutine泄露

  2. 对于具有cancel()功能的context,应该注意defer cancel();因为在context中可能会启动一个goroutine来监听取消信号(见下面源码分析),如果函数退出但是没有执行cancel(),则可能导致goroutine泄露。

  3. 对于Value()类型context,子context是可以获取父contextkey-value的,但是父context无法获取子key-value

  4. 对于ctx的父子关系,并不是只有直接父节点才是父节点,而是从根节点到此节点的路径上的所有节点都是其父节点;所有父节点的cancel()方法都会导致其Done()方法返回,因为其父节点的方法是通过第三点的Value()方法实现。

  5. 对于Value()类型,由于子节点可以查找所有父节点的key-value,因此如果使用简单的string类型作为其key,那很容易因为同命名导致被覆盖,而在Value()的查找中,是通过==进行判断是否相等,因此可以创建自己的类型作为对应的key,防止被覆盖:

    type myKey struct{}
    ctx = context.WithValue(ctx, myKey{},"123")
    

context 原理

其实context原理并不难,在没有context的时候,我们如果想要在系统中实现优雅退出,也会借助channel:

for{
  select{
   case  job:=<-jobqueue:
      //执行任务
      doJob(job)
   case  <-closeChan:
      //执行关闭
      doClose()
      //退出循环
      return
   }
} 

context的核心原理也是channel,不过context还加入了parent-children机制,也就是上面说到的树状结构,使得closeChan可以在多个goroutine间传播。

这里简单分析一下cancelCtx源码,其他原理都是基于cancelCtx实现。

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
  //不允许传入空的parent,即使是顶节点也应该使用context.Backgroud()
  if parent == nil {
        panic("cannot create context from nil parent")
    }
  //创建cancelCtx结构体
    c := newCancelCtx(parent)
  //将自己加入到父context的child属性中
    propagateCancel(parent, &c)

    return &c, func() { c.cancel(true, Canceled) }
}
// newCancelCtx returns an initialized cancelCtx.
func newCancelCtx(parent Context) cancelCtx {
    return cancelCtx{Context: parent}
}

可以看到步骤比较简单:

  • 参数检查
  • 创建cancelCtx结构体
  • 调用propagateCancel将创建的context加入到parent children属性中,便于在parent调用cancel()时回调childrencancel()函数。

cancelCtx结构体的属性如下:

type cancelCtx struct {
  //parent
    Context

  //按照golang的规范,mu主要保护下面的属性
    mu       sync.Mutex
  //通过atomic实现不加锁获取最新的channel
    done     atomic.Value          
  //子节点
  //这里是吧map当做了set使用,为什么使用set而不是通过链表实现?
    children map[canceler]struct{} 
  //通过此标识记录done是否已经close,避免重复close(chan)导致panic
    err      error                 
}

接下来继续看调用propagateCancel函数将自己加入到parentchildren属性中:

// propagateCancel arranges for child to be canceled when parent is.
func propagateCancel(parent Context, child canceler) {
  //首先检查,如果父ctx返回的channel为nil,则说明父ctx永远不会cancel,则直接返回
  //例如emptyCtx,执行Done()方法便会返回nil
    done := parent.Done()
    if done == nil {
        return // parent is never canceled
    }

  //再次检查父ctx返回的channel是否已经关闭,如果已经关闭则说明子ctx也应该关闭
    select {
    case <-done:
    //执行子ctx的cancel()方法,这里ctx.cancel()主要是执行close(channel)
        child.cancel(false, parent.Err())
        return
    default:
    }

  //通过Value()方法获取父节点
    if p, ok := parentCancelCtx(parent); ok {
    //如果成功获取,因为父节点和当前子ctx可能不在同一个goroutine中,因此后面的操作需要加锁
        p.mu.Lock()
    //再次通过err判断父节点是否已经执行过cancel()方法,这一步已经加锁,因此获取到的一定是最新的信息
        if p.err != nil {
            //如果父节点已经关闭,则调用子节点的cancel方法
            child.cancel(false, p.err)
        } else {
      //否则,尝试初始化父节点的children属性
      //可以看到这里使用的是延迟初始化
            if p.children == nil {
                p.children = make(map[canceler]struct{})
            }
            p.children[child] = struct{}{}
        }
        p.mu.Unlock()
    } else {
    //这里的else表示没有成功获取的可以主动回调的父ctx,因此需要主动启动一个goroutine开始监听

    //主要是测试使用
        atomic.AddInt32(&goroutines, +1)
        go func() {
            select {
       //监听父ctx的取消
            case <-parent.Done():
                child.cancel(false, parent.Err())
       //或则是自己主动取消 
            case <-child.Done():
            }
        }()
    }
}

在没有看propagateCancel()源码之前,其实在心里构想过一版自己如何实现contex的功能:

type Context struct {
    *Context
    children map[*Context]struct{}
    mu    sync.Mutex
    done  atomic.Value
}

func WithCancel(parent *Context) *Context {
    c := &Context{
        Context: parent,
    }
    parent.addChild(c)
    return c
}

func (c *Context) addChild(child *Context) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.children[child]= struct{}{}
}

这种简版的实现与golang中的实现,最大的区别在于:

  • propagateCancel中,寻找父节点是通过Value()方法查找的,前面说明,Value()会一直向上查找所有父节点是否包含key,直到成功查询,也就是如果第一级父节点查找不到,会一直递归查找所有的父节点,直到根节点。也就是下面的代码中:
    ctx, cancel := context.WithCancel(context.Background())
    ctx2 := context.WithValue(ctx, "key", "value")
    ctx3 := context.WithValue(ctx2, 1, 2)
    
    go func() {
        select {
        case <-ctx3.Done():
            fmt.Println("done", ctx3.Err())
        }
    }()
    cancel()
    
    time.Sleep(time.Second)
    

    即使ctx3的直接父节点是一个Value类型,但是ctxcancel()方法依然会使其Done()方法成功返回。

    而直接通过addChild()方法却无法实现此功能。

  • Context接口并没有要求实现addChild()方法,而上面的实现中,却要求传入的parent必须包含addChild()方法。

  • 上面的方法默认传入的parent能维护子节点,也就是父节点在cancel()时会主动调用子节点的cancel()方法;但是Context是一个公开的接口,也就是用户也可以实现自己的Context,然而Context的接口中并没有这样要求父节点需要主动调用子节点的cancel()方法,就源码中的后半段else一样,这种情况需要启动一个goroutine监听父ctxDone()方法

明白上面三点,便可以知道为什么golang不使用这种”简单“的实现方案。

接下来看cancel()方法:

func (c *cancelCtx) cancel(removeFromParent bool, err error) {
    //比如传入error标记关闭原因
  if err == nil {
        panic("context: internal error: missing cancel error")
    }

  //加锁
    c.mu.Lock()

  //如果err不为空,则说明已经调用过一次cancel方法
  //不能重复调用,因为关闭已经关闭的channel会panic
    if c.err != nil {
        c.mu.Unlock()
        return // already canceled
    }
  //赋值
    c.err = err
    d, _ := c.done.Load().(chan struct{})
    //c.done是延迟初始化,因此这里可能还没有用户来得及调用done
  //因此这里直接给一个已经close的channel即可,避免浪费初始化性能
  if d == nil {
        c.done.Store(closedchan)
    } else {
    //否则,关闭channel
        close(d)
    }

  //关闭完毕之后,查找所有child,依次调用child的close方法
    for child := range c.children {
      //注意:这里是在锁中执行了加锁的操作,注意死锁
    //为什么这里的removeFromParent传入的false?是因为后面会主动删除所有子节点
        child.cancel(false, err)
    }
  //删除所有的children
    c.children = nil
  //释放锁
    c.mu.Unlock()

  //如果需要将自己从父节点删除,则通过父节点删除自己
    if removeFromParent {
        removeChild(c.Context, c)
    }
}

可以看到,cancel()方法也是比较简单,主要就是为了close(channel),同时还通过err属性标记cancel()是否已经执行过以及记录关闭的原因。

使用context的弊端

context从诞生以来,争议都很多。其通过简单的context包,解决了同一个Request传播到各个goroutine的管理问题。

但是其代码侵入式的设计,导致有人形容其传播就像病毒,只要一个地方需要context,那么整个函数调用链都要传递context

有人希望context可以像JavaThreadLocal一样,线程私有,可以通过一个全局方法随意调用而不是必须作为函数参数传递,在在go2的讨论中也有人认为应该删除context

个人认为context的设计也符合golang大道至简的理念,如同err机制,虽不能面面俱到,但是只要能通过简洁的方案解决问题,对于golang来说它就是解决方案。

使用context一定需要明白其原理,理解使用context的注意事项,避免goroutine泄露。

借鉴context的源码技巧

本来文章到上面就应该结束了,但是作为Java转到Golang的开发者,在阅读context的源码时,学习到很多值得借鉴的技巧,因此这里简单总结一下:

  1. 利用golang的多返回值特性,可以返回一个ok,表示两种结果,而不是通过nil判断,例如:
  • map通过多返回值可以先判断map是包含否key,再返回key对应的value
  • Deadline()方法,通过两个返回值,第一个返回值用来表示是否设置Deadline,第二个返回值表示具体的值是多少。

    Java中,一般通过if xx==null来实现。

  1. golang没有真正的继承!!其提供的快捷语法也仅仅是组合,例如:
    type A struct{
     B 
     name string
    }
    

    AB仅仅是组合关系,两者不符合任何的”里斯替代原则”。

    golang中想要实现多态只能通过接口!

  2. 2,同理在golang中也没有抽象类的说法,但是对于golang来说:

    func (a *A) Say(){
     //xxx
    }
    
    func Say(a *A){
     //xxxx
    }
    

    上面的方法和函数没有任何区别,因此可以通过函数+接口的方式绕过需要抽象类的需求,例如在context源中,propagateCancel()便是对这一技巧的使用。

  3. 设计到关于多线程的代码时,一定想好其线程模型,注意每个方法执行时是否会将本身添加到其他线程中,如果是,则注意后面的属性修改需要加锁。在context#propagateCancel()代码中:

     //...
     //将自己添加到parent的children列表中
    if p, ok := parentCancelCtx(parent); ok {
        //添加成功之后需要加锁
       p.mu.Lock()
     //...
    

    这一段代码中由于将自己通过添加到parentchildren列表中,而parent与自己很可能不在同一个线程,因此添加完毕之后就已经将自己暴露在其他线程中,因此需要先加锁,然后再次判断属性。这种隐蔽的地方往往也是产生bug的地方。

  4. 不要害怕使用锁,过度的提前优化反而是负向优化。平时往往在开发过程中,能使用atomic就想着不使用锁,有时候这样写出的代码会非常别扭,但是真正访问的QPS又不高,反而导致了维护性差。

  5. context中,为了强制用户使用WithXXX函数初始化context,其将所有的context实现的结构体都设置为包访问级别,仅暴露接口,使得用户无法通过new(cancelCtx)初始化结构体,这也是golang强制使用构造方法的一种技巧。

  6. 设计多种功能时,可以将功能分层,合理使用装饰者模式可以更好的复用代码。例如先实现 cancelCtx ,然后在其基础上实现 timerCtx,在将功能分层的同时还复用了代码。

  7. 合理利用包级别的全局变量,然后通过函数暴漏出去,这样既可以方便的访问,又可以防止别人修改变量,作用类似JavaGetter, 参考context.Backgroud()、context.TODO()

  8. 可以通过一些err标识表示channel是否关闭,而不是一定需要通过bool表示,通过err在实现标识的同时还可以记录其关闭的原因,更加便捷。

  9. 查看withCancel()返回的cancel()方法的签名:func (c *cancelCtx) cancel(removeFromParent bool, err error)

    可以看到第一个参数:removeFromParentremoveFromParent表示是否将自己从父节点中删除。在看源码时思考了下,不管什么时候,子ctxcancel了,不都应该从parent删除吗?在parentchildren的类型是map,对于map来说不管key是否存在执行delete()不是也没有问题的么?

    可以看到在源码中,在子节点还没有添加到父节点之前,如果此时父节点已经close,那么调用cancel()函数时传入的removeFromParent参数为false,也就是不用删除;这样做的好处是,执行removeChild()方法需要获取锁并查找map,既然已经明确知道自己一定不在map中,那么就可以通过一个参数避免了此次的加锁以及map查找的性能消耗。