通过源码理解Golang中的上下文Context(一)

Golang进阶·技术 · 2023-06-23

背景

context在整个Golang生态体系中被广泛使用,我们在平时开发的时候也会经常用到它,而且Golang的很多标准包都依赖它context
关于context的一些使用方法的应用场景我在前面两篇文章也做了详细介绍,今天这篇文章我们就来介绍context内部的源码。

我们先回顾一下context它能解决的一些问题:

  1. 公共参数或数据的传递
  2. 通知所有子goroutine优雅退出,从而释放资源
  3. 业务执行超时或者在截止日期之前执行结束,然后程序优雅地退出或返回

上面几种使用方法就不做详细的介绍,下面直接看context包的源码。

源码分析

我们打开context包里面的context.go文件,发现其实除开注释的一些文档外,差不多也就只有200多行的代码。

Context接口和emptyCtx

context最基本的数据结构是Context如下接口:

type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key interface{}) interface{}
}

我们在使用上下文时,一般来说,第一步是使用context.Background()函数创建根上下文(上下文逐个链接在一起,形成树结构,根上下文是链中的第一个上下文),那么Background()这个是怎么去创建的呢?如下:

var background = new(emptyCtx)

func Background() Context {
    return background
}

Background函数返回一个声明为background的全局变量new(emptyCtx),那么什么是emptyCtx,我们继续:

// An emptyCtx is never canceled, has no values, and has no deadline. It is not
// struct{}, since vars of this type must have distinct addresses.
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"
}

可以看到emptyCtx他其实是一个基于int类型的自定义类型。

这里这个int,其实对于context来说,不管 emptyCtx 是基于 int、string 还是其他什么类型并不重要,重要的是实现了接口 Context 中定义的所有四个方法都返回 nil,因此根上下文永远不会被取消,没有值,也没有截止日期

好了,我们继续往下看,在context.go这个文件里面的 valueCtxWithValue 又是什么呢?

valueCtx 和 WithValue

上下文的一种典型用法是传递数据。在这种情况下,我们需要用到WithValue这个方法,例如下面的例子:

rootCtx := context.Background()

childCtx := context.WithValue(rootCtx, "key", "value")

WithValue是一个只有一个返回值的函数:

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

这里有一个reflectlite我们暂时先忽略他,后面在去做介绍。这里只需要关心WithValue返回值类型是&valueCtx

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

这里有一个 Golang 语言特性:embedding,它实现了composition。在这种情况下,valueCtx中定义了Context的所有四种方法。
简单来说,embedding有 3 种类型:结构中的结构接口中的接口以及结构体中的接口valueCtx就属于最后一种。

当想取出值时,可以使用以下Value方法:

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

如果这个key参数与当前上下文的key不匹配,则将调用父上下文的 Value 方法。如果我们仍然找不到这个key,那么父上下文会继续去调用它的父上下文,一直沿着链路向上去获取,直到根节点,根节点就会返回nil,就像我们上面提到的emptyCtx

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

cancelCtx 和 WithCancel

首先我们来看下面这个示例:

package main

import (
    "context"
    "fmt"
    "time"
)

func main() {
    cancelCtx, cancelFunc := context.WithCancel(context.Background())
    go task(cancelCtx)
    time.Sleep(time.Second * 3)
    cancelFunc()
    time.Sleep(time.Second * 3)
}

func task(ctx context.Context) {
    i := 1
    for {
        select {
        case <-ctx.Done():
            fmt.Println(ctx.Err())
            return
        default:
            fmt.Println(i)
            time.Sleep(time.Second * 1)
            i++
        }
    }
}

当主协程想要取消task这个协程的时候,只需调用cancelFunc,然后 task 协程退出并停止运行,这样,goroutine的管理就会变得很容易。

我们来看一下他的源码:

type CancelFunc func()

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
    if parent == nil {
        panic("cannot create context from nil parent")
    }
    c := newCancelCtx(parent)
    propagateCancel(parent, &c)
    return &c, func() { c.cancel(true, Canceled) }
}

WithCancel返回两个值,第一个是通过newCancelCtx创建&c(&cancelCtx{}),第二个是 CancenlFunc 类型,一个取消函数。

func newCancelCtx(parent Context) cancelCtx {
    return cancelCtx{Context: parent}
}

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
}

可以看到Context也嵌入了cancelCtx内部,同时还定义了其他几个字段,我们先来看Done()这个方法:

func (c *cancelCtx) Done() <-chan struct{} {
    c.mu.Lock()
    if c.done == nil {
        c.done = make(chan struct{})
    }
    d := c.done
    c.mu.Unlock()
    return d
}

Done()返回通道done,在上面的实例中,task goroutine 监听来自cancelCtx的取消信号,如下所示:

select {
case <-ctx.Done():
    fmt.Println(ctx.Err())
    return
...

该信号通过调用cancle函数来触发,那么这个cancle内部具体是处理什么以及信号是如何发送到通道的。我们来看cancelCtx的方法cancal

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
    }
    // set the err property when cancel is called for the first time
    c.err = err
    if c.done == nil {
        c.done = closedchan
    } else {
        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结构体所示,cancelCtx有四个属性,然后通过cancel这个方法可以判断出这四个属性的作用:

  • mu:一个通用锁,以确保 goroutine 安全并避免竞争条件;
  • err:表示cancelCtx是否被取消的标志。当cancelCtx被创建时,err值为nil,cancel第一次调用时,将通过c.err = err设置
  • done:发送取消信号的通道,为了实现这一点,上下文只需关闭(close)已完成的通道,而不是向其中发送数据。当我们的通道关闭后,接收者其实仍然可以根据通道类型从关闭的通道中获取零值
  • children:包含其所有子上下文的 Map。如果当前上下文被取消时,取消操作将通过在 for 循环中调用 child.cancel(false, err) 传播给子级。那么这个父子关系是什么时候建立的呢?秘密就在propagateCancel()函数内部;
func propagateCancel(parent Context, child canceler) {
    done := parent.Done()
    if done == nil {
        return // parent is never canceled
    }

    select {
    case <-done:
        // parent is already canceled
        child.cancel(false, parent.Err())
        return
    default:
    }

    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]struct{})
            }
            p.children[child] = struct{}{}
        }
        p.mu.Unlock()
    } else {
        atomic.AddInt32(&goroutines, +1)
        go func() {
            select {
            case <-parent.Done():
                child.cancel(false, parent.Err())
            case <-child.Done():
            }
        }()
    }
}

propagateCancel里面建立父级和子级之间的关系,关键点是 parentCancelCtx 函数,用于查找最里面的可取消父级上下文:

func parentCancelCtx(parent Context) (*cancelCtx, bool) {
    done := parent.Done()
    if done == closedchan || done == nil {
        return nil, false
    }
    p, ok := parent.Value(&cancelCtxKey).(*cancelCtx)
    if !ok {
        return nil, false
    }
    pdone, _ := p.done.Load().(chan struct{})
    if pdone != done {
        return nil, false
    }
    return p, true
}

可以看到Value方法被调用,因为我们在上面分析过,Value会传递搜索直到根上下文。

回到propagateCancel函数,如果找到可取消的父级上下文,则将当前上下文添加到子级哈希map中,如下所示:

if p.children == nil {
    p.children = make(map[canceler]struct{})
}
p.children[child] = struct{}{}

到这里父子关系就已经建立了。

总结

在本文中,我们分析了Context包的源代码,并了解 ContextvalueCtxcancelCtx 的工作原理。

Context 还包含其他两种类型的上下文:timeOut 上下文和 deadLine 上下文,这两个我们在下一篇context源码第二部分中去讨论。

Context Golang进阶 Golang源码
Theme Jasmine by Kent Liao