大家好,我是渔夫子。本号新推出「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()函数。如下代码:
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"time"
"github.com/gin-gonic/gin"
)
func main() {
router := gin.Default()
router.GET("/home", func(c *gin.Context) {
time.Sleep(5 * time.Second)
c.String(http.StatusOK, "Welcome Gin Server")
})
server := &http.Server{
Addr: ":8080",
Handler: router,
}
quit := make(chan os.Signal)
signal.Notify(quit, os.Interrupt)
server.RegisterOnShutdown(func(){
log.Println("start execute out shutown")
})
go func() {
if err := server.ListenAndServe(); err != nil {
if err == http.ErrServerClosed {
log.Println("Server closed under request")
} else {
log.Fatal("Server closed unexpect")
}
}
}()
<-quit
log.Println("receive interrupt signal")
//ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
//defer cancel()
if err := server.Close(); err != nil {
log.Fatal("Server Close:", err)
}
log.Println("Server exiting")
}
好了,我们总结下直接关闭的特点就是:先关闭server对象;再关闭监听对象listener不再接收新连接;最后关闭所欲已建立的连接,无论该连接是否正在处理请求都立即关闭。 大家注意,这里关闭是有一个从大到小范围的顺序:先关闭范围大的(server和lisener),最后关闭具体的处理连接(conn)。
到这里,我们已经发现,直接关闭的缺点就是正在处理的请求也会被直接关闭。而且server依赖的外部资源(比如数据库连接等)也没有释放。 接下来我们看平滑关闭是如何解决这两个问题的。
四、平滑关闭web服务又是关闭了什么?
为了解决上述问题,平滑关闭就出现了。在gin框架中对应的是Shutdown函数,代码如下:
从以上代码来看,首先也是给server对象设置关闭的标志位;然后关闭所有的listener资源,以停止接收新的连接;再接着执行外部的注册函数,以关闭server的外部依赖资源(如数据库等)。最后,循环关闭空闲的conn资源。这也是和上述Close的关闭本质区别。
同样,我们以下面的代码为例,进行实验:
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"time"
"github.com/gin-gonic/gin"
)
func main() {
router := gin.Default()
router.GET("/home", func(c *gin.Context) {
time.Sleep(5 * time.Second)
c.String(http.StatusOK, "Welcome Gin Server")
})
server := &http.Server{
Addr: ":8080",
Handler: router,
}
quit := make(chan os.Signal)
signal.Notify(quit, os.Interrupt)
server.RegisterOnShutdown(func(){
log.Println("start execute out shutown")
})
go func() {
if err := server.ListenAndServe(); err != nil {
if err == http.ErrServerClosed {
log.Println("Server closed under request")
} else {
log.Fatal("Server closed unexpect")
}
}
}()
<-quit
log.Println("receive interrupt signal")
//ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
//defer cancel()
if err := server.Shutdown(context.Background()); err != nil {
log.Fatal("Server Close:", err)
}
log.Println("Server exiting")
}
先启动服务,然后输入http://localhost:8080/home,在按Ctrl+C终端程序,这时,在浏览器中看到的就是页面依然会进行输出。
好了,我们总结下平滑关闭的特点就是:会等待所有的连接都处理完成后再关闭。
五、web服务是如何做到平滑关闭的?
那么,golang中是如何监听到关闭事件从而和平滑关闭联系在一起的呢? 在上述的示例代码中我们也能发现,就是信号。
信号是进程间通信的一种方式。在golang中,通过如下函数来监听信号:
quit := make(chan os.Signal)
signal.Notify(quit, os.Interrupt)
当监听到对应对应的信号时,会往quit通道中写入一个消息。这时,quit就不再阻塞,然后调用服务的Shutdown函数即可。
在这里需要注意两点:
- 启动服务需要放在一个协程中,这样
对于监听信号的通道quit,需要使用一个buffered channel。因为在signal.Notify函数中对于信号的监听,是不阻塞的。什么意思呢?就是说当监听到对应的信号时,如果没成功发往通道quit,那么就会丢弃该信号。下面给出一个示例来说明使用buffered channel和unbuffered channel之间的区别。
package main import ( "fmt" "os" "os/signal" ) func main() { // Set up channel on which to send signal notifications. // We must use a buffered channel or risk missing the signal // if we're not ready to receive when the signal is sent. c := make(chan os.Signal) signal.Notify(c, os.Interrupt) time.Sleep(5*time.Second) // Block until a signal is received. s := <-c fmt.Println("Got signal:", s) }
这是一个非缓冲通道。 如果在前5秒之内一直按Ctrl+C动作,当过了5秒钟,程序往下执行到第18行的时候,这会程序不会收到退出的信号。原因就是因为signal.Notify函数在监听到中断信号后,由于往通道c中发送不成功而丢弃了该信号。
总结
平滑关闭的本质是将空闲的资源给释放掉。而能够将终止和关闭联系在一起的是信号机制。信号是进程间通信的手段之一,其底层实现是硬件的中断原理。若想详细了解信号和中断机制,推荐阅读《深入理解计算机系统》的第8章和王爽著的《汇编语言》第12到第15章的内中断和外中断。
特别推荐:一个专注go项目实战、项目中踩坑经验及避坑指南、各种好玩的go工具的公众号,「Go学堂」,专注实用性,非常值得大家关注。点击下方公众号卡片,直接关注。关注送《100个go常见的错误》pdf文档。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。