编程

进程、守护进程和服务之间的技术差异

467 2024-05-11 19:21:00

1. 概述

尽管进程是操作系统中的一个基本概念,但有时可能会混淆不同类型的进程,例如守护进程和服务之间的差异。

在本教程中,我们将重点讨论 Linux 中进程、守护进程和服务之间的差异。

2. 进程

进程是由操作系统执行的活动程序。除了程序的代码外,进程还具有专用的内存和资源。另一方面,程序是用特定编程语言编写的用于执行特定任务的指令集。我们可以将程序设想为静态实体,而进程则是程序的动态对应物。

系统中的每个进程都有一个唯一的标识符 PID(进程 ID),它是一个数字。此外,每个进程都处于几个状态中的一个状态。例如,一个进程可能处于“正在运行”状态,而另一个进程则可能处于“可中断睡眠”状态,等待资源。

进程也有调度策略和优先级。比如,具有实时调度策略的进程比具有其他调度策略的过程具有更高的优先级。事实上,多线程进程中的线程可能具有不同的调度策略和优先级。

我们也可以将进程分类为前台进程或后台进程。连接到终端的进程是前台进程,而后台进程与终端分离。

在接下来的部分中,我们将了解到守护进程和服务是在后台运行的特殊进程

3. 守护进程(Daemons)

在本节中,我们将讨论守护进程的特性以及初始化守护进程的典型步骤。我们还将介绍守护进程的示例和分析。

3.1. 守护进程特征

守护进程是在系统启动时启动,在系统关闭时停止的特殊进程。它们在后台运行,并与控制终端分离。

系统中的一些守护程序以内核模式运行,而另一些则以用户模式运行。每个守护进程都执行特定的作业。例如,ksoftirqd 内核守护程序处理延迟的软件中断,而以用户模式运行的 chronyd 守护程序则同步网络中不同节点之间的时间。

3.2. 启动时的典型步骤

初始化守护进程有几个建议的步骤,用以避免不需要的交互。让我们回顾一下在 C 中实现守护进程的基本步骤:

  1. 通过调用 umask() 系统调用来清除文件创建掩码。其被设置为一个已知值,通常为 0。否则,从父进程继承的文件模式创建掩码可能会覆盖某些权限。
  2. 调用 fork() 函数并终止父进程。如果我们从终端运行守护程序,这将使守护进程在后台运行。此外,子进程从父进程继承 PGID(进程组 ID),但具有新的 PID。因此,子进程不是进程组的先导。
  3. 使用 setsid() 系统调用创建一个新会话。由于子进程不是进程组的先导,因此创建新会话是成功的。调用 setsid() 的结果是,进程成为新会话和新进程组的先导。此外,它与终端分离
  4. 将当前工作目录更改为根目录。守护进程从父进程继承当前工作目录。此目录可能是已挂载的文件系统。因此,将守护进程更改为根目录允许卸载文件系统。但是,守护程序可能更喜欢使用另一个当前工作目录,例如在其后台处理目录中工作的打印机后台脱机守护程序。
  5. 关闭从父进程继承的不必要的文件描述符。
  6. 将文件描述符 0、1 和 2 附加到 /dev/null。因此,守护进程使用的从标准输入读取并写入标准输出和标准错误的库函数不会受到影响。

3.3. 实现守护程序

让我们使用以下程序 first_daemon.c 编写一个示例守护进程,该程序实现了上一节中列出的步骤:

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

void convert_to_daemon()
{
    pid_t pid;
    int fd0,fd1,fd2;

    /* Clear file creation mask */
    umask(0);

    /* Create child daemon process and let parent exit */
    pid = fork();
    if (pid != 0) /* parent */
        exit(0);

    /* Become a session leader */
    setsid();

    /* Change the current working directory */
    chdir("/");

    /* Close open file descriptors */
    close(0);
    close(1);
    close(2);

    /* Attach stdin, stdout, and stderr to /dev/null */
    fd0 = open("/dev/null", O_RDWR);
    fd1 = dup(0);
    fd2 = dup(0);
}

int main() {

    convert_to_daemon();

    while (1) {
        /* The daemon performs its task here */

       sleep(1);
    }

    return 0;
}

convert_to_daemon() 方法实现了启动时的步骤main() 中函数调用后,该守护程序在无限循环中执行其任务。

守护进程启动时可能还会有其他步骤。比如,可以将守护进程的 PID 写入到 PID 文件,以使守护进程单个实例运行。为保持代码简短,我们在示例中还未实现该步骤。

3.4. 替代方案

作为 first_daemon.c 实现的替代方案,我们编写了另一段代码 second_daemon.c:

#include <unistd.h>

int main() {

    daemon(0, 0);

    /* The daemon performs its task here */
    while (1) {
        sleep(1);
    }

    return 0;
}

该代码比 first_daemon.c 简单。本例中,在无限 while 循环中我们只调用 daemon() 函数,该守护进程执行其任务。 

daemon() 函数将其与控制的终端分离,并在后台将其以守护进程运行。它实现了初始化守护进程所推荐的步骤。如果它的第一个参数是 0,它将把当前进程的工作目录变更为根目录。此外,如果第二个参数为 0,它会重定向标准输入、标准输出以及标准错误到 /dev/null。

3.5. 运行守护进程

首先,我们使用 gcc 来编译 first_daemon.csecond_daemon.c:

$ gcc -o first_daemon first_daemon.c
$ gcc -o second_daemon second_daemon.c

所生成的可执行文件名为 first_daemonsecond_daemon。

生成完可执行文件名后,是时候运行这些守护进程了:

$ ./first_daemon
$ ./second_daemon

让我们使用 ps 来检查正在运行的进程。我们使用 grep 过滤 ps 的输出,使之只罗列这两个守护进程:

$ ps -efj | grep _daemon | grep -v grep
centos      3901    2314    3901    3901  0 15:21 ?        00:00:00 ./first_daemon
centos      3909    2314    3909    3909  0 15:21 ?        00:00:00 ./second_daemon

这两个进程都如期与终端分离,因此我们可以在运行这些守护进程后继续使用终端。

3.6. 分析这些守护进程

ps 输出的第二列显示了进程的 PID,本例中为 3910 和 3909。第 4、5 列罗列了 PGID 和 SID(Session ID)。两个进程的 PGID 和 SID 都与其 PID 相同。因此,就像预期一样,它们既是进程组先导也是 Session 先导。

第三列是父进程的 PID。两个进程同属于同一个的父进程,其 PID 是 2314。本例中,其父进程是 systemd 的用户实例。系统 systemd 服务 的 PID 为 1,它在用户登录时启动,用户退出登录时关闭。

第 8 列显示了这些进程使用的终端。本列标注的问题意味着,如预期一样,守护进程与控制它的终端是分离的。

让我们使用 pwdx 检测一下这两个进程当前的工作目录:

$ pwdx 3901
3901: /
$ pwdx 3909
3909: /

如预期一样,这两个进程的当前工作目录都是根目录(/)。

最后,让我们实现 /proc 文件系统检测一下这两个进程打开的文件描述符:

$ sudo ls -l /proc/3901/fd
total 0
lrwx------. 1 centos centos 64 Feb  9 11:28 0 -> /dev/null
lrwx------. 1 centos centos 64 Feb  9 11:28 1 -> /dev/null
lrwx------. 1 centos centos 64 Feb  9 11:28 2 -> /dev/null
$ sudo ls -l /proc/3909/fd
total 0
lrwx------. 1 centos centos 64 Feb  9 11:29 0 -> /dev/null
lrwx------. 1 centos centos 64 Feb  9 11:29 1 -> /dev/null
lrwx------. 1 centos centos 64 Feb  9 11:29 2 -> /dev/null

如输出所示,两个守护进程都只打开了文件描述符0、1和 2。它们如预期那样都指向了/dev/null

因此,second_demin.c 中的 daemon() 函数实现了 first_daemon.c 中的步骤。

4. 服务

前文中讨论的守护进程是传统的 SysV 守护进程。 不过,也可以使用 systemd 提供的基础实现守护进程。使用 systemd 实现的守护进程也叫新式守护进程。实际上,systemd 将这些守护进程称为服务。

systemd 服务不需要 SysV 守护进程的初始化步骤。因此,它简化了其实现。在运行时监控和控制服务的执行也更容易。

4.1. 启动时的典型步骤

与 SysV 守护进程的实现一样,服务有几个建议实现的步骤:

  1. 服务应该使用 sd-notify() 通知服务管理器服务的启动。通知消息应为 READY=1
  2. 如果服务收到 SIGTERM 信号,它必须正常退出。
  3. 服务应使用通知消息 STOPPING=1 通知服务管理器。
  4. 我们需要提供一个单元文件来将服务与 systemd 集成。

这些是我们将在下一节中实施的步骤。但是,根据需要可能还有其他步骤。例如,如果我们需要将守护进程与 D-Bus 集成,那么我们需要使用 D-Bus IPC 系统定义并公开守护进程的控制接口。

4.2. 服务的实现

我们来编写一个 C 程序 systemd_daemon.c 作为服务的示例:

#include <unistd.h>
#include <signal.h>
#include <systemd/sd-daemon.h>

int signal_captured = 0;

void signal_handler(int signum, siginfo_t *info, void *extra)
{
    signal_captured = 1;
}

void set_signal_handler(void)
{
    struct sigaction action;

    action.sa_flags = SA_SIGINFO;
    action.sa_sigaction = signal_handler;
    sigaction(SIGTERM, &action, NULL);
}

int main()
{
    set_signal_handler();

    sd_notify(0, "READY=1\nSTATUS=Running");

    while(!signal_captured) {
        /* The daemon performs its task here */

        sleep(1);
    }

    sd_notify(0, "STOPPING=1");

    return 0;
}

该程序实现了之前章节中的步骤。 

set_signal_handler() 函数将 signal_handler() 函数设置为 SIGTERM 信号的信号处理器。换言之,当我们发送 SIGTERM 信号给该服务时,操作系统会调用 signal_handler() 函数。该函数仅将 signal_captured 全局变量设置为 1 。

使用 sd_notify()除了通知信息 READY=1,我们还将其中状态信息 STATUS=Running 发送给 systemd。稍后,我们将使用 systemctl status 检查状态。

signal_captured 的初始值为 0。我们在while 循环中不断检查其值。当 signal_handler() 将其值设为 1 时,我们从 while 循环中退出。我们将通知消息 STOPPING=1 发送给 systemd 并退出该程序。

4.3. 该服务的单元文件

我们来编写其单元文件,simple_service.service,将我们的服务与 systemd 集成:

[Unit]
Description=The minimal systemd daemon service

[Service]
Type=notify
ExecStart=/home/centos/work/daemon/systemd_daemon

[Install]
WantedBy=multi-user.target

systemd 使用此单元文件控制该服务。

Unit 区域中的 Description 选项描述了该服务的意图。使用 systemctl status 命令显示该描述。

Service 区域中的 Type 选项描述了 systemd 如何处理该服务。其值 notify 说明该服务发送通知消息给 systemd。我们使用 ExecStart 选项中指定的可执行文件来启动服务。

Insatall  区域中的 WantedBy 选项指定了 systemd 启动服务使用什么运行级别。multi-user.target 相当于 SysV 运行级别的 2、3 和 4。

4.4. 启动服务

首先使用 gcc 编译 systemd_daemon.c

$ gcc -o systemd_daemon systemd_daemon.c -lsystemd

可执行文件名为 systemd_daemon。我们需要将其与 libsystemd.so 库链接,才能使用 sd_notify() 函数。

然后便可使用 systemd 启动该服务:

$ pwd
/etc/systemd/system
$ sudo cp /home/centos/work/daemon/simple_service.service .
$ sudo systemctl enable simple_service.service
Created symlink /etc/systemd/system/multi-user.target.wants/simple_service.service → /etc/systemd/system/simple_service.service.
$ sudo systemctl start simple_service.service

将单元文件复制到 /etc/systemd/system 目录之后,我们使用 systemctl enablesystemctl start 命令启用和启动该服务。由于需要 root 权限,我们通过 sudo 命令将这些命令一起使用。

$ ps -efj | grep systemd_daemon | grep -v grep
root        3402       1    3402    3402  0 14:48 ?        00:00:00 /home/centos/work/daemon/systemd_daemon

我们成功地启动了这项服务。值得注意的是,ps 输出的第八列中的问号意味着服务没有连接到终端。

4.5. 分析服务进程

让我们 systemctl status 检查该服务的状态:

$ sudo systemctl status simple_service
● simple_service.service - The minimal systemd daemon service
   Loaded: loaded (/etc/systemd/system/simple_service.service; enabled; vendor preset: disabled)
   Active: active (running) since Mon 2024-02-12 15:46:14 +03; 9s ago
 Main PID: 3402 (systemd_daemon)
   Status: "Running"
    Tasks: 1 (limit: 11270)
   Memory: 468.0K
   CGroup: /system.slice/simple_service.service
           └─7382 /home/centos/work/daemon/systemd_daemon

Feb 12 15:46:14 host1 systemd[1]: Starting The minimal systemd daemon service...
Feb 12 15:46:14 host1 systemd[1]: Started The minimal systemd daemon service.

该服务处于活动状态并正在运行。它的状态是正在运行(Running),我们通过使用 sd_notify() 向 systemd 发送状态消息 STATUS=Running 来设置它。
进程的 PID、PGID 和 SID 具有相同的值,即 3402。此行为与前述章节中的 SysV 守护进程相同。但是,服务的 PPID(父进程 ID)是1。父进程是系统范围的 systemd 守护进程,它是管理其他守护进程的守护进程
让我们检查一下服务的当前工作目录:

$ sudo pwdx 3402
3402: /

服务的当前工作目录是预期的根目录(/)。

最后,让我们检查 /proc 文件系统中服务的打开文件描述符:

$ sudo ls -l /proc/3402/fd
total 0
lr-x------. 1 root root 64 Feb 12 15:36 0 -> /dev/null
lrwx------. 1 root root 64 Feb 12 15:36 1 -> 'socket:[53876]'
lrwx------. 1 root root 64 Feb 12 15:36 2 -> 'socket:[53876]'

该服务打开了文件描述符 0、1 和 2。文件描述符 0 像之前一样指向 /dev/null。但是,与 SysV 守护进程不同,文件描述符 1 和 2 指向一个 socket,因为 systemd 服务使用 systemd 的日志记录组件,即 journald

5. 结论

本文中,我们讨论了进程、守护进程和服务之间的差异。我们了解到,只要系统在运行,守护程序和服务都是在后台运行的特殊进程。它们与控制终端分离。

我们看到 systemd 将传统的 SysV 守护进程命名为服务。我们还学习了如何实现传统的 SysV 守护进程和 systemd 服务,以便更好地了解它们实现中的差异。此外,我们还比较了它们的运行时行为。