fork/vfork浅谈

在*UNIX中可以通过fork/vfork来实现多进程编程,整理总结一下相关的知识。

fork

一个现有的进程可以通过调用fork来创建一个新的进程。

1
2
#include <unistd.h>
pid_t fork(void);

通过fork创建的进程被称为子进程(child process)
fork函数的调用会有两次返回——子进程中返回0,父进程中返回子进程ID。子进程和父进程继续执行fork调用之后的命令。子进程是父进程的副本,子进程获取父进程的数据空间、堆、栈的副本。注意:父进程和子进程并不共享这些存储空间父进程和子进程共享正文段(CPU执行的机器指令部分)。

在子进程中访问父进程的数据是通过COW(copy on write)技术实现的,这些区域由父进程和子进程共享,而内核将他们的访问权限改为只读。如果父进程和子进程中的任何一个试图修改这些区域,内核只为修改区域的那块内存制作一个副本,通常是虚拟存储中的一页。

注意:有可能对fork之后的修改的对象的地址取地址(子进程和父进程)可能会得到同样的地址。但是他们实际上并不指向相同的物理地址。这与COW没有关系,因为&取得的是虚拟地址,不同进程的虚拟地址被MMU映射到不同的物理地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int x=12;
printf("fork before: x = %d it's address is 0x%x\n",x,&x);
fflush(stdout);
pid_t child;
if((child=fork())<0){
printf("error!\n");
}else{
if(child>0){
printf("parent! The process ID is %d\n",getpid());
x=1;
printf("x on parent scope is %d address is 0x%x\n\n",x,&x);
fflush(stdout);
exit(0);
}else{
printf("child! The process ID is %d\n",getpid());
x=2;
printf("x on child scope is %d address is 0x%x\n\n",x,&x);
fflush(stdout);
}
}

注意:C语言的printf是带缓冲的。在进程结束时需要自己手动fflush缓冲区。或者可以替换为不带缓冲的write(STDOUT_FILENO,buffer,n);

getpid/getppid

可以通过getpid()来获取当前的进程ID,可以通过getppid()来获取当前进程的父进程ID。

其的POSIX标准为:

文件共享

在fork之后,是子进程先执行还是父进程先执行是不确定的,这取决于内核所使用的调度算法。如果要求父进程与子进程之间相互同步,则要求某种形式的进程间通信。

注意:父进程的所有打开的文件描述符都会被拷贝到子进程中(等同于对每个文件描述符执行dup函数)。父进程和子进程每个相同的打开文件描述符共享同一个文件表项(这意味着共享同一个偏移量)。

fork fd shared

所以,如果父进程和子进程同时访问一个文件描述符且没有任何同步措施,它们的输出就会相互混合。

父进程与子进程的区别

  1. fork的返回值不同
  2. 进程ID不同
  3. 这两个进程的父进程ID不同
  4. 子进程不继承父进程设置的文件锁
  5. 子进程未处理闹钟被清除
  6. 子进程未处理的信号集设置为空集
  7. 子进程的tms_utime、tms_stime、tms_cutime和tms_ustims的值设置为0

fork失败

通常fork失败的原因有两个

  • 系统中有太多的进程
  • 该实际用户ID的进程总数超过系统限制(CHILD_MAX)

fork的两种用途

  • 复制父进程本身。使父进程和子进程同时执行不同的代码段。
  • 一个进程执行另一个进程(fork后立即调用exec)。

fork的POSIX标准

vfork

vfork的调用序列和返回值与fork相同,但二者语义不同。
vfork函数用于创建一个新进程,而该进程的目的是exec一个新程序。
vfork与fork一样都创建一个子进程,但是它并不将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exec(或exit),于是也就不会引用该地址空间。不过在子进程执行exec之前,它在父进程的空间中运行,如果此时子进程中修改了数据(除了存放vfork返回值的变量)、进行函数调用、或者没有调用exec/exit就返回可能会带来未知的结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
pid_t childID;
int ival=123;
if((childID=vfork())<0){
printf("error\n");
}else{
if(childID==0){
ival=111;
exit(0);
}
}
printf("%d\n",ival);
// output
111

fork与vfork的另一个重要区别是:vfork保证子进程先运行,在它调用exec或者exit之后父进程才可能被调度运行,当子进程调用这两个函数的任意一个时,父进程会恢复运行(如果调用这两个函数之前依赖于父进程执行某项动作,则会导致死锁)。

wait和waitpid

当一个进程正常或异常终止时,内核就向其父进程发送SIGCHLD信号。因为子进程终止是一个异步事件(可以在父进程运行的任何时候发生),所以这种信号也是内核向父进程发的异步事件。父进程可以忽略该信号,或者提供一个该信号发生时即被调用执行的函数(信号处理程序)。

调用wait或waitpid的进程会发生什么:

  • 如果有其所有子进程都还在运行,则阻塞。
  • 如果一个进程已经终止,正等待父进程获取其终止状态,则取得该子进程的终止状态立即返回。
  • 如果它没有任何子进程,则立即出错返回。

如果进程由于收到SIGCHLD信号而调用wait,我们期望wait会立即返回。但是如果在随机时间调用wait则进程可能会阻塞。

1
2
3
4
5
#incldue <sys/wait.h>
// 若成功返回进程ID,若出错返回0
pid_t wait(int *statloc);
// pid参数,状态缓冲区(可以为NULL),额外选项
pid_t waitpid(pid_t pid,int *statloc,int options);

pid的参数的作用如下:

pid参数含义
pid==-1等待任一子进程,在此种情况下waitpid与wait等效
pid>0等待进程ID与pid相等的子进程
pid==0等待组ID等于调用进程组ID的任一进程
pid<-1等待组ID等于pid绝对值的任一子进程

opetions参数的作用如下:
options参数使我们能够进一步控制waitpid的操作,此参数或是0,或是如下表格中常量按位运算的结果:

常量说明
WCONTINUED若支持作业控制,那么由pid指定的任一子进程在停止后已经继续,但其状态尚未报告,则返回其状态(POSIX.1的XSI扩展)
WNOHANG若由pid指定的子进程并不是立即可用的,则waitpid不阻塞,此时其返回值为0
WUNTRACED若某实现支持作业控制,而由pid指定的任一子进程已经处于停止状态,并且其状态自停止以来还未报告过,则返回其状态。WIFSTOPPED宏确定返回值是否对应一个停止的子进程。

若执行成功,wait/waitpid函数均返回终止子进程的进程ID,若出错则返回0或-1.
waitpid还会将该子进程的终止状态存放在由statloc指向的存储单元中。

这两个函数区别是:

  • 在一个子进程终止之前,wait使其调用者阻塞,而waitpid有一个选项,可以不阻塞调用者。
  • waitpid并不等待在其调用之后的第一个终止子进程,他有若干个选项,可以控制所有等待的进程。

如果进程已经终止,并且是一个僵死进程,则wait则立即取得该子进程的状态,否则wait使其调用者阻塞,直到一个子进程终止。如果调用者阻塞而且它具有多个子进程,则在某一个子进程终止时,wait就立即返回。因为wait返回终止子进程的进程ID,所以它总是了解是哪一个进程终止了。

这两个函数返回的整型状态(statloc)是由实现定义的。

waitpid提供了wait没有提供的三个功能:

  • waitpid可以等待一个特定的进程,而qait则返回任一终止子进程的状态
  • waitpid提供一个wait的非阻塞版本。有时希望获取一个子进程的状态而不想阻塞
  • waitpid通过WUNTRACED和WCONTINUED选项支持作业控制

wait/waitid/waidpid的POSIX标准

孤儿进程

如果父进程在子进程之前终止,会怎么样呢?
对于父进程已经终止的所有进程,他们的父进程都变为init进程。则称这些进程被init收养。
大致过称为:在一个进程终止时,内核逐个检查所有活动进程,以判断它时候是正要终止进程的子进程,如果是,则该父进程ID就更改为1(init的进程ID),这种方法保证每一个进程都有一个父进程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pid_t child;
if((child=fork())<0){
printf("error\n");
}else{
if(child==0){
printf("current Process id: %d\tparent ID: %d\n",getpid(),getppid());
sleep(2);
printf("current Process id: %d\tparent ID: %d\n",getpid(),getppid());
fflush(stdout);
}else{
sleep(1);
printf("parent end.\n");
}
}

运行上面的代码可以看到,父进程先于子进程结束,会使子进程的父进程变更为init(1)(被init收养);

僵死进程

如果子进程在父进程之前终止,那么父进程又如何能够在做相应检查时得到子进程的状态?
如果子进程完全消失了,父进程在最终准备好检查子进程是否终止时是无法获取到它的终止状态的。内核为每个终止子进程保存了一定量的信息,所以当终止进程的父进程调用wait/waitpid时,可以得到这些信息。这些信息至少包括进程ID、该进程的终止状态以及该进程使用的CPU时间总量。内核可以释放终止进程所使用的所有存储区,关闭其所有打开的文件。

在UNIX术语中,一个已经终止、但是其父进程尚未对其进行善后处理(获取终止子进程的有关信息、释放它仍占用的资源)的进程叫做僵死进程(zombie)。ps(1)命令将僵死进程的状态打印为Z.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
pid_t child;
if((child=fork())<0){
printf("error\n");
}else{
if(child==0){
printf("child Process ID: %d\tparent ID: %d\n",getpid(),getppid());
printf("child Process end.\n");
fflush(stdout);
exit(0);
}else{
sleep(5);
system("ps -o pid,ppid,state,tty,command");
printf("parent end.\n");
}
}

执行上面的程序可以看到:
zombie process

exec

前面提到子程序在fork之后可以调用一种exec函数来执行一个新的程序。
当进程调用一种exec函数时,该进程执行的程序完全替换为新程序,而新程序则从其main函数开始执行。
因为调用exec并不创建新的进程,所以前后的进程ID并未改变,exec只是用磁盘上的一个新程序替换了当前程序的正文段、数据段、堆段和段栈。
exec的POSIX标准

全文完,若有不足之处请评论指正。

扫描二维码,分享此文章

本文标题:fork/vfork浅谈
文章作者:ZhaLiPeng
发布时间:2017年03月06日 13时40分
本文字数:本文一共有2.9k字
原始链接:https://imzlp.me/posts/2658/
许可协议: CC BY-NC-SA 4.0
转载请保留原文链接及作者信息,谢谢!
您的捐赠将鼓励我继续创作!