进程控制之fork函数
一个现有进程可以调用fork函数创建一个新进程。
#include <unistd.h> pid_t fork( void ); 返回值:子进程中返回0,父进程中返回子进程ID,出错返回-1
由fork创建的新进程被称为子进程(child process)。fork函数被调用一次,但返回两次。两次返回的唯一区别是子进程的返回值是0,而父进程的返回值则是新子进程的进程ID。将子进程ID返回给父进程的理由是:因为一个进程的子进程可以有多个,并且没有一个函数使一个进程可以获得其所有子进程的进程ID。fork使子进程得到返回值0的理由是:一个进程只会有一个父进程,所以子进程总是可以调用getppid以获得其父进程的进程ID(进程ID 0总是由内核交换进程使用,所以一个子进程的进程ID不可能为0)。
子进程和父进程继续执行fork调用之后的指令。子进程是父进程的副本。例如,子进程获得父进程的数据空间、堆和栈的副本。注意,这是子进程所拥有的副本。父、子进程并不共享这些存储空间部分。父、子进程共享正文段(text,代码段)。
由于在fork之后经常跟随着exec,所以现在的很多实现并不执行一个父进程数据段、栈和堆的完全复制。作为替代,使用了写时复制(Copy-On-Write,COW)技术。这些区域由父、子进程共享,而且内核将它们的访问权限改变为只读的。如果父、子进程中的任一个试图修改这些区域,则内核只为修改区域的那块内存制作一个副本,通常是虚拟存储器系统中的一“页”。
Linux 2.4.22提供了另一种新进程创建函数——clone(2)系统调用。这是一种fork的泛型,它允许调用者控制哪些部分由父、子进程共享。
程序清单8-1中的程序演示了fork函数,从中可以看到子进程对变量所作的改变并不影响父进程中该变量的值。
程序清单8-1 fork函数示例
[root@localhost apue]# cat prog8-1.c #include "apue.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) err_sys("write error"); printf("before fork\n"); /* we don't flush stdout */ if((pid = fork()) < 0) { err_sys("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); }
如果执行此程序则得到:
[root@localhost apue]# ./prog8-1 a write to stdout before fork pid = 13367, glob = 7, var = 89 子进程的变量值改变了 pid = 13366, glob = 6, var = 88 父进程的变量值没有改变 [root@localhost apue]# ./prog8-1 > tmp.out [root@localhost apue]# cat tmp.out a write to stdout before fork pid = 13369, glob = 7, var = 89 before fork pid = 13368, glob = 6, var = 88
一般来说,在fork之后是父进程先执行还是子进程先执行是不确定的。这取决于内核所使用的调度算法。如果要求父、子进程之间相互同步,则要求某种形式的进程间通信。
当写到标准输出时,我们将buf长度减去1作为输出字节数,这是为了避免将终止null字节写出。strlen计算不包括终止null字节的字符串长度,而sizeof则计算包括终止null字节的缓冲区长度。两者之间的另一个差别是,使用strlen需进行一次函数调用,而对于sizeof而言,因为缓冲区已用已知字符串进行了初始化,其长度是固定的,所以sizeof在编译时计算缓冲区长度。
注意程序清单8-1中fork与I/O函数之间的交互关系。write函数是不带缓冲的。因为在fork之前调用write,所以其数据写到标准输出一次。但是标准I/O库是带缓冲的(这里用到了标准I/O库的printf函数)。如果标准输出连到终端设备,则它是行缓冲的,否则它是全缓冲的。当以交互方式运行该程序时(此时是行缓冲的),只得到该printf输出的行一次,其原因是标准输出缓冲区在fork之前已由换行符冲洗。但是当将标准输出重定向到一个文件时(此时是全缓冲的),却得到printf输出行两次。其原因是,在fork之前调用了printf一次,但当调用fork时,该行数据仍在缓冲区中(我们没有用fflush冲洗缓冲区),然后在将父进程数据空间复制到子进程中时,该缓冲区也被复制到子进程中。于是那时父、子进程各自有了带该行内容的标准I/O缓冲区。(子进程复制父进程缓冲区对程序的影响实例解析可参考:http://blog.csdn.net/lollipop_jin/article/details/8774057)在exit之前的第二个printf将其数据添加到现有的缓冲区中。当每个进程终止时,最终会冲洗其缓冲区中的副本。
文件共享
对程序清单8-1需注意的另一点是:在重定向父进程的标准输出时,子进程的标准输出也被重定向。实际上,fork的一个特性是父进程的所有打开文件描述符都被复制到子进程中。父、子进程的每个相同的打开描述符共享一个文件表项。
考虑下述情况,一个进程具有三个不同的打开文件,它们是标准输入、标准输出和标准出错。在从fork返回时,我们有了如图8-1所示的结构。
这种共享文件的方式使父、子进程对同一文件使用了一个文件偏移量。如果父、子进程写到同一描述符文件,但又没有任何形式的同步(例如使父进程等待子进程),那么它们的输出就会相互混合(假定所有的描述符是在fork之前打开的)。
在fork之后处理文件描述符有两种常见的情况:
(1)父进程等待子进程完成。在这种情况下,父进程无需对其描述符做任何处理。当子进程终止后,它曾进行过读、写操作的任一共享描述符的文件偏移量已执行了相应的更新。
(2)父、子进程各自执行不同的程序段。在这种情况下,在fork之后,父、子进程各自关闭它们不需要使用的文件描述符,这样就不会干扰对方使用的文件描述符。这种方法是网络服务进程中经常使用的。
除了打开文件之外,父进程的很多其他属性也由子进程继承(可以理解为共享),包括:
- 实际用户ID、实际组ID、有效用户ID、有效组ID。
- 附加组ID。
- 进程组ID。
- 会话ID。
- 控制终端。
- 设置用户ID标志和设置组ID标志。
- 当前工作目录。
- 根目录。
- 文件模式创建屏蔽字。
- 信号屏蔽和安排。
- 针对任一打开文件描述符的在执行时关闭(close-on-exec)标志。
- 环境。
- 连接的共享存储段。
- 存储映射。
- 资源限制。
父、子进程之间的区别是:
- fork的返回值。
- 进程ID不同。
- 两个进程具有不同的父进程ID:子进程的父进程ID是创建它的进程的ID,而父进程的父进程ID则不变。
- 子进程的tms_utime、tms_stime、tms_cutime已经tms_ustime均被设置为0.
- 父进程设置的文件锁不会被子进程继承。
- 子进程的未处理的闹钟(alarm)被清除。
- 子进程的未处理信号集设置为空集。
使fork失败的两个主要原因是:系统中已经有了太多的进程(通常意味着某个方面出了问题),或者该实际用户ID的进程总数超过了系统限制(CHILD_MAX)。
fork有下面两种用法:
(1)一个父进程希望复制自己,使父、子进程同时执行不同的代码段。这在网络服务进程中是常见的——父进程等待客户端的服务请求。当这种请求到达时,父进程调用fork,使子进程处理此请求。父进程则继续等待下一个服务请求到达。
(2)一个进程要执行一个不同的程序。这对shell是常见的情况。在这种情况下,子进程从fork返回后立即调用exec。
某些操作系统将(2)中的两个操作(fork之后执行exec)组合成一个,并称其为spawn。UNIX将这两个操作分开,因为在很多场合需要单独使用fork,其后并不跟随exec。另外,将这两个操作分开,使得子进程在fork和exec之间可以更改自己的属性。例如I/O重定向、用户ID、信号安排等。
本篇博文内容摘自《UNIX环境高级编程》(第二版),仅作个人学习记录所用。关于本书可参考:http://www.apuebook.com/。