研究进程的生命周期,以便您能将所看到的系统上发生的事情与内核中发生的事情联系起来。系统管理员必须知道 UNIX® 环境中的进程是如何创建和销毁的,以便了解系统是如何组合起来的,以及如何管理行为异常的进程。类似地,开发人员必须了解 UNIX 进程模型,以便编写稳定的应用程序,这些应用程序在无人参与的情况下运行,并且不会给系统管理员带来问题。
分配给系统管理员的许多工作之一是确保用户的程序正确运行。因为系统上存在其他并发运行的程序,所以此任务变得更加复杂。由于种种原因,这些程序可能会失败、挂起或行为异常。在构建更可靠的系统时,了解 UNIX® 环境如何创建、管理和销毁这些作业是至关重要的步骤。
开发人员还必须积极了解内核如何管理进程,因为与系统的其他部分和睦相处的应用程序会占用更少的资源,并且不会频繁地给系统管理员带来麻烦。由于导致僵死进程(将在稍后对其进行描述)而频繁重新启动的应用程序明显是不可取的。对控制进程的 UNIX 系统调用的了解可以使开发人员编写能够在后台自动运行的软件,而不是需要一个始终保持在用户屏幕上的终端会话。
管理这些程序的基本构件就是进程。进程是赋予某个由操作系统执行的程序的名称。如果您熟悉 ps
命令,则您应该熟悉进程清单,如清单 1 所示。
清单 1. ps 命令的输出
sunbox#ps -ef UID PID PPID C STIME TTY TIME CMD root 0 0 0 20:15:23 ? 0:14 sched root 1 0 0 20:15:24 ? 0:00 /sbin/init root 2 0 0 20:15:24 ? 0:00 pageout root 3 0 0 20:15:24 ? 0:00 fsflush daemon 240 1 0 20:16:37 ? 0:00 /usr/lib/nfs/statd ... |
前三列对这里的讨论非常重要。第一列列出用于运行该进程的用户身份,第二列列出进程的 ID,第三列列出该进程的父进程 ID。最后一列是进程的描述,通常是所运行的二进制文件的名称。每个进程都被分配一个标识符,称为进程标识符(Process Identifier,PID)。进程还有父进程,在大多数情况下就是启动它的进程的 PID。
父 PID (PPID) 的存在意味着这是一个由别的进程创建的进程。最初创建进程的原始进程名为 init
,它始终被赋予 PID 1。init
是将在内核启动时启动的第一个实际进程。启动系统的其余部分是 init
的工作。init
和其他具有 PPID 0 的进程属于内核。
fork(2)
系统调用创建一个新进程。清单 2 显示了一个简单 C 代码片段中使用的 fork
。
清单 2. 简单的 fork(2) 用法
sunbox$ cat fork1.c #include <unistd.h> #include <stdio.h> int main (void) { pid_t p; /* fork returns type pid_t */ p = fork(); printf("fork returned %d/n", p); } sunbox$ gcc fork1.c -o fork1 sunbox$ ./fork1 fork returned 0 fork returned 698 |
fork1.c
中的代码不过就是发出 fork
调用,并通过一个 printf
调用来打印整数结果。虽然只发出了一个调用,但是打印了两次输出。这是因为在 fork
调用中创建了一个新进程。现在有两个单独的进程在从该调用返回结果。这通常被描述为“调用一次,返回两次”。
fork
返回的值非常有趣。其中一个返回 0;另一个返回一个非零值。获得 0 的进程称为子进程,非零结果属于原始进程,即父进程。您将使用返回值来确定哪个是父进程,哪个是子进程。由于两个进程都在同一空间中继续运行,唯一有实际意义的区别是从 fork
返回的值。
0 和非零返回值的基本原理在于,子进程始终可以通过 getppid(2)
调用来找出其父进程是谁,但是父进程要找出它的所有子进程却很困难。因此,要告诉父进程关于其新的子进程的信息,而子进程可在需要时查找其父进程。
考虑到 fork
的返回值,现在该代码可以检查确定它是父进程还是子进程,并进行相应的操作。清单 3 显示了一个基于 fork
的结果来打印不同输出的程序。
清单 3. 更完整的 fork 用法示例
sunbox$ cat fork2.c #include <unistd.h> #include <stdio.h> int main (void) { pid_t p; printf("Original program, pid=%d/n", getpid()); p = fork(); if (p == 0) { printf("In child process, pid=%d, ppid=%d/n", getpid(), getppid()); } else { printf("In parent, pid=%d, fork returned=%d/n", getpid(), p); } } sunbox$ gcc fork2.c -o fork2 sunbox$ ./fork2 Original program, pid=767 In child process, pid=768, ppid=767 In parent, pid=767, fork returned=768 |
清单 3 在每个步骤打印出 PID,并且该代码检查从 fork
返回的值来确定哪个进程是父进程,哪个进程是子进程。对所打印的 PID 进行比较,可以看到原始进程是父进程 (PID 767),并且子进程 (PID 768) 知道其父进程是谁。请注意子进程如何通过 getppid
来知道其父进程以及父进程如何使用 fork
来定位其子进程。
现在您已经了解了复制某个进程的方法,下面让我们研究如何运行一个不同的进程。fork
只是进程机制中的一半。exec
系列系统调用运行实际的程序。
![]() ![]() |
![]()
|
exec
的工作是将当前进程替换为一个新进程。请注意“替换”这个措词的含义。在您调用 exec
以后,当前进程就消失了,新进程就启动了。如果希望创建一个单独的进程,您必须首先运行 fork
,然后在子进程中执行 (exec
) 新的二进制文件。清单 4 显示了这样一种情况。
清单 4. 通过将 fork 与 exec 配合使用来运行不同的程序
sunbox$ cat exec1.c #include <unistd.h> #include <stdio.h> int main (void) { /* Define a null terminated array of the command to run followed by any parameters, in this case none */ char *arg[] = { "/usr/bin/ls", 0 }; /* fork, and exec within child process */ if (fork() == 0) { printf("In child process:/n"); execv(arg[0], arg); printf("I will never be called/n"); } printf("Execution continues in parent process/n"); } sunbox$ gcc exec1.c -o exec1 sunbox$ ./exec1 In child process: fork1.c exec1 fork2 exec1.c fork1 fork2.c Execution continues in parent process |
清单 4 中的代码首先定义一个数组,其中第一个元素是要执行的二进制文件的路径,其余元素充当命令行参数。根据手册页的描述,该数组以 Null 结尾。在从 fork
系统调用返回以后,将指示子进程执行 (execv
) 新的二进制文件。
execv
调用首先取得一个指向要运行的二进制文件名称的指针,然后取得一个指向您前面声明的参数数组的指针。该数组的第一个元素实际上是二进制文件的名称,因此参数实际上是从第二个元素开始的。请注意,该子进程一直没有从 execv
调用返回。这表明正在运行的进程已被新进程所替换。
还存在其他执行 (exec
) 某个进程的系统调用,它们的区别在于接受参数的方式和是否需要传递环境变量。execv(2)
是替换当前映像的较简单方法之一,因为它不需要关于环境的信息,并且它使用以 Null 结尾的数组。其他选项包括 execl(2)
(它单独接受各个参数)或 execvp(2)
(它也接受一个以 Null 结尾的环境变量数组)。使问题复杂化的是,并非所有操作系统都支持所有变体。关于使用哪一种变体的决定取决于平台、编码风格和是否需要定义任何环境变量。
![]() ![]() |
![]()
|
当某个进程复制它自身时,内核生成所有打开的文件描述符的副本。文件描述符是指向打开的文件或设备的整数,并用于执行读取和写入。如果在调用 fork
前,某个程序已经打开了一个文件,如果两个进程都尝试执行读取或写入,会发生什么情况呢?一个进程会改写另一个进程中的数据吗?是否会读取该文件的两个副本?清单 5 对此进行了研究,它打开两个文件——一个文件用于读取,另一个文件用于写入——并让父进程和子进程同时执行读取和写入。
清单 5. 同时对同一文件执行读取和写入的两个进程
#include <stdio.h> #include <strings.h> #include <unistd.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> int main(void) { int fd_in, fd_out; char buf[1024]; memset(buf, 0, 1024); /* clear buffer*/ fd_in = open("/tmp/infile", O_RDONLY); fd_out = open("/tmp/outfile", O_WRONLY|O_CREAT); fork(); /* It doesn't matter about child vs parent */ while (read(fd_in, buf, 2) > 0) { /* Loop through the infile */ printf("%d: %s", getpid(), buf); /* Write a line */ sprintf(buf, "%d Hello, world!/n/r", getpid()); write(fd_out, buf, strlen(buf)); sleep(1); memset(buf, 0, 1024); /* clear buffer*/ } sleep(10); } sunbox$ gcc fdtest1.c -o fdtest1 sunbox$ ./fdtest1 2875: 1 2874: 2 2875: 3 2874: 4 2875: 5 2874: 6 2874: 7 sunbox$ cat /tmp/outfile 2875 Hello, world! 2874 Hello, world! 2875 Hello, world! 2874 Hello, world! 2875 Hello, world! 2874 Hello, world! 2874 Hello, world! |
清单 5 是用于打开文件的简单程序,并派生 (fork
) 为父进程和子进程。每个进程从同一文件描述符(它只是一个包含数字 1 至 7 的文本文件)执行读取操作,并连同 PID 一起打印所读取的内容。在读取一行之后,将 PID 写到输出文件。当输入文件中没有其他字符可供读取时,循环结束。
清单 5 的输出表明,当一个进程从该文件读取时,两个进程的文件指针都在移动。同样地,当向某个文件写入时,下一个字符被写到文件结尾。这是非常有意义的,因为内核跟踪打开文件的信息。文件描述符只不过是进程的标识符。
您可能还知道,标准输出(屏幕)也是一个文件描述符。此文件描述符在 fork
调用期间被复制,这就是两个进程都能对屏幕执行写入操作的原因。
![]() ![]() |
![]()
|
进程必须在某个时候终止。问题只是哪个进程首先终止:父进程还是子进程。
如果父进程在子进程之前终止,孤立的子进程需要知道它们的父进程是谁。记住,每个进程都有父进程,并且您可以跟踪从每个子进程一直到 PID 1(或称为 init
)的整个进程家族树。当某个父进程终止时,init
将接纳所有子进程,如清单 6 所示。
清单 6. 在子进程之前终止的父进程
#include <unistd.h> #include <stdio.h> int main(void) { int i; if (fork()) { /* Parent */ sleep(2); _exit(0); } for (i=0; i < 5; i++) { printf("My parent is %d/n", getppid()); sleep(1); } } sunbox$ gcc die1.c -o die1 sunbox$ ./die1 My parent is 2920 My parent is 2920 sunbox$ My parent is 1 My parent is 1 My parent is 1 |
在此例中,父进程调用 fork
,等待两秒钟,然后退出。子进程在五秒钟内继续打印其父 PID。可以看到,PPID 在父进程终止后更改为 1。Shell 提示符的返回也是非常有趣的。由于子进程在后台运行,父进程一终止,控制即返回到 Shell。
清单 7 与清单 6 相反——即在父进程之前终止的子进程。为更好地说明所发生的事情,进程本身中没有打印任何内容。而有趣的信息来自于进程清单。
清单 7. 子进程在父进程之前终止
sunbox$ cat die2.c #include <unistd.h> #include <stdio.h> int main(void) { int i; if (!fork()) { /* Child exits immediately*/ _exit(0); } /* Parent waits around for a minute */ sleep(60); } sunbox$ gcc die2.c -o die2 sunbox$ ./die2 & [1] 2934 sunbox$ ps -ef | grep 2934 sean 2934 2885 0 21:43:05 pts/1 0:00 ./die2 sean 2935 2934 0 - ? 0:00 <defunct> sunbox$ ps -ef | grep 2934 [1]+ Exit 199 ./die2 |
die2
使用 &
操作符在后台运行,然后显示一个进程清单,并且仅显示正在运行的进程及其子进程。PID 2934 是父进程,PID 2935 是派生 (fork
) 并立即终止的进程。尽管子进程提前退出,但它仍然在进程表中作为失效 (defunct) 进程存在,或称为僵死 (zombie) 进程。当父进程在 60 秒以后终止时,两个进程都消失了。
当子进程终止时,会使用一个名为 SIGCHLD
的信号来通知其父进程。该通知的确切机制现在对您并不重要。重要的是父进程必须以某种方式确认子进程的终止。子进程从终止时起就一直处于僵死状态,直到父进程确认该信号为止。僵死进程不运行或消耗 CPU 周期;它只是占用进程表空间。当父进程终止时,内核最终能够回收未确认的子进程以及父进程。这意味着可消除僵死进程的唯一方法是终止父进程。处理僵死进程的最好方法是首先确保它们不会发生。清单 8 中的代码实现了一个处理传入的 SIGCHLD
信号的信号处理程序。
清单 8. 实际操作中的信号处理程序
#include <unistd.h> #include <stdio.h> #include <sys/types.h> #include <sys/wait.h> void sighandler(int sig) { printf("In signal handler for signal %d/n", sig); /* wait() is the key to acknowledging the SIGCHLD */ wait(0); } int main(void) { int i; /* Assign a signal handler to SIGCHLD */ sigset(SIGCHLD, &sighandler); if (!fork()) { /* Child */ _exit(0); } sleep(60); } sunbox$ gcc die3.c -o die3 sunbox$ ./die3 & [1] 3116 sunbox$ In signal handler for signal 18 ps -ef | grep 3116 sean 3116 2885 0 22:37:26 pts/1 0:00 ./die3 |
由于使用了 sigset
函数(它向信号处理程序分配一个函数指针),清单 8 比前一个示例稍微复杂一点,。每当进程接收到某个已处理的信号时,就会调用通过 sigset
分配的函数。对于 SIGCHLD
信号,应用程序必须调用 wait(3c)
函数,以等待子进程退出。由于该进程已经退出,这相当于向内核确认了子进程的终止。实际上,父进程所做的工作可能不只是确认该信息。它还可能需要清理子进程的数据。
在执行 die3
以后,代码检查了进程清单,并干净地执行子进程。然后使用值 18 (SIGCHLD
) 来调用信号处理程序,确认子进程的退出,并且父进程返回到 sleep(60)
。
![]() ![]() |
![]()
|
UNIX 进程是在某个进程调用 fork
时创建的,fork
将正在运行的可执行进程一分为二。然后该进程可以执行 exec
系列中的某个系统调用,从而将当前运行的映像替换为新的映像。
当父进程终止时,其所有子进程将由 PID 为 1 的 init
接纳。如果子进程在父进程之前终止,则会向父进程发送一个信号,然后子进程转变为僵死状态,直到该信号得到确认,或父进程被终止。
现在您已了解了进程是如何创建和销毁的,您已经为处理运行您系统的进程作了更好的准备,尤其是大量使用多进程的系统,例如 Apache。如果您需要执行某些故障排除,能够跟踪某个特定进程的进程树还允许您将任何应用程序追溯到创建它的进程。