当前位置: 首页 > 工具软件 > ConTEXT > 使用案例 >

理解Go的Context机制

岳意蕴
2023-12-01

1. 什么是Context

最近在分析gRPC源码,proto文件生成部分的代码,接口函数的第一个参数统一是ctx context.Context,对这种设计甚是迷惑,于是找些资料,对其背后的原理一探究竟。

Context通常被译作上下文,它是一个比较抽象的概念,一般理解为“程序单元的一个运行状态、现场、快照”。将content翻译为“上下文”,很好地诠释了其本质,说明了数据流的方向,上游会把内容传递给下游。在Go语言中,程序单元指的就是Goroutine。

每个Goroutine在执行之前,都要先知道“程序当前的执行状态”。通常,将这些执行状态封装在一个Context变量中,传递到要执行的Goroutine中。上下文几乎已经成为传递“与请求Request具有相同生命周期的变量”的标准方法。在网络编程中,当接收网络请求Request时,以及处理网络请求Request时,我们可能需要开启不同的Goroutine,分别处理“获取请求数据”与“执行逻辑业务”。一个请求Request会在多个Goroutine中处理,而这些Goroutine可能需要共享Request的一些信息;同时,当该Request被取消或者超时的时候,依据这个Request创建的所有Goroutine也应该被结束。

2 context包

Context包的介绍,请参考Go Concurrency Patterns: Context

Go的设计者在设计Goroutine时,已经考虑到“多个Goroutine共享数据,以及多Goroutine管理机制”。golang.org/x/net/context包就是这种机制的实现。

context包实现的主要功能为:

其一,在程序单元之间共享状态变量。

其二,在被调用程序单元的外部,通过设置ctx变量值,将“过期撤销这些信号”传递给“被调用的程序单元”。

在网络编程中,若存在A调用B的API, B再调用C的API,若A调用B取消,那也要取消B调用C,通过在A、B、C的API调用之间传递Context,以及判断其状态,就能解决此问题。这就是为什么gRPC的接口中都带上ctx context.Context参数的原因之一。

Go1.7(当前是RC2版本)已将原来的golang.org/x/net/context包挪入了标准库中,放在$GOROOT/src/context下面。标准库中netnet/httpos/exec都用到了context。同时为了考虑兼容,在原golang.org/x/net/context包下存在两个文件,go17.go是调用标准库的context包,而pre_go17.go则是之前的默认实现,其介绍请参考go程序包源码解读

2.1 Context接口

context包的核心就是Context接口,其定义如下:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}
  • Deadline方法    返回一个超时时间。到了该超时时间,该Context所代表的工作将被取消继续执行。Goroutine获得了超时时间后,可以对某些io操作设定超时时间。

  • Done方法    返回一个通道(channel)。当Context被撤销或过期时,该通道被关闭。它是一个表示Context是否已关闭的信号。

  • Err方法    当Done通道关闭后,Err方法返回值为Context被撤的原因。

  • Value方法    可以让Goroutine共享一些数据,当然获得数据是协程安全的。但使用这些数据的时候要注意同步,比如返回了一个map,而这个map的读写则要加锁。

注意:context包里的方法是线程安全的,可以被多个线程使用。

Context接口没有提供方法来设置其值和过期时间,也没有提供方法直接将其自身撤销。也就是说,Context不能改变和撤销其自身。那么,该怎么通过Context传递改变后的状态呢?

2.2 默认错误值

context包有两个默认错误值,一个表示Context被取消,另一个表示Context超期。

var Canceled = errors.New("context canceled")
var DeadlineExceeded = errors.New("context deadline exceeded")

2.3 emptyCtx类型

在context包中,emptyCtx结构体实现了context接口,是contex接口的实现类型之一。

type emptyCtx int

func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
    return
}

func (*emptyCtx) Done() <-chan struct{} {
    return nil
}

func (*emptyCtx) Err() error {
    return nil
}

func (*emptyCtx) Value(key interface{}) interface{} {
    return nil
}

func (e *emptyCtx) String() string {
    switch e {
    case background:
        return "context.Background"
    case todo:
        return "context.TODO"
    }
    return "unknown empty Context"
}

我们不需要手动实现context接口类型的对象,context 包已经提供了两个context接口类型的对象:

var (
    background = new(emptyCtx)
    todo       = new(emptyCtx)
)

/*
    Background返回一个非nil、empty的上下文。
    这个上下文没有取消,没有值,并且没有期限。
    它通常用于由主功能,初始化和测试,并作为输入的顶层上下文。
*/
func Background() Context {
    return background
}

 /*
    TODO返回一个非nil、empty的上下文。
    在目前还不清楚要使用的上下文或尚不可用时,使用TODO函数。
*/
func TODO() Context {
    return todo
}

Background和TODO这两个函数都会返回一个context接口类型的实例,只是返回的这两个实例都是emptyCtx结构体类型。

2.4 cancelCtx结构体

cancelCtx结构体继承了Context,实现了canceler接口。

*cancelCtx 和 *timerCtx 都实现了canceler接口,实现该接口的类型都可以被直接canceled。

//*cancelCtx 和 *timerCtx 都实现了canceler接口,实现该接口的类型都可以被直接canceled。
type canceler interface {
    cancel(removeFromParent bool, err error)
    Done() <-chan struct{}
}


type cancelCtx struct {
    Context  // 匿名字段
    done chan struct{} // closed by the first cancel call.
    mu       sync.Mutex
    children map[canceler]bool // set to nil by the first cancel call
    err      error             // 当其被cancel时,将会把err设置为非nil。
}

func (c *cancelCtx) Done() <-chan struct{} {
    return c.done
}

func (c *cancelCtx) Err() error {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.err
}

func (c *cancelCtx) String() string {
    return fmt.Sprintf("%v.WithCancel", c.Context)
}

//核心是关闭c.done。
//同时会设置c.err = err, c.children = nil。
//依次遍历c.children,每个child分别cancel。
//如果设置了removeFromParent,则将c从其parent的children中删除。
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
    if err == nil {
        panic("context: internal error: missing cancel error")
    }
    c.mu.Lock()
    if c.err != nil {
        c.mu.Unlock()
        return // already canceled
    }
    c.err = err
    close(c.done)
    for child := range c.children {
        // NOTE: acquiring the child's lock while holding parent's lock.
        child.cancel(false, err)
    }
    c.children = nil
    c.mu.Unlock()

    if removeFromParent {
        removeChild(c.Context, c) // 从此处可以看到 cancelCtx的Context项是一个类似于parent的概念
    }
}

再来看一些Cancel相关的方法:

type CancelFunc func()

// WithCancel方法返回一个继承自parent的Context对象,同时返回的cancel方法可以用来关闭返回的Context对象中的Done channel。
// 该方法将新建立的节点挂载在最近的、可以被cancel的父节点下(向下方向)。
// 如果传入的parent是不可被cancel的节点,则直接只保留向上关系。
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
    c := newCancelCtx(parent)
    propagateCancel(parent, &c)
    return &c, func() { c.cancel(true, Canceled) }
}

func newCancelCtx(parent Context) cancelCtx {
    return cancelCtx{
        Context: parent,
        done:    make(chan struct{}),
    }
}

// 传递cancel
// 从当前传入的parent开始(包括该parent),向上查找最近的一个可以被cancel的parent。
// 如果找到的parent已经被cancel,则将刚才传入的child树给cancel掉。
// 否则,将child节点直接连接到找到的parent的children中(Context字段不变,即向上的父亲指针不变,但是向下的孩子指针变直接了)
// 
// 如果没有找到最近的、可以被cancel的parent,即其上都不可被cancel,则启动一个goroutine等待传入的parent终止,则cancel传入的child树,或者等待传入的child终结。
func propagateCancel(parent Context, child canceler) {
    if parent.Done() == nil {
        return // parent is never canceled
    }
    if 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]bool)
            }
            p.children[child] = true
        }
        p.mu.Unlock()
    } else {
        go func() {
            select {
            case <-parent.Done():
                child.cancel(false, parent.Err())
            case <-child.Done():
            }
        }()
    }
}

// 从传入的parent对象开始,依次往上找到一个最近的可以被cancel的对象,即cancelCtx或者timerCtx
func parentCancelCtx(parent Context) (*cancelCtx, bool) {
    for {
        switch c := parent.(type) {
        case *cancelCtx:
            return c, true
        case *timerCtx:
            return &c.cancelCtx, true
        case *valueCtx:
            parent = c.Context
        default:
            return nil, false
        }
    }
}


// 从父对象的children map中删除这个child。
func removeChild(parent Context, child canceler) {
    // 从parent开始,往上找到最近的一个可以cancel的父对象。
    p, ok := parentCancelCtx(parent)
    if !ok {
        return
    }
    p.mu.Lock()
    if p.children != nil {
        delete(p.children, child)
    }
    p.mu.Unlock()
}

2.5 timerCtx结构体

timerCtx是一个继承自cancelCtx的结构体。

type timerCtx struct {
    cancelCtx // 此处的封装是为了继承来自于cancelCtx的方法,cancelCtx.Context才是父亲节点的指针
    timer *time.Timer // Under cancelCtx.mu. 是一个计时器
    deadline time.Time
}

func (c *timerCtx) Deadline() (deadline time.Time, ok bool) {
    return c.deadline, true
}

func (c *timerCtx) String() string {
    return fmt.Sprintf("%v.WithDeadline(%s [%s])", c.cancelCtx.Context, c.deadline, c.deadline.Sub(time.Now()))
}

// 与cencelCtx有所不同,其除了处理cancelCtx.cancel,还回对c.timer进行Stop(),并将c.timer=nil
func (c *timerCtx) cancel(removeFromParent bool, err error) {
    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和WithTimeOut

func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc) {
    // 如果parent的deadline比新传入的deadline要早,则直接WithCancel。因为新传入的deadline没有效,父亲的deadline会先到期。
    if cur, ok := parent.Deadline(); ok && cur.Before(deadline) {
        // The current deadline is already sooner than the new one.
        return WithCancel(parent)
    }
    c := &timerCtx{
        cancelCtx: newCancelCtx(parent),
        deadline:  deadline,
    }
    // 接入树
    propagateCancel(parent, c)

    // 检查如果已经过期,则cancel新的子树
    d := deadline.Sub(time.Now())
    if d <= 0 {
        c.cancel(true, DeadlineExceeded) // deadline has already passed
        return c, func() { c.cancel(true, Canceled) }
    }

    c.mu.Lock()
    defer c.mu.Unlock()
    if c.err == nil {
        // 还没有被cancel的话,就设置deadline之后cancel的计时器
        c.timer = time.AfterFunc(d, func() {
            c.cancel(true, DeadlineExceeded)
        })
    }
    return c, func() { c.cancel(true, Canceled) }
}


// timeout和deadline本质一样
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc) {
    return WithDeadline(parent, time.Now().Add(timeout))
}

2.6 valueCtx结构体

valueCtx主要用来传递一些元数据,通过WithValue()来传入继承,通过Value()来读取。简单,不赘述。

func WithValue(parent Context, key interface{}, val interface{}) Context {
    return &valueCtx{parent, key, val}
}

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

func (c *valueCtx) String() string {
    return fmt.Sprintf("%v.WithValue(%#v, %#v)", c.Context, c.key, c.val)
}

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

3. context的使用

Goroutine的创建和调用关系是分层级的。更靠顶部的Goroutine应有办法主动关闭其下属的Goroutine的执行(否则,程序就可能失控)。为了实现这种关系,Context结构像一棵树,叶子节点须总是由根节点衍生出来的。

3.1 根节点

要创建Context树,第一步就是要得到根节点。可以使用context.Background()或context.TODO()来获取,一般是使用context.Background()

context.Background()返回一个emptyCtx类型的对象,该Context一般由接收请求的第一个Goroutine创建,是与进入请求对应的Context根节点。它不能被取消、没有值、也没有过期时间,常常作为处理Request的顶层context存在。通过WithCancel、WithTimeout函数来创建子对象,其可以获得cancel、timeout的能力。

context.TODO()也返回一个emptyCtx类型的对象。在目前还不清楚要使用的上下文时,或上下文尚不可用时,使用context.TODO()生成的Context接口类型的对象。

3.2 子节点

有了根节点,该怎么创建它的子节点、孙节点呢?context包提供了多个函数来创建他们,如下所示:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc)
func WithDeadline(parent Context, deadline time.Time) (Context, CancelFunc)
func WithTimeout(parent Context, timeout time.Duration) (Context, CancelFunc)
func WithValue(parent Context, key interface{}, val interface{}) Context

这些函数都接收一个Context类型的参数parent,并返回一个Context类型的值。这样,就层层创建出不同的节点。子节点是从复制父节点得到的,并且根据接收参数设定子节点的一些状态值。接着,就可以将子节点传递给下层的Goroutine了。

再回到之前的问题:该怎么通过Context传递改变后的状态呢?使用Context的Goroutine无法取消这个操作,这是符合常理的。因为这些Goroutine是被某个父Goroutine创建的,而理应只有父Goroutine可以取消操作。在父Goroutine中,可以通过WithCancel方法获得一个cancel方法,从而获得cancel的权利。

3.2.1 WithCancel函数

WithCancel函数将父节点复制到子节点,并且返回一个额外的CancelFunc函数类型变量,该函数类型的定义为:

type CancelFunc func()

调用CancelFunc函数类型变量的对象,将撤销对应的Context对象。这就是主动撤销Context的方法。在父节点的Context所对应的环境中,通过WithCancel函数不仅可创建子节点的Context,同时也获得了该节点Context的控制权。一旦执行该函数,则该节点Context就结束了。子节点需要类似如下代码来判断是否已结束,并退出该Goroutine:

select {
    case <-cxt.Done():
        // do some clean...
}

3.2.2 WithDeadline函数

WithDeadline函数的作用也差不多,它返回的Context类型值同样是parent的副本,但其过期时间由deadlineparent的过期时间共同决定。当parent的过期时间早于传入的deadline时间时,返回的过期时间应与parent相同。父节点过期时,其所有的子孙节点必须同时关闭;反之,返回的父节点的过期时间则为deadline

3.2.3 WithTimeout函数

WithTimeout函数与WithDeadline类似,只不过它传入的是从现在开始Context剩余的生命时长。他们都同样也都返回了所创建的子Context的控制权,一个CancelFunc类型的函数变量。

当顶层的Request请求函数结束后,我们就可以cancel掉某个context,从而层层Goroutine根据判断cxt.Done()来结束。

3.2.4 WithValue函数

WithValue函数返回parent的一个副本,调用该副本的Value(key)方法将得到val。这样我们不光将根节点原有的值保留了,还在子孙节点中加入了新的值。注意:若存在Key相同,则会被覆盖。

4. Context使用原则

  • Programs that use Contexts should follow these rules to keep interfaces consistent across packages and enable static analysis tools to check context propagation:     

使用Context的程序包需要遵循如下的原则来满足接口的一致性以及便于静态分析。在子Context被传递到的goroutine中,应该对该子Context的Done通道(channel)进行监控。一旦该通道被关闭(即上层运行环境撤销了本goroutine的执行),应主动终止对当前请求信息的处理,释放资源并返回。

  • Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it. The Context should be the first parameter, typically named ctx;

不要把Context存在一个结构体当中,要显式地传入函数。Context变量需要作为第一个参数使用,一般命名为ctx;

  • Do not pass a nil Context, even if a function permits it. Pass context.TODO if you are unsure about which Context to use;

即使方法允许,也不要传入一个nil的Context。如果你不确定要用什么Context,那么传一个context.TODO;

  • Use context Values only for request-scoped data that transits processes and APIs, not for passing optional parameters to functions;

使用context的Value方法时,只应该在程序和接口中传递“和请求相关的元数据”,不要用它来传递一些可选的参数;

  • The same Context may be passed to functions running in different goroutines; Contexts are safe for simultaneous use by multiple goroutines;

同样的Context可以传递到不同的goroutine中,Context在多个goroutine中是安全的。

5. 总结

context包通过构建树型关系的Context,来实现“上一层Goroutine能够对下一层Goroutine的控制”。对于处理一个Request请求操作,需要采用context来层层控制Goroutine,以及传递一些变量来共享。

  • Context对象的生存周期一般仅为一个请求的处理周期。针对一个请求创建一个Context变量(它为Context树结构的根),在请求处理结束后,撤销此ctx变量,释放资源。

  • 每次创建一个Goroutine,要么将原有的Context传递给Goroutine,要么创建一个子Context并传递给Goroutine。

  • Context能灵活地存储不同类型、不同数目的值,并且使多个Goroutine安全地读写其中的值。

  • 当通过父Context对象创建子Context对象时,可同时获得子Context的一个撤销函数,这样父Context对象的创建环境就获得了对子Context将要被传递到的Goroutine的撤销权。

 类似资料: