8.3 fork函数-进程控制
#include <unistd.h>
pid_t fork(void);//返回值:子进程中返回0,父进程中返回子进程ID。出错返回-1
.
由fork创建的新进程被成为子进程child process。fork函数被调用一次,但是返回两次,两次返回的唯一区别是子进程的返回值是0,而父进程的返回值是新子进程的进程ID。将子进程ID返回 父进程的理由是:因为一个进程的子进程可以有多个,并且没有一个函数使一个进程可以获得其所有子进程的进程ID。fork使子进程得到返回值0的理由是:一个进程只会有一个父进程,所以子进程总是可以调用getppid以获得其父进程的进程ID(进程ID0总是由内核交换进程使用,所以一个子进程的进程ID不可能为0)。
子进程和父进程继续执行fork调用之后的指令。子进程是父进程的副本。例如,子进程获得父进程数据空间、堆和栈的副本。注意,这是子进程所拥有的副本。父子进程并不共享这些存储空间部分,父子进程共享正文段。
由于在fork之后经常跟随着exec,所以现在的很多实现并不执行一个父进程数据段、栈和堆的完全复制,作为替代,使用了写时复制(Copy On Write)技术。这些区域由父子进程共享,而且内核将它们的访问权限改变为只读的。如果父子进程中的任一个试图修改这些区域,则内核只为修改区域的那块内存制作一个副本,通常是虚拟存储器系统中的一页。
下述程序8-1演示了fork函数,从中可以看到子进程对变量所作的改变并不影响父进程中改变量的值。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int glob = 6; /* external variable in initialized data */
char buf[] = "a write to stdout\n";
int main(void)
{
int var; /* automatic variable on the stack */
pid_t pid;
var = 88;
if (write(STDOUT_FILENO, buf, sizeof(buf) - 1) != sizeof(buf) - 1)
perror("write error");
printf("before fork\n"); /* we don't flush stdout */
if ((pid = fork()) < 0) {
perror("fork error");
} else if (pid == 0) { /* child */
glob++; /* modify variables */
var++;
} else {
sleep(2); /* parent */
}
printf("pid = %d, glob = %d, var = %d\n", getpid(), glob, var);
exit(0);
}
如果执行此程序则得到:
$ a.out
a write to stdout
before fork
pid = 430, glob = 7, var = 89 子 进 程 的变量值改变了
pid = 429, glob = 6, var = 88 父 进 程 的变量值没有改变
$ a.out > temp.out
$ cat temp.out
a write to stdout
before fork
pid = 432, glob = 7, var = 89
before fork
pid = 431, glob = 6, var = 88
一般来说,在fork之后是父进程先执行还是子进程先执行是不确定的。这取决于内核所使用的调度算法。如果要求父、子进程之间相互同步,则要求某种形式的进程间通信。在程序8 - 1中,父进程使自己睡眠2秒钟,以此使子进程先执行。但并不保证2秒钟已经足够,在8 . 9节说明竟争条件时,还将谈及这一问题及其他类型的同步方法。在1 0 . 6节中,在fork之后将用信号使父、子进程同步。
当写到标准输出时,我们将buf的长度减去1作为输出字节数,这是为了避免将终止null字节写出。strlen计算不包括终止null字节的字符串长度,而sizeof则计算包括终止null字节的缓冲区长度。另一个两者之间的差别是,使用strlen需进行一次函数调用,而对于sizeof而言,因为缓冲区已用已知字符串进行了初始化,其长度是固定的,所以sizeof在编译时计算缓冲区的长度。
注意,程序8 - 1中fork与I / O函数之间的关系。回忆第3章中所述,w r i t e函数是不带缓存的。因为在fork之前调用w r i t e,所以其数据写到标准输出一次。但是,标准I / O库是带缓存的。回忆一下5 . 1 2节,如果标准输出连到终端设备,则它是行缓存的,否则它是全缓存的。当以交互方式运行该程序时,只得到printf输出的行一次,其原因是标准输出缓存由新行符刷新。但是当将标准输出重新定向到一个文件时,却得到printf输出行两次。其原因是,在fork之前调用了printf一次,但当调用fork时,该行数据仍在缓存中(这就是为什么打印到文件中会出现两次before fork的原因),然后在父进程数据空间复制到子进程中时,该缓存数据也被复制到子进程中。于是那时父、子进程各自有了带该行内容的缓存。在exit之前的第二个printf将其数据添加到现存的缓存中。当每个进程终止时,其缓存中的内容被写到相应文件中。
文件共享
对程序8 - 1需注意的另一点是:在重新定向父进程的标准输出时,子进程的标准输出也被
重新定向。实际上, fork的一个特性是所有由父进程打开的描述符都被复制到子进程中。父、子进程每个相同的打开描述符共享一个文件表项(见图3 - 3 )。
考虑下述情况,一个进程打开了三个不同文件,它们是:标准输入、标准输出和标准出错。在从fork返回时,我们有了如图8 - 1中所示的安排。
这种共享文件的方式使父、子进程对同一文件使用了一个文件位移量。考虑下述情况:一个进程fork了一个子进程,然后等待子进程终止。假定,作为普通处理的一部分,父、子进程都向标准输出执行写操作。如果父进程使其标准输出重新定向(很可能是由shell实现的),那么子进程写到该标准输出时,它将更新与父进程共享的该文件的位移量。在我们所考虑的例子中,当父进程等待子进程时,子进程写到标准输出;而在子进程终止后,父进程也写到标准输出上,并且知道其输出会添加在子进程所写数据之后。如果父、子进程不共享同一文件位移量,这种形式的交互就很难实现。
如果父、子进程写到同一描述符文件,但又没有任何形式的同步(例如使父进程等待子进程),那么它们的输出就会相互混合(假定所用的描述符是在fork之前打开的)。虽然这种情况是可能发生的(见程序8 - 1),但这并不是常用的操作方式。
在fork之后处理文件描述符有两种常见的情况:
(1) 父进程等待子进程完成。在这种情况下,父进程无需对其描述符做任何处理。当子进程终止后,它曾进行过读、写操作的任一共享描述符的文件位移量已做了相应更新。
(2) 父、子进程各自执行不同的程序段。在这种情况下,在fork之后,父、子进程各自关闭它们不需使用的文件描述符,并且不干扰对方使用的文件描述符。这种方法是网络服务进程中经常使用的。
除了打开文件之外,很多父进程的其他性质也由子进程继承:
• 实际用户I D、实际组I D、有效用户I D、有效组I D。
• 添加组I D。
• 进程组I D。
• 对话期I D。
• 控制终端。
• 设置-用户- I D标志和设置-组- I D标志。
• 当前工作目录。
• 根目录。
• 文件方式创建屏蔽字。
• 信号屏蔽和排列。
• 对任一打开文件描述符的在执行时关闭标志。
• 环境。
• 连接的共享存储段。
• 资源限制。
父、子进程之间的区别是:
• fork的返回值。
• 进程I D。
• 不同的父进程I D。
• 子进程的t m s _ u t i m e , t m s _ s t i m e , t m s _ c u t i m e以及t m s _ u s t i m e设置为0。
• 父进程设置的锁,子进程不继承。
• 子进程的未决告警被清除。
• 子进程的未决信号集设置为空集。
其中很多特性至今尚末讨论过,我们将在以后几章中对它们进行说明。
使fork失败的两个主要原因是:( a )系统中已经有了太多的进程(通常意味着某个方面出了问题),或者( b )该实际用户I D的进程总数超过了系统限制。回忆表2 - 7,其中C H I L D _ M A X规定了每个实际用户I D在任一时刻可具有的最大进程数。
fork有两种用法:
(1) 一个父进程希望复制自己,使父、子进程同时执行不同的代码段。这在网络服务进程中是常见的——父进程等待委托者的服务请求。当这种请求到达时,父进程调用fork,使子进程处理此请求。父进程则继续等待下一个服务请求。
(2) 一个进程要执行一个不同的程序。这对shell是常见的情况。在这种情况下,子进程在从fork返回后立即调用exec (我们将在8 . 9节说明exec )。
某些操作系统将( 2 )中的两个操作( fork之后执行exec )组合成一个,并称其为s p a w n。UNIX将这两个操作分开,因为在很多场合需要单独使用fork,其后并不跟随exec。另外,将这两个操作分开,使得子进程在fork和exec之间可以更改自己的属性。例如I / O重新定向、用户I D、信号排列等。在第1 5章中有很多这方面的例子。