Golang之context

简介: 周末的快乐时光总是很短暂。今天天气不错,有点太阳。去附近的商场吃了一顿“高老九重庆火锅”,味道还行,主要是好久没吃火锅了~白天把家里好好收拾了一下,感觉心情也跟着变好了。

Y说


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

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

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

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

在用Golang的时候,发现很多下游的框架或服务通常会要求我们传入一个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类型的contextWithCancel返回一个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,
    handlerHeader: 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通过调用栈一层层传下去,而不是放在结构体里,参考文章:go.dev/blog/contex…

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

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

目录
相关文章
|
6月前
|
存储 SQL 安全
Golang底层原理剖析之上下文Context
Golang底层原理剖析之上下文Context
137 0
|
6月前
|
Linux Go
浅谈Golang上下文Context
浅谈Golang上下文Context
58 0
|
3月前
|
人工智能 Go
Golang 里的 context
Golang 里的 context
20 0
|
6月前
|
数据管理 Go 开发者
Golang深入浅出之-Go语言上下文(context)包:处理取消与超时
【4月更文挑战第25天】Go语言中的`context`包在并发、网络请求和长任务中至关重要,提供取消、截止时间和元数据管理。本文探讨`context`基础,如`Background()`、`TODO()`、`WithCancel()`、`WithDeadline()`和`WithTimeout()`。常见问题包括不当传递、过度使用`Background()`和`TODO()`以及忽略错误处理。通过取消和超时示例,强调正确传递上下文、处理取消错误和设置超时以提高应用健壮性和响应性。正确使用`context`是构建稳定高效Go应用的关键。
101 1
|
6月前
|
Go 开发者
Golang深入浅出之-Go语言上下文(context)包:处理取消与超时
【4月更文挑战第23天】Go语言的`context`包提供`Context`接口用于处理任务取消、超时和截止日期。通过传递`Context`对象,开发者能轻松实现复杂控制流。本文解析`context`包特性,讨论常见问题和解决方案,并给出代码示例。关键点包括:1) 确保将`Context`传递给所有相关任务;2) 根据需求选择适当的`Context`创建函数;3) 定期检查`Done()`通道以响应取消请求。正确使用`context`包能提升Go程序的控制流管理效率。
65 1
|
监控 安全 Go
Golang 语言中 Context 的使用方式
Golang 语言中 Context 的使用方式
47 0
|
6月前
|
监控 安全 Go
golang面试:golang中的context(四)
golang面试:golang中的context(四)
76 0
|
XML JSON Java
知识分享之Golang——Gin学习之context上下文的获取与使用(三)
知识分享之Golang篇是我在日常使用Golang时学习到的各种各样的知识的记录,将其整理出来以文章的形式分享给大家,来进行共同学习。欢迎大家进行持续关注。 知识分享系列目前包含Java、Golang、Linux、Docker等等。
626 0
知识分享之Golang——Gin学习之context上下文的获取与使用(三)
|
存储 SQL 安全
Golang 语言标准库 context 包控制 goroutine
Golang 语言标准库 context 包控制 goroutine
47 0
|
Go API 调度
No.14 Golang中Context你了解吗?(下)
No.14 Golang中Context你了解吗?