为什么需要 context

在 Go 程序中,特别是并发情况下,由于超时、取消等而引发的异常操作,往往需要及时的释放相应资源,正确的关闭 goroutine。防止协程不退出而导致内存泄露。如果没有 context,用来控制协程退出将会非常麻烦,我们来举一个例子。

假如说现在一个协程A开启了一个子协程B,这个子协程B又开启了另外两个子协程B1和B2来运行不同的任务,协程B2又开启了协程C来运行其他任务,现在协程A通知子协程B该退出了,这个时候我们需要完成这样的操作:A通知B退出,B退出时通知B1、B2退出,B2退出时通知C退出:

  1. func TestChanCloseGoroutine(t *testing.T) {
  2. fmt.Printf("开始了,有%d个协程\n", runtime.NumGoroutine())
  3. var (
  4. chB = make(chan struct{})
  5. chB1 = make(chan struct{})
  6. chB2 = make(chan struct{})
  7. chC = make(chan struct{})
  8. )
  9. // 协程A
  10. go func() {
  11. // 协程B
  12. go func() {
  13. // 协程B1
  14. go func() {
  15. for {
  16. select {
  17. case <-chB1:
  18. return
  19. default:
  20. }
  21. }
  22. }()
  23. // 协程B2
  24. go func() {
  25. // 协程C
  26. go func() {
  27. for {
  28. select {
  29. case <-chC:
  30. return
  31. default:
  32. }
  33. }
  34. }()
  35. for {
  36. select {
  37. case <-chB2:
  38. // 通知协程C退出
  39. chC <- struct{}{}
  40. return
  41. default:
  42. }
  43. }
  44. }()
  45. for {
  46. select {
  47. case <-chB:
  48. chB1 <- struct{}{}
  49. chB2 <- struct{}{}
  50. return
  51. default:
  52. }
  53. }
  54. }()
  55. // 1秒后通知B退出
  56. time.Sleep(1 * time.Second)
  57. chB <- struct{}{}
  58. // A后续没有任务了,会自动退出
  59. }()
  60. time.Sleep(2 * time.Second)
  61. fmt.Printf("最终结束,有%d个协程\n", runtime.NumGoroutine())
  62. }
  63. // 结果
  64. 开始了,有2个协程
  65. 最终结束,有2个协程
  66. // tips: Go Test 会启动两个额外的 goroutine 来运行代码,所以初始就会有2个 goroutine

通过 channel 来控制各个 goroutine 的关闭,程序看上去一点也不优雅。而且这才仅仅四个 goroutine ,就已经显得有些力不从心了,在真实的业务中,哪怕一个简单的 http 请求,都不可能启用四个 goroutine 就能够完成,且子协程的层级也绝非只有寥寥的三层!

context 是什么

context 在 Go 中是一个接口,它的定义如下:

  1. type Context interface {
  2. Deadline() (deadline time.Time, ok bool)
  3. Done() <-chan struct{}
  4. Err() error
  5. Value(key any) any
  6. }
  • Deadline 用来获取 ctx 的截止时间,如果没有截至时间,ok 将返回 false;
  • Done 里面是一个通道,当 ctx 被取消时,会返回一个关闭的 channel,如果该 ctx 永远都不会被关闭,则返回 nil;
  • Err 返回的 ctx 取消的原因,如果 ctx 没有被取消,会返回 nil。如果已经关闭了,会返回被关闭的原因,如果是被取消的会返回 canceled,超时的显示 deadline exceeded;
  • Value 会返回 ctx 中储存的值,会从当前 ctx 中一路向上追溯,如果整条 ctx 链中都没有找到值,则会返回nil。

context 的基本结构比较简单,里面也只有四个方法,如果到此没有理解四个方法也没有关系,下文会使用到这四个方法,届时将会很自然的掌握它们。

context 接口的实现

context 有四个不同的实现:emptyCtx、cancelCtx、timerCtx、valueCtx:

  1. type emptyCtx int
  2. func (*emptyCtx) Deadline() (deadline time.Time, ok bool) {
  3. return
  4. }
  5. func (*emptyCtx) Done() <-chan struct{} {
  6. return nil
  7. }
  8. func (*emptyCtx) Err() error {
  9. return nil
  10. }
  11. func (*emptyCtx) Value(key any) any {
  12. return nil
  13. }

emptyCtx 是一个实现了 context 接口的整型,它不能储存信息,也不能被取消,它被当作根节点 ctx。cancelCtx、timerCtx、valueCtx 由于篇幅原因,这里不放出它们的源码,只解释它们的作用:cancelCtx 是一个可以主动取消的 ctx。timerCtx 也是一个可以主动取消的 ctx,不同于 cancelCtx,它还储存着额外的时间信息,当时间条件满足后,会自动取消该 ctx,利用这点,可以实现超时机制。valueCtx 比较简单,用来创建一个携带键值的 ctx。

context 的基本使用

创建一个根节点

创建根节点有两种方法:

  1. ctx := context.Background()
  2. ctx := context.TODO()

这两种方法其实本质上都是初始化了一个 emptyCtx:

  1. var (
  2. background = new(emptyCtx)
  3. todo = new(emptyCtx)
  4. )
  5. func Background() Context {
  6. return background
  7. }
  8. func TODO() Context {
  9. return todo
  10. }

可以看到,在代码中,这两个函数其实是一模一样的,只是用于不同场景下:Background 推荐在主函数、初始化和测试中使用,TODO 用于不清楚使用哪个 context 时使用。根节点 ctx 不具备任何意义,也不能被取消。

创建一个子 ctx

可以通过WithCancel、WithDeadline、WithTimeout、WithValue 这四个主要的函数来创建子 ctx ,创建一个子 ctx 必须指定其归属的父 ctx,由此来形成一个上下文链,用来同步 goroutine 信号。来看一下它们的简单使用:

WithCancel 用来创建一个 cancelCtx,它可以被主动取消 :

  1. func TestCtxWithCancel(t *testing.T) {
  2. ctx := context.Background()
  3. ctx, cancel := context.WithCancel(ctx)
  4. go func() {
  5. for {
  6. select {
  7. // 还记得前文提到的Done的方法吗
  8. // 当 ctx 取消时,ctx.Done()对应的通道就会关闭,case也就会被执行
  9. case <-ctx.Done():
  10. // ctx.Err() 会获取到关闭原因哦
  11. fmt.Println("协程关闭", ctx.Err())
  12. return
  13. default:
  14. fmt.Println("继续运行")
  15. time.Sleep(100 * time.Millisecond)
  16. }
  17. }
  18. }()
  19. // 等待一秒后关闭
  20. time.Sleep(1 * time.Second)
  21. cancel()
  22. // 等待一秒,让子协程有时间打印出协程关闭的原因
  23. time.Sleep(1 * time.Second)
  24. }
  25. // 结果
  26. 继续运行
  27. 继续运行
  28. ……
  29. 协程关闭 context canceled

WithDeadline 用来创建一个 timerCtx,当时间条件满足后,它会被自动取消 :

  1. func TestCtxWithDeadline(t *testing.T) {
  2. ctx := context.Background()
  3. // 等待2秒后自动关闭
  4. ctx, cancel := context.WithDeadline(ctx, time.Now().Add(2*time.Second))
  5. defer cancel()
  6. // Deadline 前文也提到了,还记得吗?用来获取当前任务的截至时间
  7. if t, ok := ctx.Deadline(); ok {
  8. // time.DateTime 是 go1.20 版本的一个常量,其值是:"2006-01-02 15:04:05"
  9. fmt.Println(t.Format(time.DateTime))
  10. }
  11. go func() {
  12. select {
  13. case <-ctx.Done():
  14. // 手动关闭 context canceled
  15. // 自动关闭 context deadline exceeded
  16. fmt.Println("协程关闭", ctx.Err())
  17. return
  18. }
  19. }()
  20. time.Sleep(3 * time.Second)
  21. }
  22. // 结果
  23. 2023-05-10 18:00:36
  24. 协程关闭 context deadline exceeded
  25. // 将最后的等待时间更改为一秒
  26. func TestCtxWithDeadline(t *testing.T) {
  27. ……
  28. time.Sleep(1 * time.Second)
  29. }
  30. // 结果
  31. 2023-05-10 18:01:45
  32. 协程关闭 context canceled

哪怕 WithDeadline 到达指定时间会自动关闭,但依然推荐使用 defer cancel() 。这是因为如果任务已经完成了,但是自动取消仍需要1天时间,那么系统就会白白浪费资源在这1天上。

WithTimeoutWithDeadline 同理,只不过是 WithTimeout 用来接受一个过期时间,而不是接受一个过期时间节点:

  1. func TestCtxWithTimeout(t *testing.T) {
  2. ctx := context.Background()
  3. ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
  4. defer cancel()
  5. go func() {
  6. select {
  7. case <-ctx.Done():
  8. fmt.Println("协程关闭", ctx.Err())
  9. return
  10. }
  11. }()
  12. time.Sleep(3 * time.Second)
  13. }
  14. // 结果
  15. 协程关闭 context deadline exceeded

WithValue 用来创建一个 valueCtx:

  1. // 向上找到最近的上下文值
  2. func TestCtxWithValue(t *testing.T) {
  3. ctx := context.Background()
  4. ctx1 := context.WithValue(ctx, "key", "ok")
  5. ctx2, _ := context.WithCancel(ctx1)
  6. // Value 会一直向上追溯到根节点,获取当前上下文携带的值,
  7. value := ctx2.Value("key")
  8. if value != nil {
  9. fmt.Println(value)
  10. }
  11. }
  12. // 结果
  13. ok

这四个函数都是创建一个新的子节点,并不是直接修改当前 ctx,所以最后生成的 ctx 链有可能是这样的:

使用 ctx 退出 goroutine

回到开头提到的那个例子,我们使用 context 对其改造一下:

  1. func TestCtxCloseGoroutine(t *testing.T) {
  2. fmt.Printf("开始了,有%d个协程\n", runtime.NumGoroutine())
  3. ctx := context.Background()
  4. // 协程A
  5. go func(ctx context.Context) {
  6. ctx, cancel := context.WithCancel(ctx)
  7. // 协程B
  8. go func(ctx context.Context) {
  9. // 协程B1
  10. go func(ctx context.Context) {
  11. for {
  12. select {
  13. case <-ctx.Done():
  14. return
  15. default:
  16. }
  17. }
  18. }(ctx)
  19. // 协程B2
  20. go func(ctx context.Context) {
  21. // 协程C
  22. go func(ctx context.Context) {
  23. for {
  24. select {
  25. case <-ctx.Done():
  26. return
  27. default:
  28. }
  29. }
  30. }(ctx)
  31. for {
  32. select {
  33. case <-ctx.Done():
  34. return
  35. default:
  36. }
  37. }
  38. }(ctx)
  39. for {
  40. select {
  41. case <-ctx.Done():
  42. return
  43. default:
  44. }
  45. }
  46. }(ctx)
  47. // 1秒后通知退出
  48. time.Sleep(1 * time.Second)
  49. cancel()
  50. // A后续没有任务了,会自动退出
  51. }(ctx)
  52. time.Sleep(2 * time.Second)
  53. fmt.Printf("最终结束,有%d个协程\n", runtime.NumGoroutine())
  54. }
  55. // 结果
  56. 开始了,有2个协程
  57. 最终结束,有2个协程

可以看到,和使用 channel 控制 goroutine 退出相比,context 大大降低了心智负担。context 优雅的实现了某一层任务退出,下层所有任务退出,上层任务和同层任务不受影响。

Go 语言最佳实践:每次 context 的传递都应该直接使用值传递,不应该使用指针传递。这样可以防止上下文的值被多个并发的 goroutine 修改而导致竞争问题。虽然使用值传递会导致一些微小的性能开销,因为每次传递上下文时都需要复制一份数据,但它提供了更好的并发安全性和程序可靠性。另外,由于上下文采用了值传递,也不应该向上下文中存入较大的数据,从而导致性能问题。

Go 上下文的理解与使用的更多相关文章

  1. 对于Javascript 执行上下文的理解

    转载无源头地址 在这篇文章中,将比较深入地阐述下执行上下文 – JavaScript中最基础也是最重要的一个概念.相信读完这篇文章后,你就会明白javascript引擎内部在执行代码以前到底做了些什么 ...

  2. Linux内核中进程上下文、中断上下文、原子上下文、用户上下文的理解【转】

    转自:http://blog.csdn.net/laoliu_lcl/article/details/39972459 进程上下文和中断上下文是操作系统中很重要的两个概念,这两个概念在操作系统课程中不 ...

  3. 对Linux内核中进程上下文和中断上下文的理解

    内核空间和用户空间是操作系统理论的基础之一,即内核功能模块运行在内核空间,而应用程序运行在用户空间.现代的CPU都具有不同的操作模式,代表不同的 级别,不同的级别具有不同的功能,在较低的级别中将禁止某 ...

  4. javascript 执行上下文的理解

    首先,为什么某些函数以及变量在没有被声明以前就可以被使用,javascript引擎内部在执行代码以前到底做了些什么?这里,想信大家都会想到,变量声明提前这个概念: 但是,以下我要讲的是,声明提前的这个 ...

  5. 201709015工作日记--上下文的理解,ASM

    1.Android上下文理解 Android上下文对象,在Context中封装一个所谓的“语境”,Activity.Service.Application都继承自Context,所以在这三者创建时都会 ...

  6. Linux内核中进程上下文和中断上下文的理解

    參考: http://www.embedu.org/Column/Column240.htm http://www.cnblogs.com/Anker/p/3269106.html 首先明白一个概念: ...

  7. spring学习-ApplicationContext-spring上下文深入理解

    4月份开始复习一遍spring相关知识.让自己巩固一下spring大法的深奥益处,所以就看了大佬的博客,转载留下来日后继续研读.认为重点的标记为红色 以下文章内容转载自:http://www.cnbl ...

  8. 理解和使用NT驱动程序的执行上下文

    理解Windows NT驱动程序最重要的概念之一就是驱动程序运行时所处的“执行上下文”.理解并小心地应用这个概念可以帮助你构建更快.更高效的驱动程序. NT标准内核模式驱动程序编程的一个重要观念是某个 ...

  9. JS底层知识理解之执行上下文篇

    JS底层知识理解之执行上下文篇 一.什么是执行上下文(Execution Context) 执行上下文可以理解为当前代码的执行环境,它会形成一个作用域. 二.JavaScript引擎会以什么方式去处理 ...

  10. linux 用户态和内核态以及进程上下文、中断上下文 内核空间用户空间理解

    1.特权级         Intel x86架构的cpu一共有0-4四个特权级,0级最高,3级最低,ARM架构也有不同的特权级,硬件上在执行每条指令时都会对指令所具有的特权级做相应的检查.硬件已经提 ...

随机推荐

  1. Qt+QtWebApp开发笔记(一):QtWebApp介绍、下载和搭建基础封装http轻量级服务器Demo

    前言   在arm上做了Qt的应用程序,为了在局域网实现web页的访问方式来配置arm上Qt的程序,局域网轻量级http服务器是很好的实现方式之一,有机会做国产麒麟上Qt的http服务器,正好接触到了 ...

  2. ABP - 模块加载机制

    Abp是一个基于模块化开发的应用程序框架,提供了模块化基础的架构和模块化加载的引擎. 理解模块 一个模块是对一个功能点的封装,可以独立成为一个包,实现了松耦合的代码组织方式.Abp框架的基本思想就是模 ...

  3. 《数据结构(C语言版)》严蔚敏代码实现———顺序表

    一.前言 最近在重新学习数据结构啦,网上说这本书挺不错哒,于是我开始啃这本书咯...有一说一,严奶奶的书挺好的,就是有点大量使用指针...需要沉下心来,看一看画一画才能懂,我自己手敲了一遍书上代码,加 ...

  4. Flutter三棵树系列之BuildOwner

    引言 Flutter开发中三棵树的重要性不言而喻,了解其原理有助于我们开发出性能更优的App,此文主要从源码角度介绍Element树的管理类BuildOwner. 是什么? BuildOwner是el ...

  5. tryhackme-OWASP

    tryhackme-OWASP Top 10部分记录 敏感信息泄露 在assets目录中 可以看到到一个sqlite数据库的webapp.db文件 使用sqlite3 webapp.db .table ...

  6. 插件化工程R文件瘦身技术方案 | 京东云技术团队

    随着业务的发展及版本迭代,客户端工程中不断增加新的业务逻辑.引入新的资源,随之而来的问题就是安装包体积变大,前期各个业务模块通过无用资源删减.大图压缩或转上云.AB实验业务逻辑下线或其他手段在降低包体 ...

  7. GO通道:无缓冲通道与缓冲通道

    转载请注明出处: 1.通道定义 在多个协程之间进行通信和管理,可以使用 Go 语言提供的通道(Channel)类型.通道是一种特殊的数据结构,可以在协程之间进行传递数据,从而实现协程之间的通信和同步. ...

  8. Git 多账号配置

    本地登录多账号并连接对应的远程仓库,主要就是 密钥配对,我这里刚开始配了密钥也将密钥复制到ssh但是还是连接不到第二个远程仓库,后来发现是需要 密钥代理 1.在当前项目下更改git账号信息: git ...

  9. celery笔记九之task运行结果查看

    本文首发于公众号:Hunter后端 原文链接:celery笔记九之task运行结果查看 这一篇笔记介绍一下 celery 的 task 运行之后结果的查看. 前面我们使用的配置是这样的: # sett ...

  10. UI自动化 --- UI Automation 基础详解

    引言 上一篇文章UI自动化 --- 微软UI Automation中,介绍了UI Automation能够做什么,且借助 Inspect.exe 工具完成了一个模拟点击操作的Demo,文章结尾也提出了 ...