守护进程
守护进程是生存期长的一种进程,在系统引导装入时启动,在系统关闭时终止。
守护进程没有控制终端,通常在后台运行,实际上是在后台的孤儿进程组中运行。
没有控制终端的原因在于不与用户交互,避免终端信号影响。
在孤儿进程组中运行的原因在于防止其获取控制终端(通过每次打开终端设备设置参数O_NOCTTY
的方式不太靠谱)。
守护进程
守护进程示例
下面是守护进程示例:
$ ps -ajx
UID PID PPID PGID SID TTY COMMAND
0 1 0 1 1 ? /lib/systemd/systemd --system --deserialize=43
0 2 0 0 0 ? [kthreadd]
0 15 2 0 0 ? [ksoftirqd/0]
0 17 2 0 0 ? [migration/0]
0 59 2 0 0 ? [watchdogd]
0 62 2 0 0 ? [kswapd0]
0 107 2 0 0 ? [kworker/0:1H-kblockd]
0 365 2 0 0 ? [jbd2/sda2-8]
0 455 2 0 0 ? [rpciod]
108 703 1 703 703 ? /sbin/rpcbind -f -w
0 736 1 736 736 ? /usr/sbin/nfsdcld
0 846 1 846 846 ? /usr/sbin/cron -f -P
109 863 1 863 863 ? /sbin/rpc.statd
0 6516 1 6516 6516 ? /usr/sbin/rsyslogd -n -iNONE
0 6560 2 0 0 ? [nfsd]
0 6546 2 0 0 ? [lockd]
0 6528 1 6528 6528 ? /usr/sbin/rpc.idmapd
0 6535 1 6535 6535 ? /usr/sbin/rpc.mountd
ps
参数-a
显示所有用户进程,-x
显示没有控制中断的进程状态,-j
显示与作业控制相关的进程信息:会话ID、进程组ID、控制终端等。
父进程ID为0的进程通常是内核进程,在系统引导装入内存过程中启动,但initd
与systemd
进程除外,是系统引导过程中启动的第一个用户空间进程。
内核进程存在于整个系统生命周期中,以超级用户权限运行,无控制终端,无命令行。
在上面的示例中,被方括号包裹的属于内核进程。kthreadd
进程是第一个内核进程,其他内核进程通常由kthreadd
进程来创建。
对于需要在进程上下文执行工作,但不会被用户层进程上下文调用的每一个内核组件,通常都会有其自己的内核守护进程。例如:
kswapd0
守护进程也称为内存换页守护进程(工作在内核的进程上下文中)。主要作用是将虚拟内存子系统中的脏页面写回磁盘。jbd*
守护进程支持ext4
文件系统中的日志功能。
进程ID为1的initd
或systemd
进程主要用于创建用户空间的守护进程。例如:
rpcbind
守护进程提供将远程过程调用程序号映射为网络端口号的服务。rsyslogd
守护进程可以被具有超级用户权限的进程在写入系统日志时使用。nfsd, lockd, rpciod, rpc.idmapd, rpc.mountd, rpc.statd
等守护进程提供对NFS的支持(其中既有内核守护进程也有用户级守护进程)。cron
守护进程在定期安排的日期和时间执行命令。sshd
进程提供安全的远程登录和执行环境。
守护进程特征
- 大多数守护进程都以超级用户(
root
)特权运行。 - 所有守护进程都没有控制终端,其终端名设置为问号。
- 内核守护进程以无终端方式启动,用户层守护进程缺少控制终端可能是调用了
setsid
的结果。 - 大多数用户层守护进程都是进程组的组长进程以及会话的首进程,而且是这些进程组和会话中的唯一进程。
- 用户层守护进程的父进程都是
init
或systemd
进程。
如何编写守护进程
在写守护进程时需要遵循一些基本规则,可以防止不必要的交互。
- 调用
umask()
函数修改文件模式创建屏蔽字设置为一个已知值。因为从父进程继承来的文件模式创建屏蔽字可能会屏蔽某些需要的权限。 - 调用
fork()
返回后使父进程exit
。原因有两点:1是如果该守护进程是通过shell命令启动的,那么父进程终止会使shell返回并认为该命令已经执行结束。2是可以确保子进程不是一个进程组的组长进程,可用于调用setsid()
函数创建新会话。 - 调用
setsid()
创建一个新会话。这么做的原因有三点:1是新会话没有控制终端,不会受到终端信号的干扰(控制终端通常用于交互式会话)。2是成为一个独立的进程组的组长进程,方便与其他进程进行通信。3是成为新会话的会话首进程,独立于其他会话和进程来运行。 - 再次调用
fork()
返回后使父进程exit
。这是因为第3步执行结束之后,守护进程是会话首进程,可能会在调用外部函数时获取控制终端。这一步可以保证守护进程父进程终止,自身处于孤儿进程组中,再也无法获取控制终端。 - 将当前工作目录改为根目录。因为从父进程继承的当前工作目录可能是一个挂载的文件系统中,而守护进程通常在系统再引导之前一直存在,这会导致该文件系统无法卸载。
- 关闭不再需要的文件描述符。
- 打开
/dev/null
使其具有文件描述符0,1,2。使得任何一个试图读标准输入、写标准输出或标准错误的调用都不会产生任何效果。
通过以上步骤,可以确保守护进程不与控制终端相关联,无法输出,也无法从任何地方获取输入。即使守护进程是从交互式会话中启动的,但守护进程是在后台孤儿进程组中运行的,因此登录会话的终止也不会影响守护进程。
守护进程示例
参照上面提出的7个步骤,下面给出了一个接口,用于将当前进程设置为守护进程。
示例中设置SIGHUP
信号的处理程序为忽略,事实上意义不大,但该信号通常用于通知守护进程重新加载配置文件。
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/resource.h>
#include <sys/stat.h>
#include <unistd.h>
void daemonize();
int main()
{
daemonize();
}
void daemonize()
{
pid_t pid;
struct rlimit rl;
// 1. 设置文件模式创建屏蔽字
umask(0);
// 获取最大描述符数
if (getrlimit(RLIMIT_NOFILE, &rl) < 0)
perror("getrlimit");
// 2. 调用fork并使父进程退出,子进程不是进程组组长,可以调用setsid创建会话。
if ((pid = fork()) < 0)
{
perror("fork");
exit(-1);
}
else if (pid != 0)
{
exit(0);
}
// 3. 成为会话首进程并放弃控制终端,称为新进程组长进程
sleep(30);
setsid();
// 子进程此时已经是会话首进程并且该会话也没有控制终端,但是该标准输出被发送到哪里取决于运行环境
// 大多数情况下,如果父进程是在终端中运行的,并且没有重定向子进程的标准输出,
// 那么子进程的输出仍然会显示在终端上,即使子进程本身没有控制终端。
printf("pid=%d ppid=%d sid==%d\n", getpid(), getppid(), getsid(getpid()));
sleep(10);
// SIGHUP信号的处理可有可无,通常可以用来通知守护进程重新加载配置文件
struct sigaction sa;
sa.sa_handler = SIG_IGN;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
if (sigaction(SIGHUP, &sa, NULL) < 0)
{
perror("sigaction");
exit(-1);
}
// 4.再次调用fork使父进程退出,子进程为孤儿进程组,可确保不会获取控制终端
if ((pid = fork()) < 0)
{
perror("fork");
exit(-1);
}
else if (pid != 0)
exit(0);
// 5. 更改工作目录为/,防止当前工作目录在一个挂载的文件系统中。
if (chdir("/") < 0)
perror("chdir");
// 6. 关闭所有文件描述符
if (rl.rlim_max == RLIM_INFINITY)
rl.rlim_max = 1024;
for (int i = 0; i < rl.rlim_max; i++)
close(i);
// 6. 重定向标准流
int fd0, fd1, fd2;
fd0 = open("/dev/null", O_RDWR);
fd1 = dup(0);
fd2 = dup(0);
// 处理业务
while (1)
{
sleep(3);
}
}
// 执行结果如下:
// $ ./daemonize
// $ pid=17941 ppid=1 sid==17941
// PPID PID PGID SID TTY TPGID COMMAND
// 1 16232 16221 16221 ? -1 ./daemonize
从结果中可以看出守护进程的父进程为init进程,不是进程组的组长进程(也不是会话首进程,可以确保无法获取控制终端),同时也没有控制终端。
遵循惯例
编写守护进程通常遵循以下几点惯例:
- 如果守护进程需要使用锁文件,那么通常该文件存储在
/var/run
目录下,命名为name.pid
,name
为守护进程名,但一般需要超级用户权限才能在该目录下创建文件。 - 如果守护进程支持配置选项,那么配置文件一般为
/etc/name.conf
,其中name
为守护进程名。例如syslogd进程的配置文件名为syslogd.conf
。 - 守护进程虽然可用命令行启动,但通常是通过系统启动脚本来启动,比如
/etc/init.d/*
、/etc/rc*
以及systemd
的service
文件配置等。如果守护进程终止,通常应配置自动重启。 - 守护进程在启动后读取完配置文件,之后配置文件发生变更,通常通过捕捉
SIGHUP
信号,收到该信号后重新读取配置文件。
示例2遵循上述惯例并支持重新读取配置文件,同时在单独线程中进行信号处理。
使用场景
单实例守护进程
由于业务需要,某些守护进程在任一时刻可能仅需运行一个副本。
例如cron
守护进程,如果同时有多个副本运行,那么设置的定时任务就会运行多次导致出错。
那么如何保证守护进程仅有一个副本在运行呢?通过文件和记录锁机制可以实现这个功能。
如果守护进程创建了一个指定名称的文件,并在该文件的整体上加一把写锁,那么在该文件上仅允许创建一把这样的写锁。之后再创建写锁的操作都会失败,这可以向后续启动的守护进程副本指示已经有该守护进程在运行中。
该守护进程终止时,这把锁将会被自动删除,这就意味着新的守护进程不用对以前操作进行清理。
示例1给出了使用文件记录锁来保证守护进程单实例运行的相关代码。
示例
示例1
在该示例中,当LOCKFILE
指定的文件不存在时先创建文件,然后对文件进行加锁。
后面启动的守护进程再次对该文件加锁时就会出错,然后退出进程。
详细代码如下:
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/syslog.h>
#include <syslog.h>
#include <unistd.h>
#define LOCKFILE "/var/run/single_daemon.pid"
#define LOCKMOD (S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH)
int lockfile(int fd)
{
struct flock fl;
fl.l_type = F_WRLCK; // 写锁
fl.l_start = 0;
fl.l_whence = SEEK_SET; // 从开始位置锁定
fl.l_len = 0;
return fcntl(fd, F_SETLK, &fl);
}
int already_running()
{
int fd;
char buf[16];
fd = open(LOCKFILE, O_RDWR | O_CREAT, LOCKMOD);
if (fd < 0)
{
syslog(LOG_ERR, "can't open %s: %s", LOCKFILE, strerror(errno));
exit(-1);
}
errno = 0;
if (lockfile(fd) < 0)
{
if (errno == EACCES || errno == EAGAIN)
{
syslog(LOG_ERR, "can't lock %s: %s", LOCKFILE, strerror(errno));
close(fd);
exit(-1);
}
syslog(LOG_ERR, "can't lock %s: %s", LOCKFILE, strerror(errno));
exit(-1);
}
// 文件长度截断为0的原因在于存在之前的守护进程的ID长度大于当前守护进程ID
ftruncate(fd, 0);
sprintf(buf, "%d", getpid());
write(fd, buf, strlen(buf) + 1);
return 0;
}
int main()
{
already_running();
while (1)
sleep(30);
}
当第二次启动该进程时,日志中会提示报错(删除了不相关的日志):
$ tail -f syslog
2024-04-10T23:16:46.706011+08:00 ubuntu-server single_daemon: can't lock /var/run/single_daemon.pid: Resource temporarily unavailable
示例2
在该示例中,通过调用之前实现的函数daemonize
将当前进程设置为守护进程,调用函数already_running
保证仅有一个实例运行。
同时在单独的线程中捕捉SIGHUP
与SIGTERM
信号,当收到SIGHUP
信号后重新读取配置文件,收到SIGTERM
信号进程退出。
详细代码如下:
#include <errno.h>
#include <fcntl.h>
#include <pthread.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/resource.h>
#include <sys/stat.h>
#include <sys/syslog.h>
#include <syslog.h>
#include <unistd.h>
sigset_t mask;
extern int already_running()
extern void daemonize();
void reread(void)
{
// 该函数用于重新读取配置
syslog(LOG_INFO, "Read configuration file");
}
void* thr_fn(void* arg)
{
int err, signo;
for (;;)
{
err = sigwait(&mask, &signo);
if (err != 0)
{
syslog(LOG_ERR, "sigwait failed");
exit(-1);
}
switch (signo)
{
case SIGHUP:
// 捕捉到SIGHUP信号,则重新读取配置
syslog(LOG_INFO, "Re-reading configuration file");
reread();
break;
case SIGTERM:
// 收到SIGTERM信号则打印日志并退出进程
syslog(LOG_INFO, "got SIGTERM; exiting");
exit(0);
default:
syslog(LOG_INFO, "unexpected signal %d\n", signo);
}
}
return (0);
}
int main(int argc, char* argv[])
{
int err;
pthread_t tid;
char* cmd;
struct sigaction sa;
if ((cmd = strrchr(argv[0], '/')) == NULL)
cmd = argv[0];
else
cmd++;
daemonize();
if (already_running())
{
syslog(LOG_ERR, "daemon already running");
exit(-1);
}
// 恢复SIGHUP信号的默认处理行为,这是因为daemonize中忽略了该信号
sa.sa_handler = SIG_DFL;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
if (sigaction(SIGHUP, &sa, NULL) < 0)
{
syslog(LOG_ERR, "can't restore SIGHUP default");
exit(-1);
}
// 在当前线程中阻塞所有信号,防止在信号处理线程调用sigwait之前信号被发送给当前线程
sigfillset(&mask);
if ((err = pthread_sigmask(SIG_BLOCK, &mask, NULL)) != 0)
{
syslog(LOG_ERR, "SIG_BLOCK error");
exit(-1);
}
// 创建新线程处理信号
err = pthread_create(&tid, NULL, thr_fn, 0);
if (err != 0)
{
syslog(LOG_ERR, "can't create thread");
exit(-1);
}
while (1)
{
sleep(30);
}
}
// 执行结果如下:
// $ ps -ef | grep readconfig*
// root 76681 1 0 23:13 ? 00:00:00 ./readconfig_daemon
// $ sudo kill -1 76681
// $ ps -ef | grep readconfig*
// root 76681 1 0 23:13 ? 00:00:00 ./readconfig_daemon
// $ sudo kill -15 76681
// $ ps -ef | grep readconfig*
// $
// 日志输出如下
// $ tail -f syslog
// 2024-04-11T23:14:15.303630+08:00 ubuntu-server readconfig_daemon: Re-reading configuration file
// 2024-04-11T23:14:15.303772+08:00 ubuntu-server readconfig_daemon: Read configuration file
// 2024-04-11T23:14:49.629462+08:00 ubuntu-server readconfig_daemon: got SIGTERM; exiting
在该示例中,信号处理线程通过在循环中调用sigwait
来捕捉并处理信号。捕捉到SIGHUP
信号则重新读取配置。
同时在设置单独线程捕捉SIGHUP
信号之前,对所有信号都进行了阻塞,用以防止在调用sigwait
函数之前的时间窗口中信号丢失或者被发送给当前线程而非专门的信号处理线程。
- 原文作者:生如夏花
- 原文链接:https://blduan.top/post/%E8%AF%BB%E4%B9%A6%E7%AC%94%E8%AE%B0/apue/%E5%AE%88%E6%8A%A4%E8%BF%9B%E7%A8%8B/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。