0%

gin Context 详解

上一篇文章中讲到了 ginEngine 结构体,它代表的是 gin 框架的应用实例,里面包含了应用的一些配置等信息。 既然是应用实例,我们自然有需要用到它的时候,而在 gin 里面,我们是通过 Context 来使用它的,因为在我们的路由处理函数里面, Context 是唯一的一个参数,所以,自然而然的,Engine 就被设置为 Context 的一个属性。 当然,这只是 Context 的一小部分,也许可以这么说,Engine 只是搭起了一个骨架,构造起了一个 请求->路由->处理->响应 的模型, 但是所有脏活累活其实基本都在 Context 里面,今天就来看看 Context 具体有哪些功能。

协程是什么?

需要注意的是,我在本文前面提到的 Context 其实是 gin.Context,而不是 go 标准库的 context.Context, 当然 gin.Context 也实现了标准库的 Context 接口, 但是在这基础上,还提供了非常多的功能。

回到正题,也许很多人都知道,go 里面的一个核心功能就是它的协程(goroutine),我有时候会跟别人说 goroutine 是其精髓所在,其实一点也不为过。 毕竟再也没有其他任何一门语言,能够像 go 的协程这样如此简单地实现多线程的功能,这得益于 go 的设计,它从一开始就遵循 通过通信来共享内存,而不是通过共享内存来通信 的设计理念。

我们先来看看 go 的协程模型是怎样的。 从 go 进程启动的那一刻开始,它就启动了一个 goroutine,如:

1
2
3
4
5
6
7
8
9
10
11
package main

import (
"fmt"
"runtime"
)

func main() {
// goroutine nums: 1
fmt.Printf("goroutine nums: %d\n", runtime.NumGoroutine())
}

启动的时候,main 就是一个协程,所以调用 runtime.NumGoroutine() 的时候,输出的是 1

然后我们知道,在代码的任何地方,我们都可以通过 go 关键字来启动一个新的协程,所以我们可以通过以下的方式来在 main 里面启动一个新的协程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package main

import (
"fmt"
"runtime"
"sync"
"time"
)

func main() {
// 用以等待协程执行完成
var wg sync.WaitGroup
wg.Add(1)

go func() {
time.Sleep(time.Millisecond * 20)
wg.Done()
}()

// goroutine nums: 2
fmt.Printf("goroutine nums: %d\n", runtime.NumGoroutine())

wg.Wait()
}

稍微解释一下这里的几行代码:

  • var wg sync.WaitGroup 这个 WaitGroup 是 go 里面用来等待多个协程的机制(如果没有 wg 控制,有可能会导致主协程在子协程还没执行完毕就退出,比如子协程需要执行个几秒,但是你主协程不等它的话,子协程就会在主协程退出的时候也马上结束,这可能并不是我们想要的结果)
  • wg.Add(1) 表示需要等待的协程的数量,wg.Done() 表示需要等待的协程数量减少 1,在减少到 0 的时候,wg.Wait() 调用会返回,否则,wg.Wait() 会一直阻塞。

这里输出了 goroutine nums: 2,这是因为除了 main 协程之外,我们还用 go 关键字启动了一个新的协程,所以实际上是有两个协程。

好了,我们可以看到,在上面的代码里面通过 go 关键字后面接一个 func 就可以启动一个新的协程,所以其实协程本质上就是一个函数调用。 但是这跟普通的函数调用有什么区别呢?最关键的区别是,函数调用是顺序执行的,在执行函数的时候,调用函数的地方必须等待函数返回然后再往下执行。 而协程可以实现并行(当然,前提是有多个 cpu 可以使用),这样一来,启动协程的时候,go 关键字后面的代码可以接着执行(跟 go func 启动的协程同时执行)。

所以,go 的协程有如下特点:

  • 最关键的,main 协程退出的话,所有子协程都直接退出(注意是 main 协程)。
  • 首先,不同协程是可以并行执行的,每一个协程都是平等的,所以上面需要通过 WaitGroup 来等待子协程,因为主协程跟子协程是并行执行的,如果不等待子协程执行完毕 main 协程就退出的话,就会导致子协程执行一半就退出。

需要注意的是,上面提到的是 main 协程退出,所有子协程都会退出,但是如果不是 main 协程启动的协程的话,比如子协程 A 里面又启动了新的协程 B 的情况下, 如果 B 执行的时间会比 A 执行时间长,这种情况下,A 协程的退出并不会导致 B 协程的终止。这样就会出现 协程泄漏,这个问题可大可小,毕竟协程还在的话,资源就会被一直占用而得不到释放。 轻则导致响应缓慢,严重直接导致服务不可用、OOM 啥的。

Context 是什么?

简单来说,Context 代表了协程的上下文,用以在父子协程之间传递控制信号,共享变量等操作,比如超时的时候告诉子协程超时,父协程取消协程执行的时候传递取消的信号。

在上面我们提到了使用协程的情况下,有可能会导致协程泄漏,是不是我们对此就完全没有办法了呢?并不是的,毕竟从 go 设计出来协程就是它最重要的特性, 所以 go 提供了一种叫 Context 的东西,翻译成中文就是 "上下文" 的意思,的确是这样,它就是代表了 "上下文",但是是什么的上下文呢?

在 http 请求中,它往往代表的是当前处理的请求的上下文。它可以在请求内来共享数据,这是其中一个关键的功能。另外一个功能就是可以解决上一小节说的 "协程泄漏" 的问题。

Context 是如何解决 "协程泄漏" 的问题的呢?我们先来看看 Context 的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// context.Context 接口
type Context interface {
// 获取给协程设置的终止时间
Deadline() (deadline time.Time, ok bool)
// 用在子协程的 select 语句里面,有三种情况会导致这个函数返回:
// 1. context 的 cancel 被调用
// 2. 当前时间到了 context 的 deadline
// 3. 当前协程执行超时
Done() <-chan struct{}
// 有几种情况:
// 1. 如果 Done 通道尚未关闭,则返回 nil
// 2. 如果 Done 通道已经关闭,返回一个表示具体原因的错误:被取消了或者超时了
Err() error
// 返回父协程 context.WithValue 绑定的值
Value(key any) any
}

我们重点看这里的 Done 方法,它的返回值是一个 chan,而这个 chan 是父协程用来给子协程传递信息的关键,在父协程中进行 cancel 或者 当前的时间已经到了 context 指定的 Deadline,又或者子协程执行的总时间到达了指定的 timeout 时间时,Done 返回的那个 chan 会接收到一个值, 也就是说 case <-c.Done(): 里面的语句会被执行。

select 是 go 里面多路复用的机制,每一个 case 一般是一个 chan,在 chan 获取到值的时候,case 里面的语句会被执行,而 select 语句也会结束。

关于 Context 的其他东西,这里不细说了,不是本文的重点,我们需要知道的是, Context 提供了一种机制,可以让父协程给子协程传递一些信号,这样我们就可以控制子协程的执行耗时,防止某些操作导致协程一直执行下去

另外需要注意的是,并不是说我们用了 Context 就可以控制子协程了,我们还需要在子协程里面监听 context.Done() 的返回值,在 Done() 返回的时候,做一些清理工作,然后退出协程。

之所以需要子协程做 Done() 的监听,是因为父子协程之间并不是一种强的控制关系,而是需要子协程主动来协助父协程做超时等控制的。

好了,我们在父协程里面设置了一个带有 Timeout 或者 DeadlineContext,然后我们也在子协程里面通过 select 来监听 context.Done() 返回的 chan, 而且也在这个 chan 返回的时候作出了相应的清理操作,所以我们最终才得以实现父协程控制子协程。也就避免了父协程退出了,一堆子协程还在执行的 "协程泄漏" 现象。

Context 的一个实例

在下面的例子中:

  • 我们定义了 wg,用以控制等待子协程结束。
  • 通过 WithCancel 创建了一个 Context,同时获取了一个可以发送取消信号的函数句柄,我们调用它可以往一个 chan 写入数据,从而 select 语句从阻塞状态转变为执行态。
  • 我们在 1 秒后通过调用 cancel 方法发送了取消的信号
  • out 这个 chan 要在 2 秒后才会收到数据,所以 select 匹配上了 <-ctx.Done() 这一分支
  • 我们打印输出,发现 ctx.Done() 的原因是 context canceled(取消了),最后 wg.Done() 结束 WaitGroup 等待。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
package main

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

// 模拟控制超时的情况,
// 我们在 1 秒后主动发送取消信号,
// select 里面的 `case <-ctx.Done()` 会返回,select 语句结束
func main() {
var wg sync.WaitGroup
wg.Add(1)

// 定义了一个有取消功能的 Context,
// ctx 里面的 Done() 方法返回的 chan 在我们调用
// cancel 方法的时候会获取到值。
ctx, cancel := context.WithCancel(context.TODO())
go func(c context.Context) {
// 1 秒后发送取消信号
time.Sleep(time.Second)
cancel()
}(ctx)

// out 表示接收业务处理结果的 chan
// 我们在业务处理完毕之后往其中写入数据
out := make(chan struct{}, 1)
go func(out chan struct{}) {
// 模拟耗时 2 秒的业务处理
time.Sleep(time.Second * 2)
out <- struct{}{}
}(out)

select {
case <-ctx.Done(): // 1 秒后,这里接收到值,执行这一个分支
fmt.Println(ctx.Err()) // 输出:context canceled
wg.Done()
case <-out:
fmt.Println("done!")
wg.Done()
}

wg.Wait()
}

这个例子只是用了 WithCancel,但是 WithDeadlineWithTimeout 的功能都是类似的,都是通过 ctx.Done() 返回的那个 chan 来进行父子协程之间的通信的。

gin 里面的 Context 是怎样的?

在上一小节中,我们提到了,Context 表示的是协程的上下文。回到本文的主题,我们现在的场景是一个 web 框架,它的核心功能就是处理 HTTP 请求, 在我们的请求处理过程中,可能会有各种各样的操作,比如读写文件、发起新的 HTTP 请求、读写数据库等,而这些操作的耗时可能是不确定的。

所以 gin 也是需要 Context 的机制来控制子协程的执行的,但是正如前面所说的,gin 其实是一个 web 框架,它的核心功能是处理 HTTP 请求, 因此 gin 里面的 Context 除了 Context 共有的一些功能外,在其基础上封装了很多跟 HTTP 请求处理相关的功能,比如:

  • 表单验证
  • 错误处理
  • 获取请求数据
  • 渲染不同格式的响应

这些功能不在本文的讨论范围之内,本文只探讨 Context 相关的功能。

而且,实际上,在 gin 里面对 Context 方法的调用只是简单的对 *http.RequestContext 的封装,并没有什么特别的功能,除了 Value() 方法,它可以从 Context.Keys 里面获取值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Deadline、Done、Err 方法会调用 http.Request 本身的 context 对应的同名方法
func (c *Context) Deadline() (deadline time.Time, ok bool) {}
func (c *Context) Done() <-chan struct{} {}
func (c *Context) Err() error {}

// gin.Context.Value 方法
// 根据 key 获取关联的值。
func (c *Context) Value(key any) any {
// 获取当前 Context 关联的请求
if key == 0 {
return c.Request
}
// 获取 Context 本身
if key == ContextKey {
return c
}
// 从 c.Keys 里面获取共享的变量
if keyAsString, ok := key.(string); ok {
if val, exists := c.Get(keyAsString); exists {
return val
}
}
// 都不是上面的情况,从最初的 Context 里面获取 key 对应的值
if !c.engine.ContextWithFallback || c.Request == nil || c.Request.Context() == nil {
return nil
}
return c.Request.Context().Value(key)
}

应该如何使用 gin 中的 Context

现在我们知道了,在我们开发中其实有很多时候是需要通过启动子协程来进行处理的。为了更好的对子协程进行一些控制,我们往往需要在父协程里面定义一个 Context 对象, 然后创建子协程的时候,将其作为参数传递给子协程。

一来可以在父子协程之间共享数据,另一方面可以让子协程感知父协程的一些控制信号。再次提醒,这个是需要子协程主动去监听 context.Done() 这个 chan 的,否则一样会导致协程泄漏,下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package main

import (
"context"
"fmt"
"runtime"
"sync"
"time"
)

func main() {
var wg sync.WaitGroup
wg.Add(2)

// IDE 提示不应该忽略它的第二个返回值,防止 context 泄漏
ctx, _ := context.WithDeadline(context.TODO(), time.Now().Add(time.Second))
// 正常退出
go func(c context.Context) {
fmt.Println("goroutine 1")
wg.Done()
}(ctx)

// ctx 到了 deadline 也不会退出
go func(c context.Context) {
for {
time.Sleep(time.Second)
}
fmt.Println("goroutine 2")
wg.Done()
}(ctx)

// 输出 3
fmt.Println(runtime.NumGoroutine())

time.Sleep(time.Second * 2)
// 输出 2,还有 main 协程跟上面第二个协程
fmt.Println(runtime.NumGoroutine())

wg.Wait()
}

为什么会这样呢?这是因为,其实对于子协程来说,你传递的这个 Context 参数就仅仅是一个参数而已,只是占用了函数调用栈上的一小片内存的变量,一个变量,如果你不去管它、用它,它又能做什么呢?它只是一段内存而已。 所以才需要去主动监听 context.Done() 返回的 chan。

因此,正确的做法是在一个合适的地方加上 select,如下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
go func(c context.Context) {
out:
for {
time.Sleep(time.Second)
fmt.Println("for run...")

select {
case <-c.Done():
// 接收到协程终止信号
fmt.Println(c.Err())
break out
default:
// 终止 select,继续下一次 for 循环
break
}
}
fmt.Println("goroutine 2")
wg.Done()
}(ctx)

这个例子也许不太恰当,但是反映出的一个关键点是,我们在做一些比较耗时的操作时,必须注意监听父协程的终止信号,不加以控制的话,很有可能会导致协程泄漏。

说了那么多,该回到正题了。正如前面两个例子那样,其实我们在 web 应用中往往有需要进行网络调用的时候,比如 rpc 调用、数据库读写,缓存读写,这些库里面往往第一个参数就是接收 context.Context 参数, 通过这个参数,我们就可以控制子协程的一些行为了(前提是被调用方遵循 Context 的约定),比如做超时控制。

比如,非常流行的 gorm 库,我们在查询的时候也可以传递 Context 参数进行一些控制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package main

import (
"context"
"fmt"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"time"
)

func main() {
// refer https://github.com/go-sql-driver/mysql#dsn-data-source-name for details
dsn := "gorm:gorm@tcp(127.0.0.1:3306)/gorm?charset=utf8mb4&parseTime=True&loc=Local"
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
panic(err)
}

// 我们的 context 定义的超时时间是 1 秒
ctx, _ := context.WithTimeout(context.TODO(), time.Second)

// 实际上我们的 SQL 需要执行 3 秒
db = db.WithContext(ctx).Exec("select sleep(3)")

// 输出:context deadline exceeded
fmt.Println(db.Error)
}

在这个例子中,我们定义的 Context 的超时时间是 1 秒,但实际上我们执行的 SQL 需要的时间是 3 秒,但是我们的 Exec 调用在 1 秒后马上就返回了, 这是因为我们传递了一个有超时控制的 Context 作为参数,这样 gormmysql 库就知道需要控制它的执行时间,在超时的时候可以及时响应。

所以回到 gin 中,因为我们有一个请求相关的 Context 所以,我们在进行网络调用这类操作的时候,可以基于这个 Context 创建一个新的 Context,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package main

import (
"context"
"github.com/gin-gonic/gin"
"time"
)

func main() {
r := gin.Default()
r.GET("/", func(c *gin.Context) {
// 第一个参数是代表 HTTP 请求上下文的 Context
ctx, _ := context.WithTimeout(c, time.Second)
// ... 省略一部分代码
// 执行 SQL
db := db.WithContext(ctx).Exec("")
})
r.Run(":3000")
}

这样一来,我们就实现了,既可以通过 Context 来进行父子协程的变量共享,也实现了对子协程的一些控制。

正如我们所看到的那样,我们创建新的 Context 往往是基于一个父 Context 来创建的,在实际开发中,会出现一些相对来说比较复杂的情况,比如,父子 Context 都设置了 Timeout 又或者一个设置了 Deadline 一个设置了 Timeout 等, 这种情况下,子 Contextctx.Done() 的时候,会取父子协程中设置的最小的那个时间,如下面的例子。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
package main

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

func main() {
ch := make(chan struct{}, 1)

start := time.Now()

// 父 context 的超时时间是 1 秒
ctx1, _ := context.WithTimeout(context.TODO(), time.Second)

// 子 context 的超时时间是 3 秒
// 但实际上,在这个调用之后,ctx2 的超时时间实际上是 1 秒
ctx2, _ := context.WithTimeout(ctx1, time.Second*3)

go func(c context.Context) {
select {
case <-c.Done(): // 1 秒后 chan 会获取到值,执行下面的逻辑
fmt.Println(c.Err())
ch <- struct{}{}
}
}(ctx2)

<-ch

// 输出 1
fmt.Println(time.Now().Sub(start).Seconds())
}

简单来说,在现在很多涉及到网络调用等耗时操作的 golang 库中,往往需要提供一个 Context 参数用做超时控制,我们使用的时候将 gin.Context 作为参数传递给 context.WithTimeout 之类的函数即可。

总结

好了,到此为止吧,关于 Context 后续有空再写一篇,这里面的东西,如果对 Context 的一些 API 不太熟悉的话,可能会不太好懂。最后总结一下:

  • main 函数本身是一个协程,协程里面可以通过 go 关键字启动新的协程。 协程本质上是一个函数调用。父子协程是可以并行执行的(前提是有多个 CPU 核)。
  • 在需要等待子协程执行完的时候,可以使用 sync.WaitGroup,当然除了这个,另外一个方法是使用 chan
  • Context 代表的是父子协程的一个上下文对象,主要作用是共享数据、以及对子协程做一些超时控制等。
  • gin 里面的 Context 除了 context.Context 的基本功能外,还提供了很多 HTTP 请求处理相关的一些功能,比如获取请求数据、处理响应等。
  • 子协程有义务通过 select 来监听父协程发送的取消信号,又或者超时的信号,否则有可能导致协程泄漏。
  • 我们在调用一些库的时候,比如在 gorm 里面进行 MySQL,如果需要进行超时控制,则可以通过 context.WithTimeout 来创建一个新的 Context,并把这个新的 Context 作为参数传递给 db.WithContext,这样在网络调用超时的时候,我们的调用会直接返回错误。从而避免了长时间占用资源。