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

1.什么是热重启?

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

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

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

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

2.如何实现热重启?

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

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

  • 首先,要让老进程,这里称之为父进程了,先要 fork 出一个子进程来代替它工作;

  • 然后,子进程就绪之后,通知父进程,正常接受新连接请求、处理连接上收到的请求;

  • 再然后,父进程处理完已建立连接上的请求后、连接空闲后,平滑退出。

听上去是挺简单的...

2.1.认识 fork

fork()
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

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

go func() {
    ch := make(chan os.Signal, 1)
    signal.Notify(ch, os.SIGUSR2)
    <- ch

    //接下来就可以做热重启相关的逻辑了
    ...
}()

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 给大家,这有点占篇幅,这里只贴一个精简版的实例,其他的读者感兴趣可以自己编码测试。须知纸上得来终觉浅,还是要多实践。

package main

import (
 "fmt"
 "io/ioutil"
 "log"
 "net"
 "os"
 "strconv"
 "sync"
 "syscall"
 "time"
)

const envRestart = "RESTART"
const envListenFD = "LISTENFD"

func main() {

 v := os.Getenv(envRestart)

 if v != "1" {

  ln, err := net.Listen("tcp", "localhost:8888")
  if err != nil {
   panic(err)
  }

  wg := sync.WaitGroup{}
  wg.Add(1)
  go func() {
   defer wg.Done()
   for {
    ln.Accept()
   }
  }()

  tcpln := ln.(*net.TCPListener)
  f, err := tcpln.File()
  if err != nil {
   panic(err)
  }

  os.Setenv(envRestart, "1")
  os.Setenv(envListenFD, fmt.Sprintf("%d", f.Fd()))

  _, err = syscall.ForkExec(os.Args[0], os.Args, &syscall.ProcAttr{
   Env:   os.Environ(),
   Files: []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd(), f.Fd()},
   Sys:   nil,
  })
  if err != nil {
   panic(err)
  }
  log.Print("parent pid:", os.Getpid(), ", pass fd:", f.Fd())
  f.Close()
  wg.Wait()

 } else {

  v := os.Getenv(envListenFD)
  fd, err := strconv.ParseInt(v, 10, 64)
  if err != nil {
   panic(err)
  }
  log.Print("child pid:", os.Getpid(), ", recv fd:", fd)

  // case1: 理解上面提及的file descriptor、file description的关系
  // 这里子进程继承了父进程中传递过来的一些fd,但是fd数值与父进程中可能是不同的
  // 取消注释来测试...
  //ff := os.NewFile(uintptr(fd), "")
  //if ff != nil {
  // _, err := ff.Stat()
  // if err != nil {
  //  log.Println(err)
  // }
  //}

  // case2: 假定父进程中共享了fd 0\1\2\listenfd给子进程,那再子进程中可以预测到listenfd=3
  ff := os.NewFile(uintptr(3), "")
  fmt.Println("fd:", ff.Fd())
  if ff != nil {
   _, err := ff.Stat()
   if err != nil {
    panic(err)
   }

   // 这里pause, 运行命令lsof -P -p $pid,检查下有没有listenfd传过来,除了0,1,2,应该有看到3
   // ctrl+d to continue
   ioutil.ReadAll(os.Stdin)

   fmt.Println("....")
   _, err = net.FileListener(ff)
   if err != nil {
    panic(err)
   }

   // 这里pause, 运行命令lsof -P -p $pid, 会发现有两个listenfd,
   // 因为前面调用了ff.FD() dup2了一个,如果这里不显示关闭,listener将无法关闭
   ff.Close()

   time.Sleep(time.Minute)
  }

  time.Sleep(time.Minute)
 }
}

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

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

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

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,供大家了解、测试用。

package main

import (
 "fmt"
 "io/ioutil"
 "log"
 "net"
 "os"
 "strconv"
 "sync"
 "syscall"
 "time"

 passfd "github.com/ftrvxmtrx/fd"
)

const envRestart = "RESTART"
const envListenFD = "LISTENFD"
const unixsockname = "/tmp/xxxxxxxxxxxxxxxxx.sock"

func main() {

 v := os.Getenv(envRestart)

 if v != "1" {

  ln, err := net.Listen("tcp", "localhost:8888")
  if err != nil {
   panic(err)
  }

  wg := sync.WaitGroup{}
  wg.Add(1)
  go func() {
   defer wg.Done()
   for {
    ln.Accept()
   }
  }()

  tcpln := ln.(*net.TCPListener)
  f, err := tcpln.File()
  if err != nil {
   panic(err)
  }

  os.Setenv(envRestart, "1")
  os.Setenv(envListenFD, fmt.Sprintf("%d", f.Fd()))

  _, err = syscall.ForkExec(os.Args[0], os.Args, &syscall.ProcAttr{
   Env:   os.Environ(),
   Files: []uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd(), /*f.Fd()*/}, // comment this when test unixsock
   Sys:   nil,
  })
  if err != nil {
   panic(err)
  }
  log.Print("parent pid:", os.Getpid(), ", pass fd:", f.Fd())

  os.Remove(unixsockname)
  unix, err := net.Listen("unix", unixsockname)
  if err != nil {
   panic(err)
  }
  unixconn, err := unix.Accept()
  if err != nil {
   panic(err)
  }
  err = passfd.Put(unixconn.(*net.UnixConn), f)
  if err != nil {
   panic(err)
  }

  f.Close()
  wg.Wait()

 } else {

  v := os.Getenv(envListenFD)
  fd, err := strconv.ParseInt(v, 10, 64)
  if err != nil {
   panic(err)
  }
  log.Print("child pid:", os.Getpid(), ", recv fd:", fd)

  // case1: 有同学认为以通过环境变量传fd,通过环境变量肯定是不行的,fd根本不对应子进程中的fd
  //ff := os.NewFile(uintptr(fd), "")
  //if ff != nil {
  // _, err := ff.Stat()
  // if err != nil {
  //  log.Println(err)
  // }
  //}

  // case2: 如果只有一个listenfd的情况下,那如果fork子进程时保证只传0\1\2\listenfd,那子进程中listenfd一定是3
  //ff := os.NewFile(uintptr(3), "")
  //if ff != nil {
  // _, err := ff.Stat()
  // if err != nil {
  //  panic(err)
  // }
  // // pause, ctrl+d to continue
  // ioutil.ReadAll(os.Stdin)
  // fmt.Println("....")
  // _, err = net.FileListener(ff) //会dup一个fd出来,有多个listener
  // if err != nil {
  //  panic(err)
  // }
  // // lsof -P -p $pid, 会发现有两个listenfd
  // time.Sleep(time.Minute)
  //}
  // 这里我们暂停下,方便运行系统命令来查看进程当前的一些状态
  // run: lsof -P -p $pid,检查下listenfd情况

  ioutil.ReadAll(os.Stdin)
  fmt.Println(".....")

  unixconn, err := net.Dial("unix", unixsockname)
  if err != nil {
   panic(err)
  }

  files, err := passfd.Get(unixconn.(*net.UnixConn), 1, nil)
  if err != nil {
   panic(err)
  }

  // 这里再运行命令:lsof -P -p $pid再检查下listenfd情况

  f := files[0]
  f.Stat()

  time.Sleep(time.Minute)
 }
}

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

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

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

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

file, err := os.NewFile(fd)
// check error

tcpln, err := net.FileListener(file)
// check error

udpconn, err := net.PacketConn(file)
// check error

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

  2. mosn 启动流程: https://mosn.io/blog/code/mosn-startup/