好得很程序员自学网

<tfoot draggable='sEl'></tfoot>

Go 实现热重启的详细介绍

今天带来Go 实现热重启的详细介绍教程详解

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

1.什么是热重启?

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

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

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

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

2.如何实现热重启?

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

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

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

听上去是挺简单的...

2.1.认识 fork

大家都知道 fork() 系统调用,父进程调用 fork 会创建一个进程副本,代码中还可以通过 fork 返回值是否为 0 来区分是子进程还是父进程。

int main(char **argv, int argc) {
 pid_t pid = fork();
 if (pid == 0) {
 printf("i am child process");
 } else {
 printf("i am parent process, i have a child process named %d", pid);
 }
}

可能有些开发人员不知道 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 发送 SIGKILL 信号给进程,这个信号不允许捕获,SIGABORT 也不允许捕获,这样可以允许进程所有者或者高权限用户控制进程生死,达到更好的管理效果。

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

go func() {
 ch := make(chan os.Signal, 1)
 signal.Notify(ch, os.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 三者之间的关系图:


以上就是关于Go 实现热重启的详细介绍全部内容,感谢大家支持。

查看更多关于Go 实现热重启的详细介绍的详细内容...

  阅读:48次