欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Linux 守护进程原理及实例(Redis、Nginx)

程序员文章站 2022-05-02 07:55:06
...

1. 什么是守护进程

守护进程daemon,是指没有控制终端,运行在后台的进程,通常伴随着系统启动产生,系统关机结束。可以使用命令ps -axj查看系统的守护进程,输出如下所示:

 父ID   PID  组ID  会话ID 终端           状态    用户ID      命令

 PPID   PID  PGID   SID  TTY      TPGID STAT   UID   TIME COMMAND
    0     1     1     1  ?           -1 Ss       0   0:02 /usr/lib/systemd/systemd --switched-root --system --deserialize 21
    0     2     0     0  ?           -1 S        0   0:00 [kthreadd]
    2     3     0     0  ?           -1 S        0   0:00 [ksoftirqd/0]

说明:
1. 终端为 ? 表示没有控制终端,守护进程本身没有控制终端。
2. 状态为 s(小写) 表示此进程为Session Leader(即会话首领)。
3. 命令用 [ ] 括起来表示内核的守护进程,可以看到目前新版Linux系统使用Systemd,而不是init进程。

Linux系统中常见的守护进程有:

  • cron 进程定期执行crontab设置的定时任务。
  • kswap守护进程定期将物理脏页写回磁盘来回收页面。
  • rsyslogd记录日志信息。
  • 还有一些常见的服务器程序,例如Redis、Nginx、MySQL等等。

2. 会话、进程组、控制终端

说到守护进程,它的一个重要的特点是没有控制终端,于是引出了有关会话、进程组、控制终端等概念:

会话:又称为Session,我们正常登录shell之后整个shell程序可以看做一个会话。
进程组:一个会话可以有多个进程组,如果此会话有控制终端,则会有一个前台进程组和若干个后台进程组。
控制终端:一个会话最多只有一个控制终端或者没有,如果ssh登录或者通过命令gnome-terminal打开一个shell,则默认会打开一个终端与此shell对应,在Linux上,通常指的是虚拟终端,也就是/dev/pts/x,可以执行tty命令查看目前shell对应的终端。

通过ps命令,我们再次区分下这几个概念:

$ tty
/dev/pts/1

$ ps -o pid,ppid,pgid,sid,comm | less

  PID  PPID  PGID   SID COMMAND
16838 14313 16838 16838 bash
28697 16838 28697 16838 ps
28698 16838 28697 16838 less

说明:
1. ps 和 less 都是 shell 的子进程。
2. 他们SID相同,属于同一个会话,且会话首领是 bash,会话对应的虚拟终端为/dev/pts/13. ps 和 less 进程组ID是一样的,表明他们属于同一个进程组。

这三个进程之间的关系如下图所示:

Linux 守护进程原理及实例(Redis、Nginx)

3. 如何创建一个守护进程

创建一个守护进程通常来说有以下步骤:

  • fork(退出父进程,保证子进程不是当前会话的首领,这样才可以调用setsid重新创建新会话,因为会话首领不可以重新创建会话)
  • setsid(重新创建一个会话)
  • fork(再次fork退出父进程,保证子进程不是新会话的首领,则不具有重新获得控制终端的能力,因为会话首领有重新申请终端的能力)
  • chdir(更改守护进程运行的默认目录,防止目前目录被卸载)
  • umask(守护进程可能需要创建日志文件,因此将umask置为0,默认文件权限则为:666-000)
  • signal(忽略或者处理信号SIGHUP(进程和控制终端分离时收到SIGHUP)、 SIGTERM(系统关机之前收到SIGTERM))
  • close(关闭从父进程继承来的不会使用的文件描述符)

代码示例如下:

#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <signal.h>
#include <sys/resource.h>

void daemon_two_fork() 
{
    int fd;
    struct rlimit rl; 

    if (fork() != 0) {      // 第一次fork
        exit(0);
    }

    if (setsid() == -1) {   // 创建新会话
        perror("setsid");
    }

    if (fork() != 0) {      // 再次fork,保证子进程不是会话首领
        exit(0);
    }

    if (chdir("/tmp") == -1) {  // 修改默认工作目录
        perror("chdir");
    }

    umask(0);                   // 设置umask

    signal(SIGHUP, SIG_IGN);    // 信号处理
    signal(SIGCHLD, SIG_IGN);

    if (getrlimit(RLIMIT_NOFILE, &rl) < 0) {   // 通过getrlimit得到进程所能打开的最大文件描述符
        perror("getrlimit");
    }

    if (rl.rlim_max == RLIM_INFINITY) {
        rl.rlim_max = 1024;
    }

    /* 将除过(0,1,2)之外的文件描述符全部关闭,对一个没有打开的文件描述符  
    调用close会出现`Bad file descriptor`,这时候只能选择忽略 */
    for (int i = 3; i < rl.rlim_max; ++i) {    
        close(i);
    }

    /*将 0 1 2 输出导入 /dev/null */
    if ((fd = open("/dev/null", O_RDWR, 0) != -1)) {
        dup2(fd, STDIN_FILENO);
        dup2(fd, STDOUT_FILENO);
        dup2(fd, STDERR_FILENO);
        if (fd > STDERR_FILENO) {
            close(fd);
        }
    }
}


int main(int argc, char *argv[])
{
    int fd;
    pid_t pid;

    daemon_two_fork();

    fd = open("/tmp/log", O_RDWR | O_TRUNC);
    if (fd == -1) {
        perror("open");
    }

    pid = fork();

    if (pid > 0) {
        for (int i = 0; i < 60; ++i) {
            write(fd, "Hello World\n", 12);
            sleep(1);
        }
    }

    close(fd);
    return 0;
}

4. 第二次fork是必须的吗?

答案是:否

因为第二次fork的目的是:让子进程不再是会话首领,这样它就没能力再次申请终端,也避免了守护进程收到与终端相关信号的干扰,比如SIGHUP等,那么如果程序能确保后续不会有申请终端的操作,第二次fork也就没有什么意义了。

比如 Redis(一个内存数据库)实现守护进程的代码如下点这里

void daemonize(void) {
    int fd;

    if (fork() != 0) exit(0); /* parent exits */
    setsid(); /* create a new session */

    /* Every output goes to /dev/null. If Redis is daemonized but
     * the 'logfile' is set to 'stdout' in the configuration file
     * it will not log at all. */
    if ((fd = open("/dev/null", O_RDWR, 0)) != -1) {
        dup2(fd, STDIN_FILENO);
        dup2(fd, STDOUT_FILENO);
        dup2(fd, STDERR_FILENO);
        if (fd > STDERR_FILENO) close(fd);
    }
}

可以看到除了只使用一次fork之外,umask,close等操作也都没有,这是因为作为一个服务器程序,如果用户指定了守护进程方式启动,通常在主进程还没有打开文件的时候就会完成daemon的操作,此时既没有信号,也没有打开的文件,不然等到主进程开始打开文件,监听套接字了再去daemon,岂不是很麻烦。

此外,除了Redis, Nginx实现的守护进程也只有一次fork,代码见 这里

5. 守护进程如何支持Systemd方式启动?

比较新的Linux发行版本开始使用 Systemd 来作为initupstart的替代,也就是它是新晋的1号进程,可以看到下面ps的结果PID为 1:

[yangbodong@centos-linux system]$ ps aux 
USER       PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root         1  0.0  0.0 191244  4356 ?        Ss   Nov27   0:05 /usr/lib/systemd/systemd --switched-root --system --deserialize 21

如果我们想让自己的程序(服务)被systemd管理,则编写守护进程则要遵循一些systemd下的规则了…

参考自[1]

  • 后台服务进程代码不需要执行两次fork来实现后台进程,只需要实现服务本身的主循环即可。(传统编写守护进程要至少调用一次fork,然后停止父进程)
  • 不要调用 setsid(),交给 systemd 处理
  • 不再需要维护 pid 文件。(传统编写守护进程,会自己在某个目录下,生成pid文件,一是记录本守护进程pid,另外一点是防止本守护进程被多次重启而导致出错或者导致多个实例在运行)
  • Systemd 提供了日志功能,服务进程只需要输出到 stderr 即可,无需使用 syslog。(传统编写守护进程我们要将标准输出、出错、输入关闭或者重定向,日志信息都是发往rsyslog)
  • 处理信号 SIGTERM,这个信号的唯一正确作用就是停止当前服务,不要做其他的事情。(传统守护进程一般SIGTERM也是用来做这种事情的)
  • SIGHUP 信号的作用是重启服务。(传统数据进程一般SIGHUP也是做这种事情的)
    需要套接字的服务,不要自己创建套接字,让 systemd 传入套接字。(这个承接systemd快速启动优点而设立的,可以实现这个特点,也可以不实现)
  • 使用 sd_notify()函数通知 systemd 服务自己的状态改变。一般地,当服务初始化结束,进入服务就绪状态时,可以调用它。(没用过)

接下来实现一个进程交给Systemd去管理,继续以前面守护进程打开/tmp/log并且写入Hello World为例:

操作系统:Centos 7

$ vim daemonize.c

// 内容如下,我们只用实现守护进程要做的事情就行了

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int main(int argc, char *argv[])
{
    int fd;

    fd = open("/tmp/log", O_RDWR | O_TRUNC);

    for (int i = 0; i < 60; ++i) {
        write(fd, "Hello World\n", 13);
        sleep(1);
    }

    close(fd);
    return 0;
}

接下来编译程序,将可执行文件拷贝至/usr/local/bin/(可选操作,为了规范):

$ gcc daemonize.c -o daemonize
$ sudo cp daemonize /usr/local/bin/

然后需要编写相应的Systemd管理的.service文件 :

$ sudo touch  /usr/lib/systemd/system/mydaemon.service    // 创建自己的service为mydaemon
$ sudo vim  /usr/lib/systemd/system/mydaemon.service

[Unit]
Description=mydaemon test               // 描述自己的service
After=network.target

[Service]
ExecStart=/usr/local/bin/daemonize          // 启动命令,更多参数选项见参考[2]
ExecStop=pkill daemonize                    // 结束命令

[Install]
WantedBy=multi-user.target

然后我们为/etc/systemd/system/创建软链接指向实际编写的service文件,并且刷新Systemd的缓存:

$ sudo ln -s /usr/lib/systemd/system/mydaemon.service /etc/systemd/system/multi-user.target.wants/mydaemon.service

$ sudo systemctl daemon-reload

好了,这下就可以开心的使用`Systemd启动我们的mydaemon服务了:

$ sudo systemctl start mydaemon.service
$ sudo systemctl status mydaemon.service 
● mydaemon.service - mydaemon test
   Loaded: loaded (/usr/lib/systemd/system/mydaemon.service; enabled; vendor preset: disabled)
   Active: active (running) since Tue 2017-11-28 00:54:00 CST; 1s ago
 Main PID: 28930 (daemonize)
   CGroup: /system.slice/mydaemon.service
           └─28930 /usr/local/bin/daemonize

Nov 28 00:54:00 centos-linux.shared systemd[1]: Started mydaemon test.
Nov 28 00:54:00 centos-linux.shared systemd[1]: Starting mydaemon test...
[aaa@qq.com system]$ 

可以看到它的状态是active (running),用ps命令看一下:

$ ps aux | grep daemonize
root     29281  0.0  0.0   4156   340 ?        Ss   00:55   0:00 /usr/local/bin/daemonize
yangbod+ 29300  0.0  0.0 112652   960 pts/1    R+   00:55   0:00 grep --color=auto daemonize

可以看到它的TTY为?,表明没有终端,而且s(小写)表示它是一个会话首领,再看下/tmp/log,发现Hello World被正确写入:

Linux 守护进程原理及实例(Redis、Nginx)

参考资料:
[1] Systemd
[2] Systemd 入门教程:实战篇

[完]