Linux 网络编程,第 2 部分
守护进程是在后台运行并为各种客户端提供服务的服务器。在创建守护进程时,您应该注意以下几个问题。在开发过程中,始终建议在前台运行服务器,以便使用 printf 或 write 进行调试。此外,如果服务器碰巧变得混乱,可以通过发送中断字符(通常是 ctrl-c)来终止它。在部署使用时,服务器应该被编码为像守护进程一样运行。Unix 中的守护进程程序通常以字母 d 结尾,例如,HTTP 守护进程(Web 服务器)的 httpd。
让守护进程在运行时自动将自己置于后台总是很好的。使用 fork() 调用可以很容易地实现这一点。一个行为良好的守护进程将在 fork 后关闭它从父进程继承的所有打开的文件描述符。如果文件是终端设备,这一点尤其重要,因为必须关闭它们,以便在启动守护进程的用户注销时允许终端状态重置。使用 getrlimit() 调用来确定最大打开文件描述符数并关闭它们。
然后,进程必须更改其进程组。进程组用于分发信号——与终端具有相同组的进程在前台,并允许从终端读取。那些在不同组中的进程被认为在后台(如果他们尝试读取,将被阻塞)。
关闭控制终端并更改会话组可以防止守护进程接收来自先前组领导者(通常是 shell)的隐式信号(即,不是用户使用 kill 命令发送的信号)。
进程在进程组内组织,进程组在会话内组织。使用 setsid() 系统调用,然后创建一个新会话(以及一个新的进程组),并将该进程作为新的会话领导者。
一旦守护进程没有控制终端,它就不能重新获取一个。当进程组领导者打开终端设备时,会自动获取控制终端。防止这种情况发生的最简单方法是在调用 setsid() 后再次 fork。守护进程在这个第二个子进程中运行。由于父进程(会话和进程组领导者)将终止,第二个子进程将获得一个新的进程组零(因为它成为 init 的子进程)。因此,它无法获取新的控制终端,因为它不是进程领导者。许多标准库例程可能假设三个标准 I/O 描述符是打开的。因此,服务器通常打开所有三个描述符,并连接到无害的 I/O 设备,例如 /dev/null。
守护进程通常在启动时启动,并在系统的正常运行时间内保持运行。如果守护进程是从已挂载的文件系统启动的,则在杀死守护进程之前,将无法卸载该文件系统。因此,执行 chdir() 到 /(或者可能是保存与守护进程操作相关的文件系统)是一种明智的守护进程编程实践。守护进程从创建它们的进程继承 umask 信息。为了防止以后在守护进程中创建文件时出现问题,通常使用 umask() 将其设置为零。列表 1 用一些示例代码说明了这些要点。
对于不支持会话的系统(例如,Linux 和 Solaris 以外的一些系统),您可以使用 列表 2 中的代码实现与 setsid() 相同的结果。
当主服务器代码的 fork 子进程退出时,它们的内存被释放,但它们在进程表中的条目不会被删除。换句话说,进程已经死亡,即它们不消耗系统资源,但尚未被收割。它们以这种 僵尸 状形式存在的原因是为了允许父进程在必要时从子进程收集统计信息(例如 CPU 使用率等)。显然,守护进程不希望用僵尸进程填满进程表。
当子进程死亡时,它会向其父进程发送一个 SIGCHLD 信号。此信号的默认处理程序会导致子进程变成僵尸进程,除非它被其父进程显式收割,如 列表 3 中所示。或者,如 列表 4 中所示,您可以忽略该信号并允许僵尸进程死亡。
守护进程也很常见忽略大多数其他信号,或者在收到 SIGHUP 后重新读取任何配置文件并重新启动。许多守护进程将其 PID(进程标识)保存到日志文件中,通常称为 /var/run/foobar.pid(其中 foobar 是守护进程的名称),这可以帮助停止守护进程。
当系统正在关闭(或从多用户更改为单用户)时,会发送 SIGTERM 信号以通知所有进程。init 进程然后等待特定时间量(SVR4 为 20 秒,BSD 为 5 秒,Linux init 为 5 秒,Linux shutdown 为 3 秒或传递的命令行参数)。如果进程仍然存活,则会向其发送一个无法忽略的 SIGKILL 信号。因此,守护进程应捕获 SIGTERM 信号以确保它们优雅地关闭。
在图 1 中,图表显示了为潜在客户端提供网络服务的守护进程的三种潜在设计。在第一张图中,守护进程遵循最常见的技术,即 fork 出一个单独的进程来处理请求,而父进程继续接受新的连接请求。这种并发处理技术的优点是请求不断得到服务,并且可能比串行和迭代服务请求执行得更好。不幸的是,涉及 fork 和潜在的上下文切换,使得这种方法不适合需求非常高的服务器。
第二张图显示了在单个执行上下文中迭代、同步地接受和处理请求,然后再处理另一个请求。这种方法的缺点是,在处理请求期间发生的请求将被阻塞或拒绝。如果被阻塞,它们最多将被阻塞请求处理和通信的持续时间。根据此持续时间,由于侦听队列积压已满,可能会拒绝大量请求。因此,这种方法可能最适合处理持续时间非常短的请求。它也更适合 UDP 网络守护进程,而不是 TCP 守护进程。
第三张图(图 1)是最复杂的——它显示了一个守护进程,该守护进程预先分配新的执行上下文(在本例中为新进程)来处理请求。请注意,主进程在 listen() 之后但在 accept() 调用之前调用 fork()。从进程调用 accept()。这种情况将留下一组潜在的服务器进程同时阻塞 accept() 调用。但是,内核保证对于给定的连接请求,只有一个从进程会成功地进行 accept() 调用。然后,它将在返回到接受状态之前服务该请求。主进程可以退出(忽略 SIGCHLD),也可以持续调用 wait() 以收割退出的从进程。
从进程很常见只接受一定数量的请求,然后在自杀以防止内存泄漏累积。接受请求数量最少的进程(或者可能是特殊的管理器父进程)然后将根据需要创建新进程。许多流行的 Web 服务器都实现了预 fork 服务器线程池(例如,Netscape、Apache)。
如果请求的服务器进程时间非常短(通常情况),则并发处理并非总是必要的。通过避免上下文切换的开销,迭代服务器可能会表现更好。并发和迭代设计之间的一种混合解决方案是延迟分配新服务器进程。服务器将开始迭代处理请求。如果该请求的处理时间很长,它将创建一个单独的从进程来完成处理该请求。因此,主进程可以在创建新从进程之前检查请求的有效性,或处理短请求。
要使用延迟进程分配,请使用 alarm() 系统调用,如 列表 5 中所示。在主进程中建立一个计时器,当计时器到期时,将调用信号处理程序。在处理程序内部执行 fork() 系统调用。父进程关闭请求连接并返回到接受状态,而子进程处理请求。setjmp() 系统调用记录进程堆栈环境的状态。当稍后调用 longjmp() 时,进程将恢复到与 setjmp() 保存的状态完全相同的状态。longjmp() 的第二个参数是 setjmp() 在堆栈恢复时将返回的值。
所有这些示例中的 fork 都可以替换为对 pthread_create() 的调用,以创建新的执行线程,而不是完整的重量级进程。如前所述,线程应该是内核级线程,以确保一个线程中的 I/O 阻塞不会使其他线程缺乏 CPU 注意力。这涉及使用 Xavier Leroy 出色的内核级 Linux Threads 包 (http://pauillac.inria.fr/~xleroy/linuxthreads/),该包基于 clone() 系统调用。
使用线程实现比使用 fork() 模型引入了更多的复杂性。诚然,使用线程可以大大节省上下文切换时间和内存使用量。其他问题也会出现,例如文件描述符的可用性和关键部分的保护。
大多数操作系统限制进程可以拥有的打开文件描述符的数量。尽管进程可以使用 getrlimit() 和 setrlimit() 调用将其增加到系统范围的最大值,但此值通常在 /usr/include/sys/param.h 文件中由 NOFILE 设置为 256。
即使调整 /usr/src/linux/include/linux/fs.h 文件中的 NOFILE 和值 NR_OPEN 和 NR_FILE 并重新编译内核也可能无济于事。虽然在 Linux 中 FILE struct 的 fileno 元素(在 Linux 中实际上称为 _fileno)的类型为 int,但在其他系统中通常为 unsigned char,将缓冲 I/O 命令(fopen()、fprintf() 等)的文件描述符限制为 255。这种差异影响了完成的应用程序的可移植性。
由于线程使用公共内存空间,因此必须小心确保此空间始终处于一致状态且不会损坏。这可能涉及序列化对多个线程访问的共享数据(临界区)的写入(以及可能的读取)。这可以通过使用锁来实现,但必须注意避免进入死锁状态。
init 的主要作用是从存储在 /etc/inittab 文件中的信息创建进程。它直接或间接地负责系统上运行的所有用户创建的进程。它可以重新生成它启动的进程,如果它们死亡。
如果守护进程按照列表 1 中的代码进行 fork,则 init 的重新生成能力将变得非常混乱。原始守护进程将立即退出(子守护进程继续运行),并且 init 将认为这意味着守护进程已死亡。一个简单的解决方案是向守护进程添加一个命令行开关(可能是 -init)以通知它避免 fork 代码。更好的解决方案是从 /etc/rc 脚本而不是从 /etc/inittab 启动守护进程。
/etc/rc 的 System V 布局在流行的 Red Hat 和 Debian Linux 发行版中使用。在此系统中,每个必须启动/停止的守护进程在 Red Hat 的 /etc/rc/init.d 和 Debian 的 /etc/init.d 中都有一个脚本。调用此脚本时使用单个命令行参数 start 来启动守护进程,使用单个命令行参数 stop 来停止守护进程。该脚本通常以守护进程命名。
如果您想在特定的运行级别启动守护进程,您将需要从运行级别目录到 /etc/rc/init.d 中的相应脚本的链接。您必须将此启动链接命名为 Sxxfoobar,其中 foobar 是守护进程的名称,xx 是两位数字。该数字用于安排脚本的运行顺序。
同样,如果您希望守护进程在退出特定运行级别时死亡,您将需要从运行级别目录到 /etc/rc/init.d 脚本的相应链接。kill 链接必须命名为 Kxxfoobar,遵循与启动链接相同的命名约定。
允许系统管理员启动/停止守护进程(通过使用适当的命令行参数从 /etc/rc/init.d 调用相应的脚本)是 SysV 结构的优点之一,以及它比以前的 BSD 风格的 /etc/rc.d 布局具有更大的灵活性。
列表 6 中的 shell 脚本显示了 /etc/rc/init.d 中名为 foobar 的守护进程的典型 Red Hat 风格示例。
守护进程通常需要记录其活动以用于调试和系统管理/维护目的。它通过打开一个文件并将事件写入此文件来实现。许多 Linux 守护进程使用 syslog() 调用来记录守护进程状态信息等。syslog 是一个客户端-服务器日志记录工具,起源于 BSD 4.2。我不知道任何 SVR4 或 POSIX 等效项。发送到 syslog 服务的消息通常发送到 /etc/syslog.conf 中描述的文本文件,但也可能发送到运行 syslogd 守护进程的远程计算机。
使用 Linux syslog 接口非常简单。/usr/include/syslog.h 中原型化了三个函数调用(请参阅 syslog.3 手册页)
void openlog(char *ident, int option, int facility); void syslog(int priority, char *format, ...); void closelog(void);
openlog() 创建与系统日志记录器的连接。ident 字符串添加到每个记录的消息中,通常是守护进程的名称。option 参数允许在发生错误时记录到控制台,记录到 stderr 以及控制台,记录 PID 等。facility 参数对记录消息的程序或守护进程的类型进行分类,默认值为 LOG_USER。
syslog() 调用执行实际的日志记录。format 和可变参数的值类似于 printf(),但 %m 将被替换为与 errno 当前值对应的错误消息。priority 参数指示正在记录的消息的类型和相对重要性。
要断开与系统日志记录器的连接并关闭任何关联的文件描述符或套接字,请使用 closelog()。openlog() 和 closelog() 的使用是可选的。有关这些功能的更详细信息,请参见 syslog (3) 手册页。

Dr. John Nelson 是利默里克大学计算机工程高级讲师。他的兴趣包括移动通信、智能网络、软件工程和 VLSI 设计。他的电子邮件地址是 john.nelson@ul.ie。