上一篇文章中讲到了 gin
的 Engine
结构体,它代表的是 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 | package main |
启动的时候,main
就是一个协程,所以调用
runtime.NumGoroutine()
的时候,输出的是
1
。
然后我们知道,在代码的任何地方,我们都可以通过 go
关键字来启动一个新的协程,所以我们可以通过以下的方式来在
main
里面启动一个新的协程:
1 | package main |
稍微解释一下这里的几行代码:
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 | // context.Context 接口 |
我们重点看这里的 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
或者
Deadline
的
Context
,然后我们也在子协程里面通过 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 | package main |
这个例子只是用了 WithCancel
,但是
WithDeadline
和 WithTimeout
的功能都是类似的,都是通过 ctx.Done()
返回的那个
chan
来进行父子协程之间的通信的。
gin 里面的 Context
是怎样的?
在上一小节中,我们提到了,Context
表示的是协程的上下文。回到本文的主题,我们现在的场景是一个 web
框架,它的核心功能就是处理 HTTP 请求,
在我们的请求处理过程中,可能会有各种各样的操作,比如读写文件、发起新的
HTTP 请求、读写数据库等,而这些操作的耗时可能是不确定的。
所以 gin
也是需要 Context
的机制来控制子协程的执行的,但是正如前面所说的,gin
其实是一个 web 框架,它的核心功能是处理 HTTP 请求, 因此
gin
里面的 Context
除了 Context
共有的一些功能外,在其基础上封装了很多跟 HTTP
请求处理相关的功能,比如:
- 表单验证
- 错误处理
- 获取请求数据
- 渲染不同格式的响应
这些功能不在本文的讨论范围之内,本文只探讨 Context 相关的功能。
而且,实际上,在 gin
里面对 Context
方法的调用只是简单的对 *http.Request
的
Context
的封装,并没有什么特别的功能,除了
Value()
方法,它可以从 Context.Keys
里面获取值。
1 | // Deadline、Done、Err 方法会调用 http.Request 本身的 context 对应的同名方法 |
应该如何使用 gin
中的 Context
?
现在我们知道了,在我们开发中其实有很多时候是需要通过启动子协程来进行处理的。为了更好的对子协程进行一些控制,我们往往需要在父协程里面定义一个
Context
对象,
然后创建子协程的时候,将其作为参数传递给子协程。
一来可以在父子协程之间共享数据,另一方面可以让子协程感知父协程的一些控制信号。再次提醒,这个是需要子协程主动去监听
context.Done()
这个 chan
的,否则一样会导致协程泄漏,下面是一个例子:
1 | package main |
为什么会这样呢?这是因为,其实对于子协程来说,你传递的这个 Context 参数就仅仅是一个参数而已,只是占用了函数调用栈上的一小片内存的变量,一个变量,如果你不去管它、用它,它又能做什么呢?它只是一段内存而已。 所以才需要去主动监听 context.Done() 返回的 chan。
因此,正确的做法是在一个合适的地方加上
select
,如下面这样:
1 | go func(c context.Context) { |
这个例子也许不太恰当,但是反映出的一个关键点是,我们在做一些比较耗时的操作时,必须注意监听父协程的终止信号,不加以控制的话,很有可能会导致协程泄漏。
说了那么多,该回到正题了。正如前面两个例子那样,其实我们在 web
应用中往往有需要进行网络调用的时候,比如 rpc
调用、数据库读写,缓存读写,这些库里面往往第一个参数就是接收
context.Context
参数,
通过这个参数,我们就可以控制子协程的一些行为了(前提是被调用方遵循
Context
的约定),比如做超时控制。
比如,非常流行的 gorm
库,我们在查询的时候也可以传递
Context
参数进行一些控制:
1 | package main |
在这个例子中,我们定义的 Context
的超时时间是 1
秒,但实际上我们执行的 SQL 需要的时间是 3 秒,但是我们的
Exec
调用在 1 秒后马上就返回了,
这是因为我们传递了一个有超时控制的 Context
作为参数,这样
gorm
的 mysql
库就知道需要控制它的执行时间,在超时的时候可以及时响应。
所以回到 gin
中,因为我们有一个请求相关的
Context
所以,我们在进行网络调用这类操作的时候,可以基于这个
Context
创建一个新的 Context
,如:
1 | package main |
这样一来,我们就实现了,既可以通过 Context
来进行父子协程的变量共享,也实现了对子协程的一些控制。
正如我们所看到的那样,我们创建新的
Context
往往是基于一个父Context
来创建的,在实际开发中,会出现一些相对来说比较复杂的情况,比如,父子Context
都设置了Timeout
又或者一个设置了Deadline
一个设置了Timeout
等, 这种情况下,子Context
在ctx.Done()
的时候,会取父子协程中设置的最小的那个时间,如下面的例子。
1 | package main |
简单来说,在现在很多涉及到网络调用等耗时操作的 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
,这样在网络调用超时的时候,我们的调用会直接返回错误。从而避免了长时间占用资源。