APUE笔记:Daemon Processes(十三)

本文记录《UNIX环境高级编程》第3版 第13章 Daemon Processes 的一些知识点。


守护进程是长期运行的进程。它们通常在系统启动时启动,仅在系统关闭时终止。由于它们没有控制终端,所以我们说它们在后台运行。UNIX系统有许多守护进程,用于执行日常活动。

13.1 守护进程的特征

任何父进程ID为0的进程通常都是作为系统引导程序的一部分启动的内核进程。( init (PID=1)是个例外,它是一个由内核在引导装入时启动的用户层次的命令。)内核进程是特殊的,通常存在于系统的整个生命期中。它们以超级用户特权运行,无控制终端,无命令行。

在ps的输出实例中,内核守护进程的名字出现在方括号中。Linux使用一个名为 kthreadd (PID=2)的特殊内核进程来创建其他内核进程,所以 kthreadd 表现为其他内核进程的父进程。每个需要在进程上下文中执行工作,但不是从用户级进程的上下文中调用的内核组件,通常都会有自己的内核守护进程。

进程1通常是 init(MacOS是launchd),除了其他工作外,主要负责启动各运行层次特定的系统服务。这些服务通常是在它们自己拥有的守护进程的帮助下实现的。

注意,大多数守护进程都以超级用户(root)特权运行。所有的守护进程都没有控制终端,其终端名设置为问号。内核守护进程以无控制终端方式启动。用户级守护进程缺少控制终端可能是守护进程调用了 setsid 的结果。大多数用户层守护进程都是进程组的组长进程以及会话的首进程,而且是这些进程组和会话中的唯一进程(rsyslogd 是一个例外)。最后,应当注意的是用户层守护进程的父进程是 init 进程。


编写规则

编写守护进程的一些基本规则可以防止不必要的交互发生。在此列出这些规则,然后展示一个实现这些规则的函数 daemonize

(1)首先要做的是调用 umask 将文件模式创建屏蔽字设置为一个已知值(通常是0)。

(2)调用fork,然后使父进程exit。这样做实现了下面几点。第一,如果该守护进程是作为一条简单的shell命令启动的,那么父进程终止会让shell认为这条命令已经执行完毕。第二,虽然子进程继承了父进程的进程组 ID,但获得了一个新的进程 ID,这就保证了子进程不是一个进程组的组长进程。这是下面将要进行的 setsid调用的先决条件。

(3)调用 setsid 创建一个新会话。使调用进程:(a)成为新会话的首进程,(b)成为一个新进程组的组长进程,(c)与它的控制终端断开了连接。

(4)将当前工作目录更改为根目录。从父进程处继承过来的当前工作目录可能在一个挂载的文件系统中。由于守护进程通常会一直存在直到系统重启,所以如果守护进程停留在已挂载的文件系统上,该文件系统就无法被卸载。

(5)关闭不再需要的文件描述符。这使守护进程不再持有从其父进程继承来的任何文件描述符(父进程可能是 shell 进程,或某个其他进程)。

(6)一些守护进程会将文件描述符0、1和2指向/dev/null。这样,任何试图从标准输入读取数据或者向标准输出、标准错误写入数据的库函数都不会产生任何效果。因为守护进程并不与终端设备相关联,所以其输出无处显示,也无处从交互式用户那里接收输入。

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
#include "apue.h"
#include <syslog.h>
#include <fcntl.h>
#include <sys/resource.h>
void daemonize(const char *cmd)
{
int i, fd0, fd1, fd2;
pid_t pid;
struct rlimit rl;
struct sigaction sa;
/*
* Clear file creation mask.
*/
umask(0);
/*
* Get maximum number of file descriptors.
*/
if (getrlimit(RLIMIT_NOFILE, &rl) < 0)
err_quit("%s: can’t get file limit", cmd);
/*
* Become a session leader to lose controlling TTY.
*/
if ((pid = fork()) < 0)
err_quit("%s: can’t fork", cmd);
else if (pid != 0) /* parent */
exit(0);
setsid();
/*
* Ensure future opens won't allocate controlling TTYs.
*/
sa.sa_handler = SIG_IGN;
sigemptyset(&sa.sa_mask);
sa.sa_flags = 0;
if (sigaction(SIGHUP, &sa, NULL) < 0)
err_quit("%s: can’t ignore SIGHUP", cmd);
if ((pid = fork()) < 0)
err_quit("%s: can’t fork", cmd);
else if (pid != 0) /* parent */
exit(0);
/*
* Change the current working directory to the root so
* we won't prevent file systems from being unmounted.
*/
if (chdir("/") < 0)
err_quit("%s: can’t change directory to /", cmd);
/*
* Close all open file descriptors.
*/
if (rl.rlim_max == RLIM_INFINITY)
rl.rlim_max = 1024;
for (i = 0; i < rl.rlim_max; i++)
close(i);
/*
* Attach file descriptors 0, 1, and 2 to /dev/null.
*/
fd0 = open("/dev/null", O_RDWR);
fd1 = dup(0);
fd2 = dup(0);
/*
* Initialize the log file.
*/
openlog(cmd, LOG_CONS, LOG_DAEMON);
if (fd0 != 0 || fd1 != 1 || fd2 != 2) {
syslog(LOG_ERR, "unexpected file descriptors %d %d %d", fd0, fd1, fd2);
exit(1);
}
}

错误日志记录

守护进程存在的一个问题是如何处理出错消息。因为它本就不应该有控制终端,所以不能只是简单地写到标准错误上。需要一个中央守护进程错误日志记录工具。

大多数守护进程都使用 syslog 工具。下图显示了 syslog 工具的详细组织结构。

BSD syslog 工具

有3种产生日志消息的方法。

(1)内核例程可以调用 log 函数。任何一个用户进程都可以通过打开(open)并读取(read)/dev/klog 设备来读取这些消息。

(2)大多数用户进程(守护进程)调用 syslog(3) 函数来产生日志消息。这使消息被发送至UNIX域数据报套接字 /dev/log

(3)无论一个用户进程是在此主机上,还是在通过TCP/IP网络连接到此主机的其他主机上,都可将日志消息发向UDP端口514。注意,syslog 函数从不产生这些UDP数据报,它们要求产生此日志消息的进程进行显式的网络编程。

通常,syslogd 守护进程读取所有3种格式的日志消息。此守护进程在启动时读一个配置文件,其文件名一般为 /etc/syslog.conf,该文件决定了不同种类的消息应送向何处。例如,紧急消息可发送至系统管理员(若已登录),并在控制台上打印,而警告消息则可记录到一个文件中。

该工具的接口是 syslog 函数。

1
2
3
4
5
6
#include <syslog.h>
void openlog(const char *ident, int option, int facility);
void syslog(int priority, const char *format, ...);
void closelog(void);
int setlogmask(int maskpri);
// 返回值:前日志记录优先级屏蔽字值

调用 openlog 是可选择的。如果不调用 openlog,则在第一次调用 syslog 时,自动调用 openlog。调用 closelog 也是可选择的,因为它只是关闭了用于与syslogd 守护进程通信的描述符。

调用 syslog 产生一个日志消息。其 priority 参数是 facilitylevel 的组合。level 值按优先级从最高到最低依次排列。

level Description
LOG_EMERG emergency (system is unusable) (highest priority)
LOG_ALERT condition that must be fixed immediately
LOG_CRIT critical condition (e.g., hard device error)
LOG_ERR error condition
LOG_WARNING warning condition
LOG_NOTICE normal, but significant condition
LOG_INFO informational message
LOG_DEBUG debug message (lowest priority)

format 参数以及其他所有参数传至 vsprintf 函数以便进行格式化。在 format 中,每个出现的 %m 字符都先被代换成与 errno 值对应的出错消息字符串(strerror)。

setlogmask 函数用于设置进程的记录优先级屏蔽字。它返回调用它之前的屏蔽字。参数表示 “允许哪些级别通过”。注意,试图将记录优先级屏蔽字设置为0并不会有什么作用。

除了 syslog,很多平台还提供它的一种变体来处理可变参数列表。

1
2
3
#include <syslog.h>
#include <stdarg.h>
void vsyslog(int priority, const char *format, va_list arg);

大多数 syslogd 实现会将消息短时间排队。如果在此段时间中有重复消息到达,那么 syslog 守护进程不会把它写到日志记录中,而是会打印输出一条类似于”last message repeated N times.”的消息。


单实例守护进程

有些守护进程在实现时,为了保证正常运行,同一时间只能有一个副本在运行。例如,这样的守护进程可能需要对某个设备拥有独占访问权。以 cron 守护进程为例,如果有多个实例在运行,每个副本可能都会尝试启动同一个计划任务,这会导致任务重复执行,并且很可能会出错。

如果守护进程需要访问某个设备,设备驱动程序有时会阻止对 /dev 中相应设备节点的多次打开尝试。这就限制了我们一次只能运行一个守护进程副本。但是,如果没有这样的设备可用,我们就需要自己来完成这项工作。

文件和记录锁定机制为确保只有一个守护进程副本在运行提供了一种基础方法。如果每个守护进程都创建一个具有固定名称的文件,并在整个文件上放置写锁,那么只允许创建一个这样的写锁。后续创建写锁的尝试将会失败,这向守护进程的后续副本表明已有另一个实例在运行。

文件和记录锁定提供了一种便捷的互斥机制。如果守护进程获得了整个文件的写锁,那么当守护进程退出时,该锁会自动移除。这简化了恢复过程,省去了我们清理守护进程前一个实例的必要。


守护进程的惯例

在UNIX系统中,守护进程遵循下列通用惯例。

  • 若守护进程使用锁文件,那么该文件通常存储在 /var/run 目录中。需要注意的是,守护进程可能需要具有超级用户权限才能在此目录下创建文件。锁文件的名字通常是 name.pid,其中,name 是该守护进程或服务的名字。例如,cron 守护进程锁文件的名字是 /var/run/crond.pid

  • 若守护进程支持配置选项,那么配置文件通常存放在 /etc 目录中。配置文件的名字通常是 name.conf,其中,name 是该守护进程或服务的名字。例如,syslogd 守护进程的配置文件通常是 /etc/syslog.conf

  • 守护进程可用命令行启动,但通常它们是由系统初始化脚本之一(/etc/rc*/etc/init.d/*)启动的。如果守护进程退出时应自动重启,那么只要我们在 /etc/inittab 中为其包含一个 respawn 条目(假设系统使用System V风格的init命令),就可以让 init 重启它。

  • 若一个守护进程有一个配置文件,那么当该守护进程启动时会读该文件,但在此之后一般就不会再查看它。若某个管理员更改了配置文件,那么该守护进程需要被停止,然后再启动,以使配置文件的更改生效。为避免此种麻烦,某些守护进程将捕捉 SIGHUP 信号,当它们接收到该信号时,重新读配置文件。因为守护进程并不与终端相结合,它们或者是无控制终端的会话首进程,或者是孤儿进程组的成员,所以守护进程没有理由期望接收 SIGHUP。于是,守护进程可以安全地重复使用 SIGHUP


客户进程-服务器进程模型

守护进程常常用作服务器进程。通常来说,服务器是一个等待客户端与其联系并请求某种服务的进程。在图13.2中,syslogd 服务器所提供的服务是记录错误消息。

常见的情况是,服务器会通过 fork 子进程并 exec 另一个程序来为客户端提供服务。这些服务器通常会管理多个文件描述符:通信端点、配置文件、日志文件等等。往好里说,在子进程中让这些文件描述符保持打开状态是一种疏忽,因为子进程执行的程序可能用不到它们,尤其是当该程序与服务器无关时。往坏里说,让它们保持打开状态可能会带来安全问题——被执行的程序可能会做出恶意行为,比如修改服务器的配置文件,或者欺骗客户端使其认为自己正在与服务器通信,从而获取未授权的信息。

解决此问题的一个简单方法是对所有被执行程序不需要的文件描述符设置执行时关闭(close-on-exec)标志。下面的代码展示了一个可以用来在服务器进程中执行上述工作的函数。

1
2
3
4
5
6
7
8
9
10
include <fcntl.h>

int set_cloexec(int fd)
{
int val;
if((val = fcntl(fd, F_GETFD, 0)) < 0)
return -1;
val |= FD_CLOEXEC;
return (fcntl(fd,F_SETFD,val));
}