1. 前言

在 Go 语言中,上下文 context.Context 用来设置截止日期,同步信号,传递值的功能,它与 goroutine 关系密切,被用来解决 goroutine 之间 退出通知元数据传递 等的任务。本文通过示例代码来学习梳理 context.Context 包,希望做到从入门到深入了解。

2. context.Context 包类型

首先看类图如下:

从类图可以看出:

  • context.Context 是个接口类型,它实现了 Deadline(),Done(),Err() 和 Value(key interface{}) 方法。各方法的功能如下:

    • Deadline: 返回上下文 context.Context 的截止时间,截止时间到将取消该上下文。
    • Done: 返回只读空结构体通道。源码中没有向该通道写结构体,调用该方法会使通道阻塞在接收数据,直到关闭该通道(关闭通道会读到结构体的零值)。
    • Err: 返回上下文 context.Context 结束的错误类型。有两种错误类型:
      • 如果 context.Context 被取消,则返回 canceled 错误;
      • 如果 context.Context 超时,则返回 DeadlineExceeded 错误。
    • Value: 返回 context.Context 存储的键 key 对应的值。
  • canceler 也是一个接口,该接口实现了 cancel(removeFromParent bool, err error) 和 Done() 方法。实现了该接口的上下文 context.Context 均能被取消(通过调用 cancel 方法取消)。
  • cancelCtx 和 timerCtx(timerCtx 内嵌了 cancelCtx 结构体) 均实现了 canceler 接口,因此这两类上下文是可取消的。
  • emptyCtx 是空的上下文。它被 Backgroud 函数调用作为父上下文或被 ToDo 函数调用,用于不明确传递什么上下文 context.Context 时使用。
  • valueCtx 是存储键值对的上下文。

3. 代码示例

前面解释如果看不懂也没关系,这里通过代码来分析 context.Context 包的内部原理,毕竟 talk is cheap...

3.1 代码示例一: 单子 goroutine

func worker1(ctx context.Context, name string) {
time.Sleep(2 * time.Second)
for {
select {
case <-ctx.Done():
fmt.Println("worker1 stop", name, ctx.Err())
return
default:
fmt.Println(name, "send request")
}
}
} func main() {
ctx, cancel := context.WithCancel(context.Background()) go worker1(ctx, "worker1") time.Sleep(1 * time.Second)
cancel()
time.Sleep(1 * time.Second)
}

代码的输出为:worker1 stop worker1 context canceled

逐层分析看代码为何输出信息如上。首先,查看 context.WithCancel 函数:

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) }
}

函数接受的是父上下文,也就是 main 中传入的函数 context.Background() 返回的 emptyCtx 上下文。在 newCancelCtx 函数新建 context.cancelCtx 上下文:

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

然后,propagateCancel 函数将父上下文的 cancel 信号传递给新建的 context.cancelCtx:

func propagateCancel(parent Context, child canceler) {
done := parent.Done()
if done == nil {
return // parent is never canceled
}
...

截取部分函数内容分析,后续会接着介绍。由于 emptyCtx 不会被取消,它的 Done 方法返回值为 nil,实际上执行到第一个判断 if done == nil 函数就会返回。

最后,返回新建上下文 context.cancelCtx 的地址及 CancelFunc 函数 func() { c.cancel(true, Canceled) }。后续取消上下文即是通过调用该函数取消的。

花开两朵,各表一枝。在把视线移到 worker1 函数,这个函数需要介绍的即是 ctx.Done() 方法,前面说过它返回只读通道,如果通道不关闭,将一直是阻塞状态。从时间上看,当子 goroutine 还在 sleep,即还未调用 ctx.Done 方法,main 中的 cancel() 函数已经执行完了。那么,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 // already canceled
}
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)
}
}

在 cancel 函数中的关键代码是 c.done = closedchan,由于 goroutine 中还未调用 ctx.Done 方法,所以这里 context.cancelCtx 的 done 属性还是 nil。closedchan 是个已关闭通道,它在 context.Context 包的 init 函数就已经关闭了:

var closedchan = make(chan struct{})

func init() {
close(closedchan)
}

那么等 goroutine 睡醒了就知道通道已经关闭了从而读取到通道类型的零值,然后退出 goroutine。即打印输出 worker1 stop worker1 context canceled

到这里这一段代码的解释基本上结束了,还有一段是 cancel() 的执行要介绍,在 c.children for 循环这里,由于 c context.cancelCtx 没有 children 也即 c.children 是 nil,从而跳出 for 循环。

在 removeChild 函数中,父上下文 parent 并未取消,所以函数 parentCancelCtx 返回 ok 为 false,从而退出函数:

func removeChild(parent Context, child canceler) {
p, ok := parentCancelCtx(parent)
if !ok {
return
}
p.mu.Lock()
if p.children != nil {
delete(p.children, child)
}
p.mu.Unlock()
}

3.2 代码示例二:单子 goroutine

讨论完上一段代码,在看另一种变形就不难理解了,即子 goroutine 在取消前执行的情况。代码就不贴了,只是 sleep 时间换了下。区别在于 cancel 函数的判断:

if c.done == nil {
c.done = closedchan
} else {
close(c.done)
}

由于子 goroutine 中已经调用了 ctx.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
}

所以这里 c.done 的判断将不等于 nil 而走向 close(c.done) 直接关闭通道。

3.3 代码示例三:多子 goroutine

多子 goroutine 即一个 parent context.Context 有多个子 context.cancelCtx 的情况。如代码所示:

func worker1(ctx context.Context, name string, cancel context.CancelFunc) {
time.Sleep(2 * time.Second)
cancel() for {
select {
case <-ctx.Done():
fmt.Println("worker1 stop", name, ctx.Err())
return
default:
fmt.Println(name, "send request")
}
}
} func worker2(ctx context.Context, name string) {
time.Sleep(2 * time.Second) for {
select {
case <-ctx.Done():
fmt.Println("worker2 stop", name, ctx.Err())
return
default:
fmt.Println(name, "send request")
}
}
} func main() {
ctx, cancel := context.WithCancel(context.Background()) go worker1(ctx, "worker1", cancel)
go worker2(ctx, "worker2") time.Sleep(1 * time.Second)
cancel()
time.Sleep(2 * time.Second)
}

类似于代码示例一中单子 goroutine 的情况。区别在于同步锁这里:

c.mu.Lock()
if c.err != nil {
c.mu.Unlock()
return // already canceled
}

这里如果有一个 goroutine 调用了 cancel() 方法,c.err 就不等于 nil,其它 goroutine 再去调用 cancel() 就会判断 if c.err != nil 从而直接退出。这也引申出一点,上下文 context.Context 的方法是幂等性的,对于不同 goroutine 调用同样的上下文 context.Context 会得到相同的结果。

3.4 代码示例四:单父单子和单孙上下文

3.4.1 场景一

构造这样一种场景:父上下文 parent 有一个子上下文,该子上下文还有一个子上下文,也就是父上下文 parent 的孙上下文:

func worker3(ctx context.Context, name string, cancel context.CancelFunc) {
for {
select {
case <-ctx.Done():
fmt.Println("worker3 stop", name, ctx.Err())
return
default:
fmt.Println(name, "send request")
}
}
} func worker2(ctx context.Context, name string) {
time.Sleep(2 * time.Second) cctx, cancel := context.WithCancel(ctx)
go worker3(cctx, "worker3", cancel)
time.Sleep(2 * time.Second)
cancel() for {
select {
case <-ctx.Done():
fmt.Println("worker2 stop", name, ctx.Err())
return
default:
fmt.Println(name, "send request") }
}
} func main() {
ctx, cancel := context.WithCancel(context.Background()) go worker2(ctx, "worker2") time.Sleep(1 * time.Second)
cancel()
time.Sleep(2 * time.Second)
}

输出结果:

worker3 stop worker3 context canceled
worker2 stop worker2 context canceled

在这样一个场景下,子上下文会先于孙上下文取消,同样的层层查看为何会打印以上输出。首先,对于main 中的 cancel() 函数,当它运行时孙上下文还未创建,所以它的运行和代码示例一样。那么,我们看当孙上下文 cctx 创建时发生了什么:

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:
}
...

还是看 propagateCancel 函数,由于传入的 parent context.Context 已经取消了,所以 case <- done 会读到结构体的零值,进而调用 child.cancel 方法:

func (c *cancelCtx) cancel(removeFromParent bool, err error) {
...
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)
}
}

为了篇幅起见这里省略了部分 cancel 代码,类似前文调用 c.done = closedchan 关闭上下文 cctx 的通道,接着执行 cancel 方法,由于 cctx 并没有 children 同样的 for child := range c.children 将跳出循环,并且 removeFromParent 为 false 跳出 if 判断。

此时孙上下文 cctx 通道已经被关闭了,再次调用 cancel() context.cancelFunc 会判断 if c.err != nil 进而退出。

3.4.2 场景二

更改 sleep 时间,使得 main 中 cancel 函数在孙上下文 cancel() 执行后执行。由于子上下文并未 cancel,在 propagateCancel 里会走到 parentCancelCtx 判断这里,这里通过 p.children[child] = struct{}{} 将孙上下文绑定:

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()
}

绑定的目的是:对下,当子上下文取消时会直接调用孙上下文取消,实现了取消信号的同步。对上,当孙上下文取消时会切断和子上下文的关系,保持子上下文的运行状态。这部分是在 cancel 函数里实现的:

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)
}

对于 removeFromParent 函数,重点是其中的 delete 函数 delete(p.children, child) 将子上下文从父上下文的 p.children map 中移除掉:

func removeChild(parent Context, child canceler) {
p, ok := parentCancelCtx(parent)
if !ok {
return
}
p.mu.Lock()
if p.children != nil {
delete(p.children, child)
}
p.mu.Unlock()
}

3.5 代码示例四:多子上下文

直接看图:

图片来自 深度解密 Go 语言之context,这里不作过多分析,有兴趣的读者可自行研究。相信通过前文几个代码示例的梳理已基本到深入了解的程度了。

4. 附言

本文希望通过代码的梳理达到从入门上下文 context.Context 到深入了解的程度,然而本文并未高屋建瓴的对其中的设计进行抽象,也并未分析 context.Context 的由来及其它上下文 context.Context 如 valueCtx 和 timerCtx 等的分析,这些内容是本文缺乏的。幸好网上有较好的文章记录,想更深入了解,推荐博文:

  1. 深度解密 Go 语言之 context
  2. Go 语言设计与实现:上下文 Context
  3. Go 标准库之 context

context 从入门到深入了解的更多相关文章

  1. Spring框架入门

    技术分析之什么是Spring框架        1. Spring框架的概述        * Spring是一个开源框架        * Spring是于2003 年兴起的一个轻量级的Java开发 ...

  2. Spring框架第一天

    ## 今天课程:Spring框架第一天 ## ---------- **Spring框架的学习路线** 1. Spring第一天:Spring的IOC容器之XML的方式,Spring框架与Web项目整 ...

  3. JavaScript入门之Canvas(一): 2D Context

    概念 Canvas    是 HTML5 新增的元素,可用于通过使用JavaScript中的脚本来绘制图形.例如,它可以用于绘制图形,制作照片,创建动画,甚至可以进行实时视频处理或渲染.自HTML5添 ...

  4. SpringCloud入门之应用程序上下文服务(Spring Cloud Context)详解

    构建分布式系统非常复杂且容易出错.Spring Cloud为最常见的分布式系统模式提供了简单易用的编程模型,帮助开发人员构建弹性,可靠和协调的应用程序.Spring Cloud构建于Spring Bo ...

  5. react入门(六):状态提升&context上下文小白速懂

    一.状态提升 使用 react 经常会遇到几个组件需要共用状态数据的情况.这种情况下,我们最好将这部分共享的状态提升至他们最近的父组件当中进行管理. 原理:父组件基于属性把自己的一个fn函数传递给子组 ...

  6. Go语言的context包从放弃到入门

    目录 一.Context包到底是干嘛用的 二.主协程退出通知子协程示例演示 主协程通知子协程退出 主协程通知有子协程,子协程又有多个子协程 三.Context包的核心接口和方法 context接口 e ...

  7. 分布式学习系列【dubbo入门实践】

    分布式学习系列[dubbo入门实践] dubbo架构 组成部分:provider,consumer,registry,monitor: provider,consumer注册,订阅类似于消息队列的注册 ...

  8. Spring MVC入门

    1.什么是SpringMvc Spring MVC属于SpringFrameWork的后续产品,已经融合在Spring Web Flow里面.Spring 框架提供了构建 Web 应用程序的全功能 M ...

  9. 【腾讯Bugly干货分享】Android动态布局入门及NinePatchChunk解密

    本文来自于腾讯bugly开发者社区,非经作者同意,请勿转载,原文地址:http://dev.qq.com/topic/57c7ff5d53bbcffd68c64411 作者:黄进——QQ音乐团队 摆脱 ...

  10. ABP(现代ASP.NET样板开发框架)系列之2、ABP入门教程

    点这里进入ABP系列文章总目录 基于DDD的现代ASP.NET开发框架--ABP系列之2.ABP入门教程 ABP是“ASP.NET Boilerplate Project (ASP.NET样板项目)” ...

随机推荐

  1. .net 温故知新【16】:Asp.Net Core WebAPI 筛选器

    一.筛选器 通过使用筛选器可在请求处理管道中的特定阶段之前或之后运行代码. 这即是我们经常听到的面向切面编程AOP(Aspect Oriented Programming)技术,AOP通过预编译方式和 ...

  2. 【源码系列#04】Vue3侦听器原理(Watch)

    专栏分享:vue2源码专栏,vue3源码专栏,vue router源码专栏,玩具项目专栏,硬核推荐 欢迎各位ITer关注点赞收藏 语法 侦听一个或多个响应式数据源,并在数据源变化时调用所给的回调函数 ...

  3. Javascript Ajax总结——GET请求和POST请求

    1.GET请求GET最常用于向服务器查询信息.可在URL末尾添加查询字符串参数.XHR中,传入open()方法的URL末尾的查询字符串必须经过正确的编码,使用encodeURIComponent()编 ...

  4. CTFHub 栈溢出 ret2text exp代码

    exp代码: from pwn import * host='challenge-1868f48f1e630fd3.sandbox.ctfhub.com' port=27988 p=connect(h ...

  5. 文心一言 VS 讯飞星火 VS chatgpt (174)-- 算法导论13.3 3题

    三.用go语言,假设图13-5 和图13-6 中子α.β.γ.δ和ε的黑高都是k.给每张图中的每个结点标上黑高,以验证图中所示的转换能保持性质 5. 文心一言: 在Go语言中,你可以通过以下步骤来为图 ...

  6. CUDA C编程权威指南:1.3-CUDA基础知识点梳理

      主要整理了N多年前(2013年)学习CUDA的时候开始总结的知识点,好长时间不写CUDA代码了,现在LLM推理需要重新学习CUDA编程,看来出来混迟早要还的. 1.CUDA数组 解析:CUDA数组 ...

  7. 数据库技术丨GaussDB(DWS)数据同步状态查看方法

    摘要:针对数据同步状态查看方法,GaussDB(DWS)提供了丰富的系统函数.视图.工具等可以直观地对同步进度进行跟踪,尤其是为方便定位人员使用,gs_ctl工具已集合了大部分相关系统函数的调用,可做 ...

  8. CANN 6.0来了,硬核技术抢先看

    摘要:在华为全联接大会2022期间,华为正式官宣昇腾AI异构计算架构CANN 6.0版本将在年底正式发布. 本文分享自华为云社区<昇腾AI异构计算架构CANN 6.0全新开放升级,全面释放AI生 ...

  9. ​  appuploader使用教程

    ​ appuploader使用教程 转载:appuploader使用教程 目录 问题解决秘籍 登录失败 don't have access,提示没权限或同意协议 上传后在app管理中心找不到版本提交 ...

  10. 十大 CI/CD 安全风险(五)

    在本篇文章中,我们将了解第三方服务的监管不足,工件完整性验证及日志可见性不足这三个关键 CI/CD 安全风险,并给出缓解相应风险的建议与措施. 第三方服务监管不足 CI/CD 攻击面包括企业资产,例如 ...