# 06|重启:如何进行优雅关闭? 你好,我是轩脉刃。 通过前面几节课的学习,我们已经能启动一个按照路由规则接收请求、进入控制器计算逻辑的服务器了。 但是,在实际业务开发过程中,功能和需求一定是不断迭代的,在迭代过程中,势必需要重启服务,这里的重启就是指一个关闭、启动进程的完成过程。 目前所有服务基本都无单点问题,都是集群化部署。对一个服务的关闭、启动进程来说,启动的流程基本上问题不大,可以由集群的统一管理器,比如Kubernetes,来进行服务的启动,启动之后慢慢将流量引入到新启动的节点,整个服务是无损的。 但是在关闭服务的过程中,要考虑的情况就比较复杂了,比如说有服务已经在连接请求中怎么办?如果关闭服务的操作超时了怎么办?所以这节课我们就来研究下如何优雅关闭一个服务。 ## 如何优雅关闭 什么叫优雅关闭?你可以对比着想,不优雅的关闭比较简单,就是什么都不管,强制关闭进程,这明显会导致有些连接被迫中断。 或许你并没有意识到这个问题的严重性,不妨试想下,当一个用户在购买产品的时候,由于不优雅关闭,请求进程中断,导致用户的钱包已经扣费了,但是商品还未进入用户的已购清单中。这就会给用户带来实质性的损失。 所以,优雅关闭服务,其实说的就是,关闭进程的时候,不能暴力关闭进程,而是要等进程中的所有请求都逻辑处理结束后,才关闭进程。按照这个思路,需要研究两个问题“**如何控制关闭进程的操作**” 和 “**如何等待所有逻辑都处理结束**”。 当我们了解了如何控制进程关闭操作,就可以延迟关闭进程行为,设置为等连接的逻辑都处理结束后,再关闭进程。 ### 如何控制关闭进程的操作 那么第一个问题,如何控制关闭进程的操作怎么解决?你可以先想想平时关闭一个进程的方法有哪些,如果这些方法都有办法控制关闭操作,那么是不是就达到目的了。 * Ctrl+C 在终端,在非后台模式下启动一个进程的时候,要想关闭,我们在控制台会使用 Ctrl+C 来关闭进程。不管在 Unix 类的系统,还是在 Windows 系统中,Ctrl+C 都是向进程发送信号 SIGINT,这个信号代表的是中断,常用在通过键盘通知前台进程关闭程序的情景中。这个信号是可以被阻塞和处理的。 * Ctrl+\\ 这个键盘操作是向进程发送信号 SIGQUIT,这个信号其实和 SIGINT 差不多,也是可以被阻塞和处理的,它们都是为了通知进程结束,唯一不同的是,进程处理 QUIT 退出的时候,默认行为会产生 core 文件。 * Kill 命令 当使用后台模式挂起一个进程的时候,操作系统会给这个进程分配一个进程号 pid, 我们可以通过 kill pid 或者 kill -9 pid 来杀死某个进程。 kill pid 会向进程发送 SIGTERM 信号,而 kill -9 会向进程发送 SIGKILL 信号。这两个信号都用于立刻结束进程,但是 SIGTERM 是可以被阻塞和处理的,而 SIGKILL 信号是不能被阻塞和处理的。 用表格总结一下终止进程的这几个信号和对应的操作:![](https://static001.geekbang.org/resource/image/ff/eb/ff73733e54b5f94a3cbe6af2b3cc94eb.jpg?wh=1920x1080) 除了 SIGKILL 信号无法被捕获之外,其他的信号都能捕获,所以,只要在程序中捕获住这些信号,就能实现控制关闭进程操作了。那么接下来要解决的问题就是,在 Golang 中如何捕获信号呢? 对于这个问题,标准库提供了 os/signal 这个库,还记得第一节课说的快速了解一个库的方法么,**库函数 > 结构定义 > 结构函数。** ### os/signal 库 所以,第一步我们使用 `go doc os/signal|grep "^func"` 来了解下这个库的函数,看看提供了哪些功能。 ```go // 忽略某个信号 func Ignore(sig ...os.Signal){} // 判断某个信号是否被忽略了 func Ignored(sig os.Signal) bool{} // 关注某个/某些/全部 信号 func Notify(c chan<- os.Signal, sig ...os.Signal){} // 取消使用 notify 对信号产生的效果 func Reset(sig ...os.Signal){} // 停止所有向 channel 发送的效果 func Stop(c chan<- os.Signal){} ``` 这个库提供了订阅信号的方法 Notify 和忽略信号的方法 Ignore ,为了全局管理方便,也提供了停止所有订阅的 Stop 函数。另外还有,停止某些订阅的 Reset 函数,当我们已经订阅了某些信号之后,想重新将其中的某些信号不进行订阅,那么可以使用Reset方法。 然后就是第二、第三步,通过 `go doc os/signal|grep "^type"` 了解到,这个库比较简单,没有任何结构定义和结构函数,因为管理信号只需要几个库函数即可,不需要进行更多的模块划分和数据结构抽象。在Golang的官方类库中,有不少都是这样只提供库函数,而没有自定义的模块数据结构的。 理解完了捕获信号的 os/signal 库,我们就明白了,要控制这些信号量可以使用 Notify 方法,所以在业务main.go里补充: ```go func main() { ... // 这个 Goroutine 是启动服务的 Goroutine go func() { server.ListenAndServe() }() // 当前的 Goroutine 等待信号量 quit := make(chan os.Signal) // 监控信号:SIGINT, SIGTERM, SIGQUIT signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) // 这里会阻塞当前 Goroutine 等待信号 <-quit ... } ``` 注意下这里有两个 Goroutine,一个 Goroutine 是提供启动服务的,另外一个 Goroutine 用于监听信号并且结束进程。那么哪个Goroutine用于监听信号呢? 答案是main 函数所在的当前 Goroutine。因为使用Ctrl或者kill命令,它们发送的信号是进入main函数的,即只有main函数所在的Goroutine会接收到,**所以必须在main函数所在的Goroutine监听信号**。 在监听信号的 Goroutine 中,我们先创建了一个等待信号量的 channel,然后通过 Notify 方法,订阅 SIGINT、SIGTERM、SIGQUIT 三个可以捕获处理的信号量,并且将信号量导入到 channel 中。 最后,使用 channel 的导出操作,来阻塞当前 Goroutine,让当前 Goroutine 只有捕获到结束进程的信号之后,才进行后续的关闭操作。这样就实现了第一个问题进程关闭的可控。 ## 如何等待所有逻辑都处理结束 然后就是第二个问题“如何等待所有逻辑都处理结束”。 在 Golang 1.8 版本之前,net/http 是没有提供方法的,所以当时开源社区涌现了不少第三方解决方案: [manners](https://github.com/braintree/manners) 、 [graceful](https://github.com/tylerstillwater/graceful) 、 [grace](https://github.com/facebookarchive/grace) 。 它们的思路都差不多:自定义一个Server数据结构,其中包含net/http的Server数据结构,以及和net/http中Server一样的启动服务函数,在这个函数中,除了调用启动服务,还设计了一个监听事件的函数。监听事件结束后,通过channel等机制来等待主流程结束。 而在1.8版本之后,net/http引入了server.Shutdown来进行优雅重启。 **server.Shutdown方法是个阻塞方法,****一旦执行之后,它会阻塞当前 Goroutine,并且在所有连接请求都结束之后,才继续往后执行**。实现非常容易,思路也和之前的第三方解法差不多,所以就重点理解这个方法。 ### server.Shutdown源码 来看server.Shutdown的源码,同样你可以通过IDE跳转工具直接跳转到Shutdown源码进行阅读,使用第一节课教的思维导图的方式,列出Shutdown函数的代码逻辑流程图。我们还是从前往后讲。 ![](https://static001.geekbang.org/resource/image/5a/83/5ab0b74f880bed2273490c71b4d44783.png?wh=1454x303) 第一层,在运行Shutdown方法的时候,先做一个标记,将server中的isShutdown标记为true。 ```go srv.inShutdown.setTrue() func (b *atomicBool) setTrue()    { atomic.StoreInt32((*int32)(b), 1)  ``` 这里标准库实现的就很细节了。inShutdown是一个标记,它用来标记服务器是否正在关闭,**标记的时候,还使用了 atomic 操作来保证标记的原子性**。这里要琢磨一下,为什么要使用atomic操作呢? 在Golang中,所有的赋值操作都不能保证是原子的,比如int类型的a=a+1,或者bool类型的a=true,这些赋值操作,在底层并不一定是由一个独立的CPU指令完成的。所以在并发场景下,我们并不能保证并发赋值的操作是安全的。 比如有两个操作同时对a变量进行读写,写a变量的线程如果不是原子的,那么读a变量的线程就有可能读到写了一半的a变量。 所以为保证原子性,Golang提供了一个atomic包,当对一个字段赋值的时候,**如果你无法保证其是否原子操作,你可以使用atomic包来对这个字段进行赋值**。atomic包,在底层一定会保证,这个操作是在一个单独的CPU指令内完成的。 因为这里的srv.inShutdown是一个非常重要的标记位。一旦由于任何原因,它读取错误,会发生严重问题,比如进程已经在处理结束的时候,启动server的进程还继续监听请求,这个时候会导致新接收的请求有服务错误。所以,这里为了保险起见,使用了一个标准库atomic来保证其原子性操作。 然后是逻辑代码: ```go for _, f := range srv.onShutdown { go f() } ``` onShutdown在server结构中按需求设置。这个字段保存的是回调方法,即用户希望server在关闭时进行的回调操作。比如用户可以设置在服务结束的时候,打印一个日志或者调用一个通知机制。如果用户设置了回调,则执行这些回调条件,如果没有设置,可以忽略。 ### for循环 接下来进入这一层最重要的for循环。这个for循环是一个无限循环,它使用ticker来控制每次循环的节奏,通过return来控制循环的终止条件。这个写法很值得我们学习。 ```go ticker := time.NewTicker(shutdownPollInterval) // 设置轮询时间 defer ticker.Stop() for { // 真正的操作 if srv.closeIdleConns() && srv.numListeners() == 0 { return lnerr } select { case <-ctx.Done(): // 如果ctx有设置超时,有可能触发超时结束 return ctx.Err() case <-ticker.C:  // 如果没有结束,最长等待时间,进行轮询 } } ``` 我们在工作中经常会遇到类似的需求:每隔多少时间,执行一次操作,应该有不少同学会使用time.Sleep来做间隔时长,而这里演示了如何使用time.Ticker来进行轮询设置。这两种方式其实都能完成“每隔多少时间做一次操作”,但是又有一些不同。 time.Sleep是用阻塞当前Goroutine的方式来实现的,它需要调度先唤醒当前Goroutine,才能唤醒后续的逻辑。**而Ticker创建了一个底层数据结构定时器runtimeTimer,并且监听runtimeTimer计时结束后产生的信号**。 这个runtimeTimer是Golang定义的定时器,做了一些比较复杂的优化。比如在有海量定时器的场景下,runtimeTimer会为每个核,创建一个runtimeTimer,进行统一调度,所以它的CPU消耗会远低于time.Sleep。所以说,使用ticker是Golang中最优的定时写法。 ![](https://static001.geekbang.org/resource/image/07/18/07db466da8f4da1de087b7b9995d5318.png?wh=1454x182) 再回到源码思维导图中,可以看到真正执行操作的是 closeIdleConns 方法。这个方法的逻辑就是:判断所有连接中的请求是否已经完成操作(是否处于Idle状态),如果完成,关闭连接,如果未完成,则跳过,等待下次循环。 ```go // closeIdleConns 关闭所有的连接并且记录是否服务器的连接已经全部关闭 func (s *Server) closeIdleConns() bool { s.mu.Lock() defer s.mu.Unlock() quiescent := true for c := range s.activeConn { st, unixSec := c.getState() // Issue 22682: 这里预留5s以防止在第一次读取连接头部信息的时候超过5s if st == StateNew && unixSec < time.Now().Unix()-5 { st = StateIdle } if st != StateIdle || unixSec == 0 { // unixSec == 0 代表这个连接是非常新的连接,则标记位需要标记false quiescent = false continue } c.rwc.Close() delete(s.activeConn, c) } return quiescent } ``` 这个函数返回的quiescent标记位,是用来标记是否所有的连接都已经关闭。如果有一个连接还未关闭,标记位返回false,否则返回true。 现在源码就梳理好了,再整理一下。 为了实现先阻塞,然后等所有连接处理完再结束退出,Shutdown 使用了两层循环。其中: * 第一层循环是定时无限循环,每过ticker的间隔时间,就进入第二层循环; * 第二层循环会遍历连接中的所有请求,如果已经处理完操作处于Idle状态,就关闭连接,直到所有连接都关闭,才返回。 所以我们可以在业务代码main.go中这么写: ```go func main() { ... // 当前的 Goroutine 等待信号量 quit := make(chan os.Signal) // 监控信号:SIGINT, SIGTERM, SIGQUIT signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) // 这里会阻塞当前 Goroutine 等待信号 <-quit // 调用Server.Shutdown graceful结束 if err := server.Shutdown(context.Background()); err != nil { log.Fatal("Server Shutdown:", err) } } ``` 在监听到关闭进程的信号之后,直接执行server.Shutdown操作,等待这个程序执行结束,再结束main函数,就可以了。 ## 验证 到这里,我们就完成了优雅关闭的逻辑。最后验证成果,写一个10s才能结束的控制器: ```go func UserLoginController(c *framework.Context) error { foo, _ := c.QueryString("foo", "def") // 等待10s才结束执行 time.Sleep(10 * time.Second) // 输出结果 c.SetOkStatus().Json("ok, UserLoginController: " + foo) return nil } ``` 按顺序执行下列操作,就能检验出你的关闭逻辑能不能跑通了。 1. 在控制台启动Web服务 2. 在浏览器启动一个请求进入10s才能结束的控制器 3. 10s内在控制台执行Ctrl+C关闭程序 4. 观察控制台程序是否不会立刻结束,而是在10s后结束 5. 浏览器端正常输出 依次操作后,你在控制台可以看到,在执行完成URI之后,程序才退出。 ![](https://static001.geekbang.org/resource/image/77/0a/777151e8f5604b3fa6cf228ee3956f0a.png?wh=1488x252) 而且,浏览器中正常输出控制器结果。说明你已经完整完成了优雅关闭逻辑! ![](https://static001.geekbang.org/resource/image/1f/7a/1fd82869f6a25c1fe856356b0d59547a.png?wh=563x118) 今天只修改了业务文件夹中的main.go代码,框架目录并没有什么变化。 ![](https://static001.geekbang.org/resource/image/e0/69/e0e66094f173768b93e2d121e1556069.png?wh=744x1392) 有的同学可能会很奇怪,重启这个逻辑不应该放在框架目录的某个地方么,难道每次启动一个服务都要写这个逻辑么?不急,先了解掌握好优雅关闭的原理,在后续章节我们会为框架引入命令行工具,这些优雅关闭的逻辑就会作为框架的一部分存放在框架目录中了。 ## 小结 今天完成了优雅关闭进程的逻辑,通过标准库os.Signal来控制关闭进程的操作,并且通过net/http提供的server.Shutdown来实现优雅关闭。所有代码都同样放在GitHub上的 [geekbang/06](https://github.com/gohade/coredemo/tree/geekbang/06) 分支了。 讲了很多代码细节,相信你看完shutdown这个函数的实现原理后,会不由得感叹Golang源码的优雅。很多同学会说没有好的Golang项目可以跟着学习,其实Golang源码本身就是一个非常好的学习资料。 如果你能对其中的每个细节点画出思维导图,顺着导图中的分支展开分析,思考作者为什么会选择这种写法、有没有其他写法,多多练习,你一定会受益颇丰。 ### 思考题 在如何控制关闭进程操作中,阻塞的最长时间实际上也是可以进行控制的,请尝试一下修改代码,控制优雅关闭的最长等待时间为5s? 欢迎在留言区分享你的思考。感谢你的收听,如果你觉得今天的内容有所帮助,也欢迎分享给你身边的朋友,邀请他一起学习。我们下节课见~