七叶笔记 » golang编程 » Golang之context

Golang之context

Y说

周末的快乐时光总是很短暂。

今天天气不错,有点太阳。去附近的商场吃了一顿“高老九重庆火锅”,味道还行,主要是好久没吃火锅了~

白天把家里好好收拾了一下,感觉心情也跟着变好了。

已经用Golang在日常工作中开发了好几个月了。作为一个Golang菜鸟,有些东西往往只是会用,没有来得及去深究其背后的原理和设计用意。今年默默给自己立了一个Flag,就是好好深入学习一下这门语言。

在用 go lang的时候,发现很多下游的框架或服务通常会要求我们传入一个 context.Context 对象,且它们一般在函数的第一个参数里。我们公司的框架里,每个请求都会有一个独一无二的 Log Id ,用来串联多个 微服务 的请求。有时候我们自己的代码可能用不上这个对象,但为了保持调用链的完整,日志不丢失,还是不得不传下去。

从协程说起

Golang这个语言的优势之一,就是它拥有一个高并发利器:goroutine。它是一个Golang语言实现的协程,单机就可以同时支持大量的并发请求,非常适合如今互联网时代的后端服务。

那有了大量的协程,就带来了一些问题。比如:请求的一些比较通用的参数(比如上面提到的Log Id)如何传递到协程呢?如何终止一个协程呢?

在Golang中,我们无法从外部终止一个协程,只能它自己结束。常见的比如超时取消等需求,我们通常使用抢占操作或者中断后续操作。

在context出来以前,Golang是channel + select的方式来做这件事情的。具体的做法是:定义一个channel,子协程启一个定时任务循环监听这个channel,主协程如果想取消子协程,就往channel里写入信号。

这样确实能解决这个问题,但编码麻烦不说,如果有协程里面启协程,形成协程树的话,就比较麻烦了,得定义大量的channel。

Context的接口

Context 是一个接口,位于 context 包。它的接口定义非常简单:

 // A Context carries a deadline, cancelation signal, and  request -scoped values
// across API boundaries. Its methods are safe for simultaneous use by multiple
// goroutines.
type Context interface {
    // Done returns a channel that is closed when this Context is canceled
    // or times out.
    Done() <-chan  struct {}

    // Err indicates why this context was canceled, after the Done channel
    // is closed.
     Err () error

    //  deadline  returns the time when this Context will be canceled, if any.
    Deadline() (deadline time.Time, ok  bool )

    // Value returns the value associated with key or nil if none.
    Value(key interface{}) interface{}
}  

简单解释一下四个方法的作用:

  • Done:返回一个Channel,用于向当前协程传递是否结束;
  • err :当Done Channel结束时,返回这个context为什么取消。如果是被取消,将返回 Canceled ;如果是超时,将返回 DeadlineExceeded
  • Deadline:返回context会被取消的时间,如果没有设置时间,ok会返回false;
  • Value:获取context相关的数据。

默认的Context实现

context 包中有一些默认的Context实现,基本能满足绝大多数的应用场景。下面简单介绍一下:

emptyCtx

emptyCtx的实现是一个int类型的变量,没有超时时间,不能取消,也不能存储任何额外信息。

它有两个实例:Background和TODO,分别由两个方法返回。 Background 通常被用于主函数、初始化以及测试中,作为一个顶层的 context ,也就是说一般我们创建的 context 都是基于 Background ;而 TODO 是在不确定使用什么 context 的时候才会使用。

valueCtx

valueCtx可以存储键值对。且有一个指向父Context的组合。代码如下:

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

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

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

WithValue 方法可以使用传入的context作为父,添加一个键值对,然后重新创建一个新的context。在找Value的时候,是会沿着context树往上找的,也就是说,如果在当前的context找不到,就会尝试在其父context找,有点责任链的感觉了。

cancelCtx

可取消的context。它自己这个包里又定义了一个 canceler 接口。结构图:

 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
}

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

重点在这个 cancel 方法,会设置取消原因,并会取消所有的children,如果有需要还会将当前节点从父节点上移除。

WithCancel 函数用来创建一个可取消的 context ,即 cancelCtx 类型的 context WithCancel 返回一个 context 和一个 CancelFunc ,调用 CancelFunc 即可触发 cancel 操作。

 type CancelFunc func()

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
    c := newCancelCtx(parent)
    // 将当前context加入到最近的类型为cancelCtx的祖先节点的children中
    propagateCancel(parent, &c)
    return &c, func() { c.cancel(true, Canceled) }
}

// newCancelCtx returns an initialized cancelCtx.
func newCancelCtx(parent Context) cancelCtx {
    // 将parent作为父节点context生成一个新的子节点
    return cancelCtx{Context: parent}
}  

注意这里的 propagateCancel 方法,为什么是最近的祖先节点而不是父节点?因为它父节点可能并不是一个cancelCtx,可能是一个valueCtx之类的,也就没有children字段。

timerCtx

timerCtx是一种可以定时取消的context,内部是基于cancelCtx来设计的,也实现了cancel接口。

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

    deadline time.Time
}

func (c *timerCtx) cancel(removeFromParent bool, err error) {
    // 将内部的cancelCtx取消
    c.cancelCtx.cancel(false, err)
    if removeFromParent {
        // Remove this timerCtx from its parent cancelCtx's children.
        removeChild(c.cancelCtx.Context, c)
    }
    c.mu.Lock()
    if c.timer != nil {
        取消计时器
        c.timer.Stop()
        c.timer = nil
    }
    c.mu.Unlock()
}  

WithDeadline 返回一个基于 parent 的timerCtx,并且其过期时间 deadline 不晚于所设置时间 d 。其逻辑如下:

  1. 如果父节点 parent 有过期时间并且过期时间早于给定时间 d ,那么新建的子节点 context 无需设置过期时间,使用 WithCancel 创建一个可取消的 context 即可;
  2. 否则,就要利用 parent 和过期时间 d 创建一个定时取消的 timerCtx ,并建立新建 context 与可取消 context 祖先节点的取消关联关系,接下来判断当前时间距离过期时间 d 的时长 dur
  3. 如果 dur 小于0,即当前已经过了过期时间,则直接取消新建的 timerCtx ,原因为 DeadlineExceeded
  4. 否则,为新建的 timerCtx 设置定时器,一旦到达过期时间即取消当前 timerCtx

WithDeadline 类似, WithTimeout 也是创建一个定时取消的 context ,只不过 WithDeadline 是接收一个过期时间点,而 WithTimeout 接收一个相对当前时间的过期时长 timeout

使用

协程需要自己去监听 Done 方法的channel,决定是否结束本协程:

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

// consumer
go func(ctx context.Context) {
    ticker := time.NewTicker(1 * time.Second)
    for _ = range ticker.C {
        select {
            case <-ctx.Done():
                fmt.Println("child process interrupt...")
                return
            default:
                fmt.Printf("send message: %d\n", <-messages)
        }
    }
}(ctx)  

在父协程里面,通过定义timeout或者手动调用 cancel() 方法来发送取消信号。

阅读过 net/http 包源码的朋友可能注意到在实现http server时就用到了context, 下面简单分析一下。

  1. 首先server在开启服务时会创建一个 valueCtx ,存储了server的相关信息,之后每建立一条连接就会开启一个协程,并携带此 valueCtx
  2. 建立连接之后会基于传入的 context 创建一个 valueCtx 用于存储本地地址信息,之后在此基础上又创建了一个 cancelCtx ,然后开始从当前连接中读取网络请求,每当读取到一个请求则会将该 cancelCtx 传入,用以传递取消信号。一旦连接断开,即可发送取消信号,取消所有进行中的网络请求。
  3. 读取到请求之后,会再次基于传入的 context 创建新的 cancelCtx ,并设置到当前请求对象 req 上,同时生成的 response 对象中 cancelCtx 保存了当前 context 取消方法。

关于第三步用代码解释可能更清晰一点:

 ctx, cancelCtx := context.WithCancel(ctx)
req.ctx = ctx
// 省略其它方法
w = &response{
    conn:          c,
    cancelCtx:     cancelCtx,
    req:           req,
    reqBody:       req.Body,
     handler Header: make(Header),
    contentLength: -1,
    closeNotifyCh: make(chan bool, 1),

    // We populate these ahead of time so we're not
    // reading from req.Header after their Handler starts
    // and maybe mutates it (Issue 14940)
    wants10KeepAlive: req.wantsHttp10KeepAlive(),
    wantsClose:       req.wantsClose(),
}  

这样设计有以下作用:

  • 一旦请求超时,即可调用 cancelCtx 来中断当前请求;
  • 在处理构建response过程中如果发生错误,可直接调用response对象的 cancelCtx 方法结束当前请求;
  • 在处理构建response完成之后,调用response对象的 cancelCtx 方法结束当前请求。

总结&日常开发

context 主要用于父子任务之间的同步取消信号,本质上是一种协程调度的方式。另外在使用 context 时有两点值得注意:

  1. 上游任务仅仅使用 context 通知下游任务不再需要,但不会直接干涉和中断下游任务的执行,由下游任务自行决定后续的处理操作,也就是说 context 的取消操作是无侵入的;
  2. context 是线程安全的,因为 context 本身是不可变的( immutable ),因此可以放心地在多个协程中传递使用。

可以看出来,Context最强大的功能就是可以优雅地关闭协程。在一般的服务框架中,这件事情可能就是框架帮我们做了,在接收请求之后设置一个context,传入到请求对应的协程里,在超时或者发生错误的时候调用cancel,关闭这个请求。需要注意的是,这里的请求协程一般是框架写代码去结束的。

但假如我们在请求里面自己开启了一个协程,框架代码就关不了这个协程了。所以我们需要传入context,然后在新建的这个协程里,根据这个协程的性质,看是否去监听context的Done方法。典型的场景就是,服务有统一的超时时间设置(比如10秒),但如果这个服务触发的是一个定时任务,这个定时任务有没有自己的超时时间?比如10分钟,如果有,就应该为这个协程调用 WithTimeout 来设置一个单独的context,然后在协程内部去监听。

context的设计让我想起了 Java 线程的中断 ,它也是只是设置一个信号量,至于具体中不中断,是由 线程 根据具体的场景,自己决定的。之前也写过一篇Java线程中断方面的文章,感兴趣的小伙伴可以在公众号历史里面翻一翻。

另外需要注意的是,官方推荐的是把context通过调用栈一层层传下去,而不是放在结构体里,参考文章:。如果当前函数暂时用不到context,为了避免lint工具报错,可以使用 _ 来隐藏变量名。

由于context需要在函数一层层传递,所以有些同学编码的时候会觉得比较麻烦。在一门公司的内部课程里,提到一个方式,就是使用Java类似的 ThreadLocal 来存储context,在需要的时候去取。其中会用到一些黑科技,比如从stack上取goroutine的id这种。但我个人不是很建议这种方式,在设计context的时候,其实ThreadLocal已经存在了很久了。Golang为什么没有使用那种方式,而是采用了现在的设计,应该是有一定的用意的。Golang的context设计是遵循Golang本身函数式编程的思想的,如果使用ThreadLocal,感觉有些不伦不类了。

context也有值传递的功能。我们目前团队上只用来传了log Id,那是不是也可以用来传当前操作人信息呢?我觉得是可以的,大家可以根据自己的团队规范来统一使用~

参考

  • 知乎-深入理解Golang之context
  • Go语言中文网-golang中context包解读
  • Go语言中文网-服务器开发利器golang context用法详解
  • Go Concurrency Patterns: Context
  • context-and-structs

求个支持

我是Yasin,一个坚持技术原创的博主,我的微信公众号是: 编了个程

都看到这儿了,如果觉得我的文章写得还行,不妨支持一下。

文章会首发到公众号,阅读体验最佳,欢迎大家关注。

你的每一个转发、关注、点赞、评论都是对我最大的支持!

还有学习资源、和一线互联网公司内推哦

相关文章