一文看懂 Context

Go 中有一个 Context 接口,配合 goroutine 使用,主要是用来协调 goroutine 的执行,但是比较难理解,这篇文章中来详细分析一下。

1. Context 是什么

在 Go 1.7 版本,引入了一个接口 context.Context。 Context 从字面意思来看,就是上下文的意思,可以理解为它就是某个请求的上下文。

它主要的作用是在 Go 进程中传递信号。这里需要注意,虽然也可以传递参数,但主要传递信号,Context 不推荐用来传递大量的参数。

Context 接口有四个方法:

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

这四个方法看起来有点抽象:

  • Deadline:这个方法返回 Context 被取消的时间,也就是 Context 生命周期结束的时间
  • Done:返回一个 channel,这个 channel 会在 Context 生命周期结束之后被关闭,多次调用 Done 返回的是同一个 channel
  • Err:返回 Context 结束的原因,这里只会在 Done 方法返回的 channel 关闭之后才返回非空的值
  • Value:从 Context 中获取对应 Key 的值,这里面最好放入不可变的值,因为 Context 会在多个 goroutine 中传递,如果值是经常变化的话,有可能会带来意外的结果

2. 为什么需要 Context

以服务端为例,服务端在接收一个请求之后,就需要启动新的 goroutine 来处理这个请求,有可能会启动多个 goroutine。

多个 goroutine 启动之后,很难再去协调这些 goroutine,比如这个请求结束或者被终止,在这样的情况下,所有相关的 gouroutine 都应该被终止。

下面是一个简单的 http 服务,访问 [http://localhost:8080/index](http://localhost:8080/index) 之后,请求很快就返回了,正常情况下,在代码中启动的 goroutine 都应该结束,但是实际上,里面的 goroutine 还是会执行,最后会打印 "goroutine2 invoke"。

http.HandleFunc("/index", func(writer http.ResponseWriter, request *http.Request) {
    go func() {
        fmt.Println("goroutine1 invoke")
        go func() {
            select {
            // 模拟费时操作
            case <- time.After(2 * time.Second):
                fmt.Println("goroutine2 invoke")
            }
        }()
    }()
    writer.Write([]byte("index"))
})

fmt.Println("server starting")
http.ListenAndServe(":8080", nil)

但这种情况不是我们希望看到的,理想的情况下一个请求的结束,那么这个请求启动的 goroutine 都应该结束。这里就需要通过 Context 来做到。

3. Context 能做什么

可以使用 Context 来解决上面的问题,简单来说就是通过 Context 来串联所有的 goroutine,所有的goroutine 根据 Context 的状态来决定是否还要继续执行。

在一个请求中,不使用 Context,执行的状态是这样的:

在使用 Context 之后,执行的状态是这样的:

3.1 创建一个 Context

在使用 goroutine 之前,需要先创建 Context,Go 提供了两种创建 Context 的方法,使用 context.Background() 或者 context.ToDo() 方法:

func Background() Context {
    return background
}

func TODO() Context {
    return todo
}

这两个方法生成的 Context 其实都是 emptyCtx,这是一个 Context 接口的空实现:

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
}

在功能的角度,context.Background 和 context.Todo 没有区别,只是用来表达不同的用途,Background 表示是最顶层的 Context,其他的 Context 都应该由 Background 衍生而来,关于衍生的概念我们下面会讲到。而 Todo 用于还不确定定使用哪个 Context。

通常,如果没有通过参数接收到 Context,我们就会使用 Background 作为初始的 Context 向后传递。

3.2 衍生 Context

通过上面的 Background 创建的 Context 实际上是一个空实现,无法用来执行具体的逻辑。那么就需要根据具体的场景,衍生出相应的 Context。

每个衍生出来的 Context 都和父 Context 的状态保持一致,如果一个 Context 状态变化,那么通过这个 Context 衍生出来的所有 Context 都会改变。

衍生 Context 可以通过 4 个方法来完成:

  • WithCancel
  • WithTimeout
  • WithDeadline
  • WithValue

使用 WithCancel 来衍生新的 Context:

background := context.Background()
child1, cancelFunc := context.WithCancel(background)

go func() {
    // 继续衍生 Context
    child2, _ := context.WithCancel(child1)
    // 继续衍生 Context
    child3, _ := context.WithCancel(child2)

    select {
    // 接收到取消信号
    case <- child3.Done():
        fmt.Println("context canceled")
    }
}()

// 调用取消方法
cancelFunc()

time.Sleep(1 * time.Second)

使用 WithTimeout 来衍生新的 Context:

background := context.Background()

child1, _ := context.WithTimeout(background, 3 * time.Second)

go func() {
    // 继续衍生 Context
    child2, _ := context.WithCancel(child1)
    // 继续衍生 Context
    child3, _ := context.WithCancel(child2)

    // context 超时的时间
    timeout, _ := child3.Deadline()
    fmt.Printf("timeout is %+v\n", timeout)

    select {
    // 接收到超时
    case <- child3.Done():
        fmt.Println("context timeout")
    }
}()

time.Sleep(5 * time.Second)

使用 WithDeadline 来衍生新的 Context:

background := context.Background()
child1, _ := context.WithDeadline(background, <-time.After(3 * time.Second))

go func() {
    // 继续衍生 Context
    child2, _ := context.WithCancel(child1)
    // 继续衍生 Context
    child3, _ := context.WithCancel(child2)

    // context 超时的时间
    timeout, _ := child3.Deadline()
    fmt.Printf("timeout is %+v\n", timeout)

    select {
    // 接收到超时
    case <- child3.Done():
        fmt.Println("context timeout")
    }
}()

time.Sleep(5 * time.Second)

还有最后一个方法比较特殊,WithValue 在衍生 Context 的同时,可以放入键值对:

background := context.Background()
child1 := context.WithValue(background, "ray","jun")

go func() {
    v := child1.Value("ray")
    fmt.Printf("values is %+v\n", v)
}()

time.Sleep(5 * time.Second)

通过上面的代码可以看到,只要一个 Context 取消,或者设置了超时之后,后面衍生出来的所有 Context 都可以获取到这个状态,Context 是线程安全的,可以在多个 goroutine 之间使用。

上面的 http 服务可以使用 Context 来结束内部的 goroutine 执行,每个 request 中都有一个 Context,可以直接使用:

http.HandleFunc("/index", func(writer http.ResponseWriter, request *http.Request) {
    c := request.Context()
    cancelContext, cancelFunc := context.WithCancel(c)
    defer cancelFunc()

    go func() {
        fmt.Println("index goroutine1")
        go func() {
            select {
                case <-cancelContext.Done():
                    fmt.Println("request is done")
                return
                case <- time.After(2 * time.Second):
                    fmt.Println("goroutine is invoke")
            }
        }()
    }()

    writer.Write([]byte("index"))
})
fmt.Println("server starting")
http.ListenAndServe(":8080", nil)

请求结束之后,里面的 goroutine 就不会继续执行。

4. Context 内部实现

下面来看一下,Context 是如果实现的,使用 Background 生成的 Context 是一个空实现。而且 Background 生成的 Context 无法被取消,也不会过期。

通常在都会使用 With 系列方法来衍生 Context 来使用,下面就来看一下几个 With 方法的实现,四个方法的实现都差不多,这里我们以 WithCancel 方法为例:

func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
    c := newCancelCtx(parent)
    propagateCancel(parent, &c)
    return &c, func() { c.cancel(true, Canceled) }
}

进入到方法之后,首先会生成一个新的 cancelCtx,这是一个内部的结构体,另外 WithDeadline 会创建一个 timerCtx,WithTimeout 会直接调用 WithDeadline,WithValue 则会创建一个 valueCtx。

type cancelCtx struct {
    Context                        // 保存父 Context
    mu       sync.Mutex            // 这个锁用来保证下面变量的方式是安全的
    done     chan struct{}         // 用来表示 Context 是否结束
    children map[canceler]struct{} // 保存衍生的 Context 
    err      error                 // Context 结束时的错误信息
}

然后会调用 propagateCancel 方法来构建子 Context 和父 Context 的关系:

func propagateCancel(parent Context, child canceler) {
    done := parent.Done()
    if done == nil {
        return // 空的 Context 无法被取消
    }

    select {
    case <-done:
        // 这里表示父 Context 已经结束,直接返回错误
        child.cancel(false, parent.Err())
        return
    default:
    }
    // 这里找出父 Context 是可取消的 Context
    if p, ok := parentCancelCtx(parent); ok {
        p.mu.Lock()
        if p.err != nil {
            // 如果父 Context 已经结束,这里直接结束
            child.cancel(false, p.err)
        } else {
      // 否则就把这个当前的这个 Context 加入到 map 中
            if p.children == nil {
                p.children = make(map[canceler]struct{})
            }
            p.children[child] = struct{}{}
        }
        p.mu.Unlock()
    } else {
    // 如果父类型不是可取消的类型,直接启动一个新的 goroutine 来监听父 Context 是否结束,以及监听当前 Context 是否已经结束
        atomic.AddInt32(&goroutines, +1)
        go func() {
            select {
            case <-parent.Done():
                child.cancel(false, parent.Err())
            case <-child.Done():
            }
        }()
    }
}
  1. 当父 Context 是不可取消的类型,直接返回,否则检查父 Context 是否已经结束,如果结束,直接返回错误信息
  2. 然后找出父 Context 中是否有 Cancel 类型的 Context:
    1. 如果有,且被取消,当前 Context 会直接被取消
    2. 如果没有被取消,那么当前 Context 会被直接添加到父 Context 的 map 中
  3. 当父 Context 中没有可取消的 Context 时,直接监听父 Context 的状态,当父 Context 关闭时,直接取消当前的 Context

与父 Context 建立联系后,会返回 Context 和一个 cancel 方法:

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 
    }
    c.err = err
    if c.done == nil {
        c.done = closedchan
    } else {
        close(c.done)
    }
    for child := range c.children {
        child.cancel(false, err)
    }
    c.children = nil
    c.mu.Unlock()

    if removeFromParent {
        removeChild(c.Context, c)
    }
}

当在代码中主动调用 cancel 方法时,会关闭 Done 通道,同时会把由当前 Conetx 衍生出来的可取消的 Context 都关闭,同时将这个 Context 从父 Context 中移除。

5. 小结

Context 提供了一种协调多个 goroutine 的运行,可以在多个 goroutine 之间安全的传递,让那些没必要执行 goroutine 尽快停止,释放系统资源。Context 主要用来传递信号,虽然也提供了传递值的方法,但不推荐使用这个方法来传递大量的数据,通常只用来传递简单不可变的数据,比如用户的认证 token 和请求的 traceId。

文 / Rayjun

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 220,458评论 6 513
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,030评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 166,879评论 0 358
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,278评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,296评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,019评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,633评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,541评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,068评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,181评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,318评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,991评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,670评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,183评论 0 23
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,302评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,655评论 3 375
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,327评论 2 358

推荐阅读更多精彩内容