多进程 - daemon进程和优雅重启

1,389 阅读2分钟

本文会详细介绍主流的daemon进程的实现方案,以及网络编程中如何实现优雅重启,这些都是多进程的一些编程技巧!

如何创建daemon进程

  1. 为什么我们需要daemon进程?

我们平时做服务器开发都是启动一个程序,这个程序是一个前台程序,但是前台程序它一直在那开着,我想让他后台运行,例如mysql的server,那么怎么解决呢,我自己怎么实现一个daemon进程呢?

  1. 常见的手段
  • systemctl 是linux最常见的手段,它需要软件定义一个 .service 文件,来定义和管理 软件的等 www.freedesktop.org/software/sy…
  • 可以用 systemctl status 查看所有 systemctl 的进程,但是貌似需要root权限用起来不太方便....

image-20230807132711259

~ cat /lib/systemd/system/docker.service
[Unit]
Description=Docker Application Container Engine
Documentation=https://docs.docker.com
After=network-online.target docker.socket firewalld.service
Wants=network-online.target
Requires=docker.socket

[Service]
Type=notify
# the default is not to use systemd for cgroups because the delegate issues still
# exists and systemd currently does not support the cgroup feature set required
# for containers run by docker
ExecStart=/usr/bin/dockerd -H fd://
ExecReload=/bin/kill -s HUP $MAINPID
LimitNOFILE=1048576
# Having non-zero Limit*s causes performance problems due to accounting overhead
# in the kernel. We recommend using cgroups to do container-local accounting.
LimitNPROC=infinity
LimitCORE=infinity
# Uncomment TasksMax if your systemd version supports it.
# Only systemd 226 and above support this version.
TasksMax=infinity
TimeoutStartSec=0
# set delegate yes so that systemd does not reset the cgroups of docker containers
Delegate=yes
# kill only the docker process, not all processes in the cgroup
KillMode=process
# restart the docker process if it exits prematurely
Restart=on-failure
StartLimitBurst=3
StartLimitInterval=60s

[Install]
WantedBy=multi-user.target

其实大概描述了,当前软件详情信息,如何启动当前软件等,具体可以参考这个文章 segmentfault.com/a/119000002…

  • nohup 就更简单了,只需要 nohup 命令一下即可,其实它所做的就更简单了,就是一个后台运行,并不会涉及到重启等操作

  • supervisor 我个人感觉就和 systemctl差不多

如何实现一个daemon进程

首先需要了解一个进程的机制,例如我们在shell里执行了一个命令(非nohup),那么整体流程是,shell进程启动了我们的进程,那么我们当前进程的父亲进程就是 shell 进程,当我们把shell进程关了,那么我们的进程也没了!

下图是我写了一个 Go代码,其实就是解释了上面说的!整个进程的关系!

image-20230807134220378

根据上面描述基本无解了,那么怎么办呢,实际上这里就需要用到孤儿进程,孤儿进程的父进程ID是1,他的回收权就转移给了init进程(进程ID为1),那么如何创建一个孤儿进程了!

其实很简单,就是当父进程退出,子进程还在运行,此时子进程就是孤儿进程了,孤儿进程的父亲进程为init进程(进程ID=1)!

僵尸进程(zomibe)进程产生的原因是因为子进程退出后,父进程没有退出但是也没有及时清理子进程的资源,当父进程退出了僵尸进程会自动回收,僵尸进程造成的问题就是占用系统资源(pid资源)!

#include <iostream>
#include <unistd.h>
#include <cerrno>
#include <cstring>

// g++ -std=c++14 main.cpp -o main

int main() {
    std::cout << "main start: " << getpid() << "\n";
    auto child_pid = fork();
    if (child_pid < 0) {
        std::cout << "fork find err: " << strerror(errno) << "\n";
    }
    if (child_pid == 0) {
        // 子进程
        std::cout << "child pid " << getpid() << " start" << "\n";
        sleep(1);
        std::cout << "child pid " << getpid() << " end" << "\n";
        return 0;
    }
    std::cout << "main start wait child process success: " << child_pid << "\n";
    sleep(20); // 这里子进程1s退出后子进程身份就标记为僵尸进程了.

//    if (waitpid(child_pid, nullptr, 0) != child_pid) { // 正确做法是分配有限的资源或者及时回收
//        std::cout << "main wait child process find err: " << strerror(errno) << "\n";
//        return 1;
//    }
    std::cout << "main wait child process success: " << child_pid << "\n"; // 当父进程退出僵尸进程就会被清理了.(如果系统创建大量的子进程且子进程退出后没及时回收就会造成系统进程无法分配)
}

具体文章可以看:

简单实现一个daemon进程

这个例子是实现一个 http 服务的daemon进程,直接运行后会后台启动一个http服务!

package main

import (
	"fmt"
	"net/http"
	"os"
	"path/filepath"
)

func main() {
	fmt.Printf("当前ppid: %v, pid: %v, args: %#v\n", os.Getppid(), os.Getpid(), os.Args)
	if len(os.Args) > 1 && os.Args[1] == "child_process" { // 子进程
		fmt.Println("child process")
		if err := http.ListenAndServe(":10099", http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
			fmt.Printf("method: %s, url: %s\n", request.Method, request.URL)
			_, _ = writer.Write([]byte(`hello world`))
		})); err != nil {
			panic(err)
		}
		return
	}

	executable, err := os.Executable()
	if err != nil {
		panic(err)
	}
	dir, err := os.Getwd()
	if err != nil {
		panic(err)
	}
	fmt.Printf("dir: %s\n", dir)
	stdout, err := os.OpenFile(filepath.Join(dir, "child_process.log"), os.O_CREATE|os.O_WRONLY, 0644)
	if err != nil {
		panic(err)
	}
	process, err := os.StartProcess(executable, []string{os.Args[0], "child_process"}, &os.ProcAttr{
		Dir: dir,
		Files: []*os.File{ // 共享fd
			os.Stdin,  // stdin
			stdout, // stdout
			stdout, // std error
		},
	})
	if err != nil {
		panic(err)
	}
	pidFile, err := os.OpenFile(filepath.Join(dir, "child_process.pid"), os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
	if err != nil {
		panic(err)
	}
	defer pidFile.Close()
	if _, err := pidFile.WriteString(fmt.Sprintf("%d", process.Pid)); err != nil {
		panic(err)
	}
	fmt.Printf("create child process %d\n", process.Pid)

	return
	// 不执行这个,直接return就是孤儿进程了
	if _, err := process.Wait(); err != nil {
		panic(err)
	}
}

image-20230807142245307

我们成功实现了一个 孤儿进程, 如何结束孤儿进程了, 直接 kill 孤儿进程的进程ID即可

➜  test git:(master) ✗ ps -ef | grep './main'  
  502 48190     1   0  2:42PM ttys052    0:00.01 ./main child_process
  502 48312 43352   0  2:42PM ttys052    0:00.00 grep --color=auto --exclude-dir=.bzr --exclude-dir=CVS --exclude-dir=.git --exclude-dir=.hg --exclude-dir=.svn --exclude-dir=.idea --exclude-dir=.tox ./main
➜  test git:(master) ✗ kill `cat child_process.pid`

注意:Go的StartProcess底层是fork + exec , fork函数是创建一个子进程,exec函数是加载一个程序覆盖当前程序(替换整个程序). 不懂得可以百度下..

代码地址: coliru.stacked-crooked.com/a/caed3826f…

#include <iostream>
#include <unistd.h>
#include <sys/wait.h>
#include <cstring>
#include <cerrno>

int main() {
    std::cout << "main start process" << std::endl;
    int count = 0;
    pid_t cpid = fork();

    if (cpid == 0) {
        // 子进程从这里开始执行(fork 函数会有很多优化,核心是写时复制,所以fork函数开销不大)
      	// copy on write https://wingsxdu.com/posts/linux/concurrency-oriented-programming/fork-and-cow/
        count = count + 100;
        std::cout << "child process start. pid: " << getpid() << " , count: " << count << std::endl;
        sleep(2); // sleep 2s
        std::cout << "child process replace current exec process" << count << std::endl;
        // 这里的意思时将子进程替换成 ls 这个程序
        if (execl("/bin/ls", "ls", "-a", NULL) == -1) {
            // 替换程序失败会报错
            std::cout << "child process error, err: " << strerror(errno) << std::endl;
            return 1;
        }
        // 子进程替换成功后下面这些代码都不会执行了(因为已经替换了完整的代码段/数据段之类的,完全和这个程序无关了)
    } else if (cpid == -1) {
        std::cout << "fork err: " << strerror(errno) << std::endl;
        return 1;
    } else {
        std::cout << "main process: " << getpid() << ", child process: " << cpid << std::endl;
        waitpid(cpid, nullptr, 0); // 等待子进程结束
        std::cout << "main process done, count: " << count << std::endl;
    }
    return 0;
}

封装 daemon 进程

这里我就不造轮子了,大概可以看一下 github.com/sevlyar/go-… 这个项目,我大概介绍一些几个方法的核心原理

  1. ctx
func (s *DaemonService) newCtx() *daemon.Context {
	return &daemon.Context{
		PidFileName: filepath.Join(s.homeDir, PidFile), // pid所在文件,主要是解决如何获取子进程pid的问题
		PidFilePerm: 0644,
		LogFileName: filepath.Join(s.homeDir, LogFile), // 替换子进程的stdout/stderr
		LogFilePerm: 0644,
		WorkDir:     s.homeDir, // 子进程工作目录
		Umask:       027, // 文件权限,有兴趣可以查一下
		Args:        os.Args, // 子进程参数
	}
}
  1. DaemonStart
func (s *DaemonService) DaemonStart() error {
	ctx := s.newCtx()
	// Search
	// 其实就是读取pid文件,判断进程是否存在
	search, err := ctx.Search()
	if err == nil && search != nil {
		return fmt.Errorf(`the background program has started. PID: %d`, search.Pid)
	}
	// Reborn
	// 如果是父进程,则返回 child process
	// 如果是子进程,则返回 空 (判断父子进程逻辑很简单就是根据环境变量 _GO_DAEMON=1,子进程会被注入这个环境变量)
	childProcess, err := ctx.Reborn()
	if err != nil {
		return fmt.Errorf("unable to run background program, reason: %v", err)
	}
  // 子进程不为空,说明是父进程,直接退出即可(这里子进程就是孤儿进程了)
	if childProcess != nil {
		logs.Infof("start parent process success. pid: %d, cpid: %d", os.Getpid(), childProcess.Pid)
		return nil
	}
	defer func() {
		_ = ctx.Release() // 释放一些当时创建时分配的资源
	}()
	logs.Infof("start child process success. ppid: %d, pid: %d", os.Getppid(), os.Getpid())
	return s.run()
}

实现优雅重启tcp服务

  1. 现在已经有了 k8s / 自研的发布平台都支持滚动重启了,滚动重启阶段会新建一个新的服务,然后等待旧服务结束。但是吧他比较消耗资源,因为假如你服务1w台,滚动粒度时10%,那么需要冗余1000台服务器的资源!
  2. 原地重启吧,需要实现优雅重启或者暴力重启了,暴力重启可能会短暂影响sla,所以优雅重启也非常重要!
  3. 优雅重启的大概原理就是:多进程的文件共享,这里共享的是tcp socket的文件,当需要重启时候会创建一个新进程,然后通知旧进程关闭监听socket文件,两个进程共享socket文件,新进程启动后会重新监听共享的socket,那么新的连接会打向新进程,旧进程依然处理旧的连接,最后处理完后旧进程会退出,最终实现优雅重启,它很好的解决了新连接/旧连接的处理!

造个轮子

这个例子,我是主进程正常创建和监听TCPListener, 当需要重启的时候此时需要关闭 TCPListener 然后创建子进程继续监听,当再监听到重启时同样的需要主进程关闭子进程!

package main

import (
	"fmt"
	"net"
	"net/http"
	"os"
	"os/exec"
	"os/signal"
	"strings"
	"sync"
	"syscall"
	"time"
)

func main() {
	var name string
	var err error
	var listen net.Listener

	// 如果是子进程的话,listen 获取不太一样
	if os.Getenv("is_slave") == "true" {
		file := os.NewFile(uintptr(3), "")
		listen, err = net.FileListener(file)
		name = fmt.Sprintf("slave-%d", os.Getpid())
	} else {
		listen, err = net.Listen("tcp", ":10086")
		name = "master"
	}
	if err != nil {
		panic(fmt.Errorf("init (%s) listen err: %v", name, err))
	}
	debug("[%s] start", name)

	go func() {
		if isSlave(name) {
			return
		}

		var listenFd *os.File
		var loadFD = sync.Once{}
		loadListenFd := func() *os.File {
			loadFD.Do(func() {
				tl := listen.(*net.TCPListener)
				fds, err := tl.File()
				if err != nil {
					panic(fmt.Errorf("tl.File() find err: %v\n", err))
				}
				if err := listen.Close(); err != nil { // 只需要关闭一次,所以用sync.once
					panic(err)
				}
				listenFd = fds
			})
			return listenFd
		}

		// 父亲进程watch 变更
		debug("[%s] watch file changed", name)

		var command *exec.Cmd
		var done chan bool
		var errch chan error
		for {
			<-time.After(time.Second * 3) // 监听到需要重启进程 (这里可以换成实际程序的)
			if command != nil {
				// 通知子进程关闭.
				if err := command.Process.Signal(syscall.SIGQUIT); err != nil {
					panic(err)
				}
				select {
				case err := <-errch: // todo watch err.
					debug("[%s] close slave-%d err: %v", name, command.Process.Pid, err)
					panic(err)
				case <-done:
					debug("[%s] close slave-%d success", name, command.Process.Pid)
				}
			}
			// 启动子进程
			done = make(chan bool, 0)
			errch = make(chan error, 0)
			if subCmd, err := startSlaveProcess(loadListenFd(), done, errch); err != nil {
				panic(err)
			} else {
				command = subCmd
			}
			debug("[%s] run slave-%d success", name, command.Process.Pid)
		}
	}()

	// 子进程如果监听到关闭,则需要关闭连接
	done := make(chan bool, 0)
	if isSlave(name) {
		c := make(chan os.Signal)
		signal.Notify(c, syscall.SIGQUIT)
		go func() {
			vv := <-c
			if err := listen.Close(); err != nil { // close 不优雅,优雅的话需要用 wait-group
				panic(err)
			}
			debug("[%s] close listen success, signal: %v", name, vv.String())
			close(done)
		}()
	}

	// 启动监听
	if err := http.Serve(listen, newHandlerFunc(name)); err != nil {
		if strings.Contains(err.Error(), "use of closed network connection") {
			<-done
			return
		}
		panic(err) // 别的异常直接panic
	}
}

func newHandlerFunc(name string) http.HandlerFunc {
	return func(writer http.ResponseWriter, request *http.Request) {
		_, err := writer.Write([]byte(fmt.Sprintf("name: %s, hello world", name)))
		if err != nil {
			panic(err)
		}
	}
}

func isMaster(name string) bool {
	return name == "master"
}

func isSlave(name string) bool {
	return strings.HasPrefix(name, "slave")
}

func debug(format string, v ...interface{}) {
	fmt.Printf(format+"\n", v...)
}

func startSlaveProcess(fd *os.File, done chan bool, errch chan error) (*exec.Cmd, error) {
	executable, err := os.Executable()
	if err != nil {
		return nil, err
	}
	command := exec.Command(executable)
	command.Stdout = os.Stdout
	command.Stdin = os.Stdin
	command.Stderr = os.Stderr
	command.Env = append(os.Environ(), "is_slave=true")
	command.ExtraFiles = append(command.ExtraFiles, fd) // 共享fd
	if err := command.Start(); err != nil {
		return nil, err
	}
	go func() {
		if err := command.Wait(); err != nil {
			errch <- err
			return
		}
		close(done)
	}()
	return command, nil
}

开源实现

  • github.com/jpillora/ov… 父进程不负责监听端口(负责创建端口/管理子进程),子进程负责监听端口,当父进程监听到重启的时候会重启 子进程 (区别于我这个例子) 【比较推荐,sdk也比较成熟】
  • github.com/facebookarc… 没怎么细看
  • github.com/fvbock/endl… 这个做法更暴力了,相当于当监听到 SIGHUP 信号时,直接启动个孤儿进程(子进程),当前进程因为被close,自己主动退出!不太适用于!

总结

  1. 上面例子的缺陷就是 子进程/父进程 直接调用 close 方法去关闭连接,然后子进程时直接退出进程了,此时会存在部分已经建立连接的请求失败了,需要优雅关闭,但是实际上优雅关闭也会存在问题,就是wait的时间过长,导致后续新建的连接失败(连接超时),所以可以间接过度,也就先 close 关闭新建连接,然后创建子进程继续监听,然后等待前面这个子进程优雅退出即可!
  2. 优雅重启都强依赖于sdk,假如你父进程的sdk有BUG还是得强制升级的!

linux 小技巧

日常中我们也不需要上面那些复杂的东西,比如我就是想后台挂起几个进程是不是,还需要我自己造个轮子,太麻烦了!

后台运行

package main

//  go build -v -o main main.go

import (
	"fmt"
	"log"
	"net/http"
	"net/http/httputil"
	"os"
)

func main() {
	addr := fmt.Sprintf(":%s", os.Args[1])
	fmt.Printf("ppid: %d, pid: %d, listen: %s\n", os.Getppid(), os.Getpid(), addr)
	if err := http.ListenAndServe(addr, http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
		dumpRequest, _ := httputil.DumpRequest(request, false)
		fmt.Printf(string(dumpRequest))
		if _, err := writer.Write([]byte(`hello world`)); err != nil {
			log.Printf("ERROR write conn find err: %v", err)
		}
	})); err != nil {
		log.Fatal(err)
	}
}
  1. 后台运行 (当终端关闭的时候他也会关闭)
➜  test git:(master) ✗ ./main 10086 &
[1] 17195
ppid: 12947, pid: 17195, listen: :10086                                                                                                                                                  
➜  test git:(master) ✗ echo $!                 
17195
  1. nohub (not hang up) 当终端关闭它也不会关闭
➜  test git:(master) ✗ nohup ./main 10086 >nohub.log 2>&1 &
[1] 18193
➜  test git:(master) ✗ echo $!                                   
18193

后台运行多个进程

信号:

HUP     1    终端断线(你把终端关了,就会收到这个)
INT     2    中断(同 Ctrl + C)
QUIT    3    退出(同 Ctrl + \)
TERM   15    终止
KILL    9    强制终止
CONT   18    继续(与STOP相反, fg/bg命令)
STOP   19    暂停(同 Ctrl + Z)

这里我们运行多个后台进程,当脚本结束的时候杀死后台进程

#!/usr/bin/env bash

set -e

child_pids=()
function kill_child_process() {
    echo "kill process ...."
    for elem in "${child_pids[@]}" ; do
        echo "kill pid: $elem"
        kill -9 "$elem"
    done
}
# 当脚本退出时执行 kill_child_process (exit类似于defer函数)
trap kill_child_process EXIT

# 当收到INT/QUIT信号时执行 kill_child_process
# trap kill_child_process INT QUIT

# 创建子进程1
./main 10086 &
child_pids+=("$!")

# 创建子进程2
./main 10010 &
child_pids+=("$!")

echo "创建子进程: ${child_pids[*]}"

# 等待子进程结束
wait

总结

方案没有绝对的好与坏,取决于具体场景,掌握了各种方案的底层实现,会方便我们针对于各个场景做出支持!