「Go 框架」平滑关闭:要关闭服务,未处理完的请求怎么办?
大家好,我是渔夫子。本号新推出「Go 工具箱」系列,意在给大家分享使用 go 语言编写的、实用的、好玩的工具。同时了解其底层的实现原理,以便更深入地了解 Go 语言。
关闭软件可以分为平滑关闭(软关闭)和硬关闭。就像我们在关闭电脑的时候,有时候遇到电脑死机,会直接长按开关键,直至电脑关机,这就是硬关机。 而通过电脑上的菜单选择“关机”,则属于软关机(平滑关闭)。在软关机的时候,大家应该会注意到时间会比较长,时不时还会有弹窗弹出 询问是否要退出。
在我们自己编写的 web 应用中,实际上也是需要有软关闭的。今天我就 golang 中的 gin 框架为例,来聊聊平滑关闭背后的处理逻辑。
一、为什么平滑关闭如此重要性?
硬关闭就是当程序收到关闭的信号后,立即将正在做的事情关闭。比如,我们在强制关机时,代码还没有保存,就会造成丢失。而平滑关闭具有如下优点:
第一,平滑关闭能够及时的释放资源。
第二,平滑关闭能够保证事务的完整性。
比如在 web 服务中,一个请求还没有返回,就被关闭了,那么影响体验。平滑关闭,能够等待请求处理完成后 连接再被关闭。所以,平滑关闭本质上就是当程序收到关闭的信号后,会等待程序把正在做的事情做完,释放掉所有的资源后再关闭服务。
二、web 服务是如何接收和处理请求的?
无论是正常关闭还是平滑关闭服务,本质上都是关闭服务的资源。所以,有必要先了解下 web 服务是如何启动的以及启动后是如何处理 http 请求。这样在关闭的时候就能对应的知道应该关闭哪些资源以及如何关闭了。
我们以 gin 框架为例来说明处理 http 请求的流程。
先构建一个 server 对象
根据传入的网络地址,建立网络监听器 listener。实际是建立了一个 socket。
将 listener 加入到 server 对象的一个资源池中,以代表 server 监听正在使用的 listener 资源。
listner 开始监听对应网络地址上(socket)的请求。
当有用户发起 http 请求时,Accept 函数就能监听到。
对新接收的请求创建一个一个 TCP 连接
将新的 TCP 连接包装成一个 conn 对象,同时将该 conn 对象加入到 server 的关羽 conn 的资源池中。这样 server 就能跟踪当前有多少个请求连接正在处理请求。
启动一个新的协程,异步处理该连接
读取请求内容
执行具体的处理逻辑
输出响应
请求结束,关闭本次的 TCP 连接。同时,从资源池中释放掉对应的 conn 资源。
继续回到 Accept 监听后续的 HTTP 请求。
通过以上流程图,我们实际上可以将 web server 处理 http 请求的整个过程分为两部分:创建网络监听器 listener(socket)阶段以及监听并处理 HTTP 请求阶段。 相应的,和这两个阶段相对应的使用到的资源就是网络监听器 listner 以及每个 HTTP 请求连接 conn。即上图中的 server 中的两个资源池。
对于两种资源,分别有存在不同的状态。下面我们简单看下两种资源的各自状态以及转换。
listener 资源的状态
listner 的作用就是监听网络连接。所以该资源有两种状态:正常和关闭状态。
conn 资源的状态
conn 本质上是一个 TCP 的连接,但 server 对象为了容易跟踪目前监听到的连接,所以将 TCP 连接包装成了 conn,并给 conn 定义了以下状态:新连接(New)、活跃状态(Active)、关闭状态(Closed)、空闲状态(Idle)和被劫持状态(Hijacked)。以下是各状态之间的转化关系:
在启动阶段是建立资源。那么,在 server 的关闭阶段,主要也就是要释放这些资源。那么该如何释放这些资源就是我们接下来要讨论的重点。
三、直接关闭 web 服务是关闭了什么?
在 web 框架中,server 的 Close 函数对应功能就是直接关闭 server 服务。在 gin 框架中,对应的代码如下:
从代码中可以看到,基本上是首先是给 server 对象设置关闭的标志位;然后关闭 doneChan;关闭所有的 listener 资源,以停止接收新的连接;最后,循环 conn 资源,依次关闭。
这里有一点需要注意,在关闭 conn 资源的时候,不管 conn 当前处于什么状态,都是立即关闭。也就是说如果一个 conn 正处于 Active 状态,代表着该请求还没处理完,那么也会立即终止处理。对于客户端的表现来说 就是收到“连接被拒绝,网站无法访问”的错误。
我们实验一下。如下代码是注册了路由"/home",在处理函数中我们等待了 5
秒,以便模拟在关闭的时候,我们我们的请求处理还没有完成的场景。然后,往下就是通过 signal.Notify 函数在 quit 通道上注册了程序终止的信号 os.Interrupt(即按 Ctrl+C),当通过在终端上按 Ctrl+C 发送了中断信号给 quit 通道时,就执行 server.Close()函数。如下代码:
好了,我们总结下直接关闭的特点就是:先关闭 server 对象;再关闭监听对象 listener 不再接收新连接;最后关闭所欲已建立的连接,无论该连接是否正在处理请求都立即关闭。 大家注意,这里关闭是有一个从大到小范围的顺序:先关闭范围大的(server 和 lisener),最后关闭具体的处理连接(conn)。
到这里,我们已经发现,直接关闭的缺点就是正在处理的请求也会被直接关闭。而且 server 依赖的外部资源(比如数据库连接等)也没有释放。 接下来我们看平滑关闭是如何解决这两个问题的。
四、平滑关闭 web 服务又是关闭了什么?
为了解决上述问题,平滑关闭就出现了。在 gin 框架中对应的是 Shutdown 函数,代码如下:
从以上代码来看,首先也是给 server 对象设置关闭的标志位;然后关闭所有的 listener 资源,以停止接收新的连接;再接着执行外部的注册函数,以关闭 server 的外部依赖资源(如数据库等)。最后,循环关闭空闲的 conn 资源。这也是和上述 Close 的关闭本质区别。
同样,我们以下面的代码为例,进行实验:
先启动服务,然后输入 http://localhost:8080/home,在按 Ctrl+C 终端程序,这时,在浏览器中看到的就是页面依然会进行输出。
好了,我们总结下平滑关闭的特点就是:会等待所有的连接都处理完成后再关闭。
五、web 服务是如何做到平滑关闭的?
那么,golang 中是如何监听到关闭事件从而和平滑关闭联系在一起的呢? 在上述的示例代码中我们也能发现,就是信号。信号是进程间通信的一种方式。在 golang 中,通过如下函数来监听信号:
当监听到对应对应的信号时,会往 quit 通道中写入一个消息。这时,quit 就不再阻塞,然后调用服务的 Shutdown 函数即可。
在这里需要注意两点:
启动服务需要放在一个协程中,这样
对于监听信号的通道 quit,需要使用一个 buffered channel。因为在 signal.Notify 函数中对于信号的监听,是不阻塞的。什么意思呢?就是说当监听到对应的信号时,如果没成功发往通道 quit,那么就会丢弃该信号。下面给出一个示例来说明使用 buffered channel 和 unbuffered channel 之间的区别。
这是一个非缓冲通道。 如果在前 5 秒之内一直按 Ctrl+C 动作,当过了 5 秒钟,程序往下执行到第 18 行的时候,这会程序不会收到退出的信号。原因就是因为 signal.Notify 函数在监听到中断信号后,由于往通道 c 中发送不成功而丢弃了该信号。
总结
平滑关闭的本质是将空闲的资源给释放掉。而能够将终止和关闭联系在一起的是信号机制。信号是进程间通信的手段之一,其底层实现是硬件的中断原理。若想详细了解信号和中断机制,推荐阅读《深入理解计算机系统》的第 8 章和王爽著的《汇编语言》第 12 到第 15 章的内中断和外中断。
---特别推荐---
特别推荐:一个专注 go 项目实战、项目中踩坑经验及避坑指南、各种好玩的 go 工具的公众号,「Go 学堂」,专注实用性,非常值得大家关注。点击下方公众号卡片,直接关注。关注送《100 个 go 常见的错误》pdf 文档。
版权声明: 本文为 InfoQ 作者【Go学堂】的原创文章。
原文链接:【http://xie.infoq.cn/article/94d6cdf862c69388312641719】。
本文遵守【CC-BY 4.0】协议,转载请保留原文出处及本版权声明。
评论