Road to growth of rookie

Meaningful life is called life

0%

Golang 进程热重启

There are only 10 kinds of people in the world, one is a person who understands binary, and the other is a person who does not understand binary.

代码需要升级; 配置文件需要更新. 如果在 PHP 中这些都很简单, 一个简单的 vimgit pull 就能解决. 可是像 Golang 这样的静态语言就无法做到像 PHP 中的那么灵活. 在线上的生产环境, 我们必须保证新老服务无缝交替. 如果贸然停掉服务, 就可能造成一些问题; 例如:

  • 旧的请求未完成, 服务端进程直接退出, 会造成客户端链接中断
  • 新的请求发送过来, 服务端还未完成重启, 造成 connection refused
  • 旧的请求未处理完成, 服务端进程直接退出, 可能会造成用户数据不对称(即: 用户已完成一部分操作, 还要一部分未完成)

当然; 这些问题都可以通过其他方法去规避. 例如使用负载均衡(如: nginx), 保证在升级的过程中始终有一个服务可用, 即各服务器依次灰度升级. 但是像 nginx -s reload 这种的方式, 好像更加方便

什么是 热启动

热启动也可以叫平滑启动, 其思想就是新老程序 (进程) 无缝交替, 一直保持对客户端的服务, 让客户端感觉不到服务的重启. 且新程序启动之后所有新的请求会直接打到新程序, 老程序处理完旧请求之后(或超时)安全(或超时)退出.

其实现原理就是拦截 Linux 的信号, 例如 nginx -s reload 就是给 nginx 发送一个表示 “注意: 我需要重启啦” 这样的信号. 当 nginx 接收到这样的信号的时候, 就会 fork 一个子进程, 并将父进程的 socket 句柄交给子进程 ,将新的请求打到子进程中, 父进程在处理完旧进程之后安全退出. 父进程安全退出之后, 子进程会被 Linuxinit 进程领养成为新的 nginx 进程. 如此循环

Linux/Unix 下三个特殊进程和孤儿进程

在编写代码之前, 我们先来了解一下 Linux/Unix 下三个特殊的进程 idleinitkthreadd 以及什么是 孤儿进程

  • idle 进程:

PID 为 0, 其前身是系统创建的第一个进程, 也是 唯一 一个不是被 forkkernel_thread 创建的进程; 完成加载系统后, 演变为进程调度, 交换

  • init 进程:

PID 为 1, 由 idle 进程通过 kernel_thread 创建. 在内核空间初始化完成以后, 加载 init 进程. 在 linux 中所有进程都是由 init 进程创建并运行(PS: PID 为 0、1、2 的除外)的

image

  • kthreadd 进程:

PID 为 2, 也是由 idel 进程通过 kernel_thread 创建. kthreadd 进程始终运行在内核中, 它的任务就是管理和调度其他内核线程 kernel_thread, 它会 循环 执行一个 kthread 函数, 该函数的作用就是运行 kthread_create_list 全局链表中的 kthread. 当我们调用 kernel_therad 创建内核线程时, 该线程会被加入到 kthread_create_list 链表中, 所以所有的内核线程都是直接或间接的以 kthreadd 为父进程

  • 孤儿进程 :

idle 进程以外的所有进程都有一个父进程(initkthreadd 的父进程就是 idle), 但是进程是可以被 杀死 的.当一个进程退出, 而它有一个或多个进程还在运行, 那么这些子进程就会因为父进程被 Kill 掉变成没有父进程的进程, 这种进程我们就称为孤儿进程. 但是孤儿进程显然不符合 Linux/Unix 的进程规范. 所以在父进程表 Kill 掉以后, init 会领养这样的孤儿进程, 由 init 作为它们的父进程.

演示 孤儿进程Init 收养

在 Unix 中, 创建进程是通过系统调用 fork 实现, 在 GO 语言中的 Linux 下创建进程使用的系统调用是 clone. 子进程几近于父进程的翻版, 子进程获得父进程的栈、数据段、堆和执行文本段的拷贝. 可以视为把父进程一分为二

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
package main

import (
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/exec"
"syscall"
"time"
)

type Handler struct{}

func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Println("Request start at", time.Now(), r.URL.Path+"?"+r.URL.RawQuery, "request done at", time.Now(), " pid:", os.Getpid())
time.Sleep(5 * time.Second)
_, err := w.Write([]byte("this is current request response"))
if err != nil {
fmt.Println("Current request failure:", err.Error())
}
fmt.Println("Request done at", time.Now(), " pid:", os.Getpid())
}

func main() {
var (
err error
listener net.Listener

// Pass the parameter on the command line to decide whether to fork a child process
// If there is no parameter control, the child process will fork another process,
// and the fork process will fork a child process.
// This has been the fork child process, obviously this is not what we need
fork = flag.Bool("f", false, "Parameter determines whether to fork a child process")
)
flag.Parse()

if *fork {
listener, err = net.Listen("tcp", ":10005")
if err != nil {
panic("Listen port 10005 failure: " + err.Error())
}
tcp, _ := listener.(*net.TCPListener)
fd, _ := tcp.File()
fmt.Printf("Current process fd: %v, filename: %v, %#v\n", fd.Fd(), fd.Name(), listener)
} else {
f := os.NewFile(3, "")
listener, err = net.FileListener(f)
if err != nil {
panic(fmt.Sprintf("Listen file %s failure fd: %v error: %s\n", f.Name(), f.Fd(), err.Error()))
}
fmt.Printf("Child process fd: %v, filemame: %v, %#v\n", f.Fd(), f.Name(), listener)
}

server := http.Server{Handler: &Handler{}, ReadTimeout: 6 * time.Second}
log.Println("Actual pid is ", syscall.Getpid())
log.Printf("Listener: %v \n", listener)

if *fork {
// To make it easier to check the status of the process, fork child process after 10 seconds
timer := time.NewTimer(10 * time.Second)
go func() {
<- timer.C
tl, _ := listener.(*net.TCPListener)
currentFd, err := tl.File()
if err != nil {
panic("acquiring listener file failed")
}
// Here, you need to remove the parent process's -f parameter to prevent the child process
// from creating the child process.
cmd := exec.Command(os.Args[0])
cmd.ExtraFiles, cmd.Stdout, cmd.Stderr = []*os.File{currentFd}, os.Stdout, os.Stderr
err = cmd.Start()
if err != nil {
panic(fmt.Sprintf("cmd.Start fail:%s", err))
}
fmt.Println("forked new pid: ", cmd.Process.Pid)
}()
}

err = server.Serve(listener)
if err != nil {
log.Println(err)
}
}

通过 go build main.go 编译后获得一个 mian 命令, 我们先来看一下在没有 fork 子进程 之前的请求状态(加上 -f 参数):

image

服务启动后我通过 curl http://127.0.0.1:10005 发送了几个请求, 请求正常输出. 10秒之后 子进程fork 出来之后 (PS:父进程还没有被杀掉) 我又发送了几个请求如下:

image

通过上面的图片我们可以看出, 两个进程都是在运行的且监听的是同一个端口, 两个进程都在请求. 通过 ps -ef 命令查看它们的进程状态如下:

image

在父进程还没有被杀死的时候, 子进程的 PPID 为父进程ID, 正常! 接下来 我们通过命令 kill 4555 杀死父进程, 查看子进程状态:

image

当父进程 4555 被杀死后, 子进程的 PPID 变成了 1. 跟我们上述的理论没有差别, 我们在通过 curl 尝试请求端口 10005 看是否存在差异:

image

os.NewFile(3, “”) 解析: os.NewFile 接收两个值, 一个为文件描述符(FD), 一个是文件名, 该函数并不是创建一个新的文件, 而是新建一个文件但是不保存返回文件指针.

在 Linux 系统中一切都可以看成是文件, 文件可分为: 普通文件、目录文件、链接文件和设备文件. 文件描述符 (file descriptor) 是内核为了高效管理已打开文件所创建的索引, 为一个 非负整数 (通常是一个小整数), 用于指代被打开的文件, 所有执行 I/O 操作的系统调用都需要用到文件描述符. 程序刚启动时: 0 为标准输入(stdio)、1 为标准输出(stdout)、2 是标准错误(stderr). 如果此时需要打开一个新文件, 那么它的描述符就是 3 (这里的打开包括创建). POSIX标准要求每次打开文件时(含socket)必须使用当前进程中最小可用的文件描述符号码,因此,在网络通信过程中稍不注意就有可能造成串话

实现热启动

通过上面的示例我们发现当父进程被杀死后, 子进程会被 Init 进程收养称为新的主进程, 那么我们就通过这种逻辑完成对新老服务的交替, 在子进程启动后且父进程处理完旧请求后安全退出父进程 (PS: 上述示例中, 在子进程启动后并没有关闭父进程的请求接收, 所有父进程和子进程都在处理请求, 在热启动中我们会关闭父进程请求接收).

代码实现

修改代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
package main

import (
"context"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"os/exec"
"os/signal"
"sync"
"syscall"
"time"
)

type Handler struct{}

func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Println("Request start at", time.Now(), r.URL.Path+"?"+r.URL.RawQuery, "request done at", time.Now(), " pid:", os.Getpid())
time.Sleep(5 * time.Second)
_, err := w.Write([]byte("this is current request response"))
if err != nil {
fmt.Println("Current request failure:", err.Error())
}
fmt.Println("Request done at", time.Now(), " pid:", os.Getpid())
}

func main() {
var (
err error
listener net.Listener
// You need a wait group to wait for the current process (parent process) http service to shut down gracefully,
// otherwise, after calling server.Shutdown, the main thread will stop blocking the entire process exit,
// and the old requests that are not completed will be discarded
// And this wait group must be a pointer (the data exists on the heap),
// because we said before: The child process is the stack area of the shared parent process,
// when the wait group exists on the stack, it will cause two processes to call a same one wait group
group = new(sync.WaitGroup)
graceful = flag.Bool("g", false, "When the service is started for the first time, "+
"there is no need to perform hot restart related operations")
)
flag.Parse()

if *graceful {
f := os.NewFile(3, "")
listener, err = net.FileListener(f)
if err != nil {
panic(fmt.Sprintf("Listen file %s failure fd: %v error: %s\n", f.Name(), f.Fd(), err.Error()))
}
fmt.Printf("Child process fd: %v, filemame: %v, %#v\n", f.Fd(), f.Name(), listener)
} else {
listener, err = net.Listen("tcp", ":10005")
if err != nil {
panic("Listen port 10005 failure: " + err.Error())
}
tcp, _ := listener.(*net.TCPListener)
fd, _ := tcp.File()
fmt.Printf("Current process fd: %v, filename: %v, %#v\n", fd.Fd(), fd.Name(), listener)
}

server := http.Server{Handler: &Handler{}, ReadTimeout: 6 * time.Second}
log.Println("Actual pid is ", syscall.Getpid())
log.Printf("Listener: %v \n", listener)

group.Add(1)
go func(group *sync.WaitGroup) {
defer group.Done()
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, syscall.SIGHUP, syscall.SIGTERM)

ListenSignal:
for {
sig := <-signalChan
// Give the http service that needs to be gracefully shut down a timeout to
// prevent the parent process from persisting
ctx, _ := context.WithTimeout(context.Background(), 20*time.Second)
switch sig {
case syscall.SIGTERM, syscall.SIGHUP:
tl, _ := listener.(*net.TCPListener)
currentFd, err := tl.File()
if err != nil {
panic("acquiring listener file failed")
}
cmd := exec.Command(os.Args[0], "-g")
cmd.ExtraFiles, cmd.Stdout, cmd.Stderr = []*os.File{currentFd}, os.Stdout, os.Stderr
err = cmd.Start()
if err != nil {
panic(fmt.Sprintf("cmd.Start fail:%s", err))
}
fmt.Println("forked new pid: ", cmd.Process.Pid)

// When the child process fork is complete, close the http service of the parent process,
// so the parent process will not receive new requests.
err = server.Shutdown(ctx)
if err != nil {
fmt.Println("shutdown fail: ", err)
}

// After closing the parent process http service,
// you need to jump out of the loop and stop monitoring the signal,
// otherwise the parent process will still exist
break ListenSignal
}
}
}(group)

err = server.Serve(listener)
if err != nil {
log.Println(err)
}
fmt.Printf("Pid %d http server closed\n", os.Getpid())

// After calling server.Shutdown, server.Serve will stop blocking the main thread,
// so here we need to wait for the old request to be processed or timed out
group.Wait()
}
测试结果

我通过 ab 测试, 发送了 100 个测试请求, 并在中间关闭了父进程, 结果如下:

images

images

测试结果 OK, 没有请求失败的情况.

数据来源

Golang服务器热重启、热升级、热更新详解(有BUG, 主线程会一直监听信号, 不会退出)
Linux下1号进程的前世(kernel_init)今生(init进程)—-Linux进程的管理与调度