最近在优化公司框架trpc时发现了一个热重启相关的问题,优化之余也总结沉淀下,对go如何实现热重启这方面的内容做一个简单的梳理。

1.什么是热重启?

热重启(Hot Restart),是一项保证服务可用性的手段。它允许服务重启期间,不中断已经建立的连接,老服务进程不再接受新连接请求,新连接请求将在新服务进程中受理。对于原服务进程中已经建立的连接,也可以将其设为读关闭,等待平滑处理完连接上的请求及连接空闲后再行退出。通过这种方式,可以保证已建立的连接不中断,连接上的事务(请求、处理、响应)可以正常完成,新的服务进程也可以正常接受连接、处理连接上的请求。当然,热重启期间进程平滑退出涉及到的不止是连接上的事务,也有消息服务、自定义事务需要关注。

这是我理解的热重启的一个大致描述。热重启现在还有没有存在的必要?我的理解是看场景。

以后台开发为例,假如运维平台有能力在服务升级、重启时自动踢掉流量,服务就绪后又自动加回流量,假如能够合理预估服务QPS、请求处理时长,那么只要配置一个合理的停止前等待时间,是可以达到类似热重启的效果的。这样的话,在后台服务里面支持热重启就显得没什么必要。但是,如果我们开发一个微服务框架,不能对将来的部署平台、环境做这种假设,也有可能使用方只是部署在一两台物理机上,也没有其他的负载均衡设施,但不希望因为重启受干扰,热重启就很有必要。当然还有一些更复杂、要求更苛刻的场景,也需要热重启的能力。

热重启是比较重要的一项保证服务质量的手段,还是值得了解下的,这也是本文介绍的初衷。

2.如何实现热重启?

如何实现热重启,这里其实不能一概而论,要结合实际的场景来看(比如服务编程模型、对可用性要求的高低等)。大致的实现思路,可以先抛一下。

一般要实现热重启,大致要包括如下步骤:

  • 首先,要让老进程,这里称之为父进程了,先要fork出一个子进程来代替它工作;
  • 然后,子进程就绪之后,通知父进程,正常接受新连接请求、处理连接上收到的请求;
  • 再然后,父进程处理完已建立连接上的请求后、连接空闲后,平滑退出。

听上去是挺简单的...

2.1.认识fork

fork()

可能有些开发人员不知道fork的实现原理,或者不知道fork返回值为什么在父子进程中不同,或者不知道如何做到父子进程中返回值不同……了解这些是要有点知识积累的。

2.2.返回值

简单概括下,ABI定义了进行函数调用时的一些规范,如何传递参数,如何返回值等等,以x86为例,如果返回值是rax寄存器能够容的一般都是通过rax寄存器返回的。

如果rax寄存器位宽无法容纳下的返回值呢?也简单,编译器会安插些指令来完成这些神秘的操作,具体是什么指令,就跟语言编译器实现相关了。

  • c语言,可能会将返回值的地址,传递到rdi或其他寄存器,被调函数内部呢,通过多条指令将返回值写入rdi代指的内存区;
  • c语言,也可能在被调函数内部,用多个寄存器rax,rdx...一起暂存返回结果,函数返回时再将多个寄存器的值赋值到变量中;
  • 也可能会像golang这样,通过栈内存来返回;

2.3.fork返回值

fork系统调用的返回值,有点特殊,在父进程和子进程中,这个函数返回的值是不同的,如何做到的呢?

联想下父进程调用fork的时候,操作系统内核需要干些什么呢?分配进程控制块、分配pid、分配内存空间……肯定有很多东西啦,这里注意下进程的硬件上下文信息,这些是非常重要的,在进程被调度算法选中进行调度时,是需要还原硬件上下文信息的。

Linux fork的时候,会对子进程的硬件上下文进行一定的修改,我就是让你fork之后拿到的pid是0,怎么办呢?前面2.2节提过了,对于那些小整数,rax寄存器存下绰绰有余,fork返回时就是将操作系统分配的pid放到rax寄存器的。

那,对于子进程而言,我只要在fork的时候将它的硬件上下文rax寄存器清0,然后等其他设置全ok后,再将其状态从不可中断等待状态修改为可运行状态,等其被调度器调度时,会先还原其硬件上下文信息,包括PC、rax等等,这样fork返回后,rax中值为0,最终赋值给pid的值就是0。

因此,也就可以通过这种判断 “pid是否等于0” 的方式来区分当前进程是父进程还是子进程了。

2.4.局限性

很多人清楚fork可以创建一个进程的副本并继续往下执行,可以根据fork返回值来执行不同的分支逻辑。如果进程是多线程的,在一个线程中调用fork会复制整个进程吗?

fork只能创建调用该函数的线程的副本,进程中其他运行的线程,fork不予处理。这就意味着,对于多线程程序而言,寄希望于通过fork来创建一个完整进程副本是不可行的。

前面我们也提到了,fork是实现热重启的重要一环,fork这里的这个局限性,就制约着不同服务编程模型下的热重启实现方式。所以我们说具体问题具体分析,不同编程模型下实际上可以采用不同的实现方式。

3.单进程单线程模型

单进程单线程模型,可能很多人一听觉得它已经被淘汰了,生产环境中不能用,真的么?强如redis,不就是单线程。强调下并非单线程模型没用,ok,收回来,现在关注下单进程单线程模型如何实现热重启。

单进程单线程,实现热重启会比较简单些:

  • fork一下就可以创建出子进程,
  • 子进程可以继承父进程中的资源,如已经打开的文件描述符,包括父进程的listenfd、connfd,
  • 父进程,可以选择关闭listenfd,后续接受连接的任务就交给子进程来完成了,
  • 父进程,甚至也可以关闭connfd,让子进程处理连接上的请求、回包等,也可以自身处理完已建立的连接上的请求;
  • 父进程,在合适的时间点选择退出,子进程开始变成顶梁柱。

核心思想就是这些,但是具体到实现,就有多种方法:

  • 可以选择fork的方式让子进程拿到原来的listenfd、connfd,
  • 也可以选择unixdomain socket的方式父进程将listenfd、connfd发送给子进程。

有同学可能会想,我不传递这些fd行吗?

  • 比如我开启了reuseport,父进程直接处理完已建立连接connfd上的请求之后关闭,子进程里reuseport.Listen直接创建新的listenfd。

也可以!但是有些问题必须要提前考虑到:

  • reuseport虽然允许多个进程在同一个端口上多次listen,似乎满足了要求,但是要知道只要euid相同,都可以在这个端口上listen!是不安全的!
  • reuseport实现和平台有关系,在Linux平台上在同一个address+port上listen多次,多个listenfd底层可以共享同一个连接队列,内核可以实现负载均衡,但是在darwin平台上却不会!

当然这里提到的这些问题,在多线程模型下肯定也存在。

4.单进程多线程模型

前面提到的问题,在多线程模型中也会出现:

  • fork只能复制calling thread,not whole process!
  • reuseport多次在相同地址+端口listen得到的多个fd,不同平台有不同的表现,可能无法做到接受连接时的load banlance!
  • 非reuseport情况下,多次listen会失败!
  • 不传递fd,直接通过reuseport来重新listen得到listenfd,不安全,不同服务进程实例可能会在同一个端口上监听,gg!
  • 父进程平滑退出的逻辑,关闭listenfd,等待connfd上请求处理结束,关闭connfd,一切妥当后,父进程退出,子进程挑大梁!

5. 其他线程模型

其他线程都基本上避不开上述3、4的实现或者组合,对应问题相仿,不再赘述。

6. go实现热重启:触发时机

需要选择一个时机来触发热重启,什么时候触发呢?操作系统提供了信号机制,允许进程做出一些自定义的信号处理。

kill -9

kill也可以用来发送其他信号给进程,如发送SIGUSR1、SIGUSR2、SIGINT等等,进程中可以接收这些信号,并针对性的做出处理。这里可以选择SIGUSR1或者SIGUSR2来通知进程热重启。

7. 如何判断热重启

那一个go程序重新启动之后,所有运行时状态信息都是新的,那如何区分自己是否是子进程呢,或者说我是否要执行热重启逻辑呢?父进程可以通过设置子进程初始化时的环境变量,比如加个HOT_RESTART=1。

这就要求代码中在合适的地方要先检测环境变量HOT_RESTART是否为1,如果成立,那就执行热重启逻辑,否则就执行全新的启动逻辑。

8. ForkExec

假如当前进程收到SIGUSR2信号之后,希望执行热重启逻辑,那么好,需要先执行syscall.ForkExec(...)来创建一个子进程,注意go不同于cc++,它本身就是依赖多线程来调度协程的,天然就是多线程程序,只不过是他没有使用NPTL线程库来创建,而是通过clone系统调用来创建。

前面提过了,如果单纯fork的话,只能复制调用fork函数的线程,对于进程中的其他线程无能为力,所以对于go这种天然的多线程程序,必须从头来一遍,再exec一下。所以go标准库提供的函数是syscall.ForkExec而不是syscall.Fork。

9. go实现热重启: 传递listenfd

go里面传递fd的方式,有这么几种,父进程fork子进程的时候传递fd,或者后面通过unix domain socket传递。需要注意的是,我们传递的实际上是file description,而非file descriptor。

附上一张类unix系统下file descriptor、file description、inode三者之间的关系图:

fd分配都是从小到大分配的,父进程中的fd为10,传递到子进程中之后有可能就不是10。那么传递到子进程的fd是否是可以预测的呢?可以预测,但是不建议。所以我提供了两种实现方式。

9.1 ForkExec+ProcAttr{Files: []uintptr{}}

tcpln := ln.(*net.TCPListener); file, _ := tcpln.File(); fd := file.FD()

需要注意的是,这里的fd并非底层的file description对应的初始fd,而是被dup2复制出来的一个fd(调用tcpln.File()的时候就已经分配了),这样底层file description引用计数就会+1。如果后面想通过ln.Close()关闭监听套接字的话,sorry,关不掉。这里需要显示的执行 file.Close() 将新创建的fd关掉,使对应的file description引用计数-1,保证Close的时候引用计数为0,才可以正常关闭。

试想下,我们想实现热重启,是一定要等连接上接收的请求处理完才可以退出进程的,但是这期间父进程不能再接收新的连接请求,如果这里不能正常关闭listener,那我们这个目标就无法实现。所以这里对dup出来的fd的处理要慎重些,不要遗忘。

file.FD()

子进程中接收到这些fd之后,在类unix系统下一般会按照从0、1、2、3这样递增的顺序来分配fd,那么传递过去的fd是可以预测的,假如除了stdin, stdout, stderr再传两个listenfd,那么可以预测这两个的fd应该是3,4。在类unix系统下一般都是这么处理的,子进程中就可以根据传递fd的数量(比如通过环境变量传递给子进程FD_NUM=2),来从3开始计算,哦,这两个fd应该是3,4。

父子进程可以通过一个约定的顺序,来组织传递的listenfd的顺序,以方便子进程中按相同的约定进行处理,当然也可以通过fd重建listener之后来判断对应的监听network+address,以区分该listener对应的是哪一个逻辑service。都是可以的!

file, _ := os.NewFile(fd); tcplistener := net.FileListener(file)udpconn := net.PacketConn(file)
前面提到file.FD()会将底层的file description设置为阻塞模式,这里再补充下,net.FileListener(f), net.PacketConn(f)内部会调用newFileFd()->dupSocket(),这几个函数内部会将fd对应的file description重新设置为非阻塞。父子进程中共享了listener对应的file description,所以不需要显示设置为非阻塞。

有些微服务框架是支持对服务进行逻辑service分组的,google pb规范中也支持多service定义,这个在腾讯的goneat、trpc框架中也是有支持的。

当然了,这里我不会写一个完整的包含上述所有描述的demo给大家,这有点占篇幅,这里只贴一个精简版的实例,其他的读者感兴趣可以自己编码测试。须知纸上得来终觉浅,还是要多实践。

这里用简单的代码大致解释了如何用ProcAttr来传递listenfd。这里有个问题,假如后续父进程中传递的fd修改了呢,比如不传stdin, stdout, stderr的fd了,怎么办?服务端是不是要开始预测应该从0开始编号了?我们可以通过环境变量通知子进程,比如传递的fd从哪个编号开始是listenfd,一共有几个listenfd,这样也是可以实现的。

这种实现方式可以跨平台。

感兴趣的话,可以看下facebook提供的这个实现。

9.2 unix domain socket + cmsg

另一种,思路就是通过unix domain socket + cmsg来传递,父进程启动的时候依然是通过ForkExec来创建子进程,但是并不通过ProcAttr来传递listenfd。

父进程在创建子进程之前,创建一个unix domain socket并监听,等子进程启动之后,建立到这个unix domain socket的连接,父进程此时开始将listenfd通过cmsg发送给子进程,获取fd的方式与9.1相同,该注意的fd关闭问题也是一样的处理。

子进程连接上unix domain socket,开始接收cmsg,内核帮子进程收消息的时候,发现里面有一个父进程的fd,内核找到对应的file description,并为子进程分配一个fd,将两者建立起映射关系。然后回到子进程中的时候,子进程拿到的就是对应该file description的fd了。通过os.NewFile(fd)就可以拿到file,然后再通过net.FileListener或者net.PacketConn就可以拿到tcplistener或者udpconn。

剩下的获取监听地址,关联逻辑service的动作,就与9.1小结描述的一致了。

这里我也提供一个可运行的精简版的demo,供大家了解、测试用。

这种实现方式,仅限类unix系统。

如果有服务混布的情况存在,需要考虑下使用的unix domain socket的文件名,避免因为重名所引起的问题,可以考虑通过”进程名.pid“来作为unix domain socket的名字,并通过环境变量将其传递给子进程。

10. go实现热重启: 子进程如何通过listenfd重建listener

前面已经提过了,当拿到fd之后还不知道它对应的是tcp的listener,还是udpconn,那怎么办?都试下呗。

11. go实现热重启:父进程平滑退出

父进程如何平滑退出呢,这个要看父进程中都有哪些逻辑要平滑停止了。

11.1. 处理已建立连接上请求

可以从这两个方面入手:

  • shutdown read,不再接受新的请求,对端继续写数据的时候会感知到失败;
  • 继续处理连接上已经正常接收的请求,处理完成后,回包,close连接;

也可以考虑,不进行读端关闭,而是等连接空闲一段时间后再close,是否尽快关闭更符合要求就要结合场景、要求来看。

如果对可用性要求比较苛刻,可能也会需要考虑将connfd、connfd上已经读取写入的buffer数据也一并传递给子进程处理。

11.2. 消息服务

  • 确认下自己服务的消息消费、确认机制是否合理
  • 不再收新消息
  • 处理完已收到的消息后,再退出

11.3. 自定义AtExit清理任务

有些任务会有些自定义任务,希望进程在退出之前,能够执行到,这种可以提供一个类似AtExit的注册函数,让进程退出之前能够执行业务自定义的清理逻辑。

不管是平滑重启,还是其他正常退出,对该支持都是有一定需求的。

12. 其他

有些场景下也希望传递connfd,包括connfd上对应的读写的数据。

比如连接复用的场景,客户端可能会通过同一个连接发送多个请求,假如在中间某个时刻服务端执行热重启操作,服务端如果直接连接读关闭会导致后续客户端的数据发送失败,客户端关闭连接则可能导致之前已经接收的请求也无法正常响应。 这种情况下,可以考虑服务端继续处理连接上请求,等连接空闲再关闭。会不会一直不空闲呢?有可能。

其实服务端不能预测客户端是否会采用连接复用模式,选择一个更可靠的处理方式会更好些,如果场景要求比较苛刻,并不希望通过上层重试来解决的话。这种可以考虑将connfd以及connfd上读写的buffer数据一并传递给子进程,交由子进程来处理,这个时候需要关注的点更多,处理起来更复杂,感兴趣的可以参考下mosn的实现。

13. 总结

热重启作为一种保证服务平滑重启、升级的实现方式,在今天看来依然非常有价值。本文描述了实现热重启的一些大致思路,并且通过demo循序渐进地描述了在go服务中如何予以实现。虽然没有提供一个完整的热重启实例给大家,但是相信大家读完之后应该已经可以亲手实现了。

由于作者本人水平有限,难免会有描述疏漏之处,欢迎大家指正。

参考文章

  1. Unix高级编程、进程间通信, Steven Richards