奔跑的小河
Talk is cheap. Show me the code.

导航

 

如果对中间的零碎的讲解没有兴趣,可以直接跳到文章末尾,看总结。

一、fork基础知识

一个进程,包括代码、数据和分配给进程的资源。fork()函数通过系统调用创建一个与原来进程几乎完全相同的进程,也就是两个进程可以做完全相同的事,但如果初始参数或者传入的变量不同,两个进程也可以做不同的事。

一个进程调用fork()函数后,系统先给新的进程分配资源,例如存储数据和代码的空间。然后把原来的进程的所有值都复制到新的新进程中,只有少数值与原来的进程的值不同。相当于克隆了一个自己。

fork()函数得到的子进程,继承父进程的所有系统资源,包括,代码段、数据区、常量区等等。父子进程的地址空间开始是共享的,只有当父子进程中的任何一个企图修改其中的内容时才进行复制,即copy on write。写时复制技术我会在另外一篇博客中进行详细讲解)。而从表明上看二者是互不相干的。


看一个简单例子:

 

#include <unistd.h>
#include <stdio.h>
int main ()
{
pid_t fpid; //fpid表示fork函数返回的值
int count=0;
fpid=fork();
if (fpid < 0)
printf("error in fork!");
else if (fpid == 0) {
printf("i am the child process, my process id is %d\n",getpid());
count++;
}
else {
printf("i am the parent process, my process id is %d\n",getpid());
count++;
}
printf("count is: %d\n",count);
return 0;
}

结果:

 

i am the parent process, my process id is 5573

count: 1 i am the child process, my process id is 5574count: 1

 


 

为什么两个进程的fpid不同呢,这与fork函数的特性有关。

fork调用的一个奇妙之处就是它仅仅被调用一次,却能够返回两次,它可能有三种不同的返回值:

1.在父进程中,fork返回新创建子进程的进程ID;2.在子进程中,fork返回0;3.如果出现错误,fork返回一个负值;

 fork出错可能有两种原因:1.当前的进程数已经达到了系统规定的上限,这时errno的值被设置为EAGAIN。2.系统内存不足,这时errno的值被设置为ENOMEM。

创建新进程成功后,系统中出现两个基本完全相同的进程,这两个进程执行没有固定的先后顺序,哪个进程先执行要看系统的进程调度策略。每个进程都有一个独特(互不相同)的进程标识符(process ID),可以通过getpid()函数获得,还有一个记录父进程pid的变量,可以通过getppid()函数获得变量的值。

执行完fork后,进程1的变量为count=0,fpid!=0(父进程)。进程2的变量为count=0,fpid=0(子进程),这两个进程的变量都是独立的,存在不同的地址中,不是共用的,这点要注意。可以说,我们就是通过fpid来识别和操作父子进程的。还有人可能疑惑为什么不是从#include处开始复制代码的,这是因为fork是把进程当前的情况拷贝一份,执行fork时,进程已经执行完了int count=0;fork只拷贝下一个要执行的代码到新的进程。

二、fork进阶

先看代码:

 

#include <unistd.h>
#include <stdio.h>
int main ()
{
   int i=0;
   printf("i son/pa ppid pid  fpid\n");
   //ppid指当前进程的父进程pid
   //pid指当前进程的pid,
   //fpid指fork返回给当前进程的值
   for(i=0;i<2;i++){
       pid_t fpid=fork();
       if(fpid==0)
           printf("%d child  %4d %4d %4d\n",i,getppid(),getpid(),fpid);
       else
           printf("%d parent %4d %4d %4d\n",i,getppid(),getpid(),fpid);
   }
   return 0;
}


结果:

 

 

    i son/pa ppid pid  fpid
    0 parent 2043 3224 3225
    0 child  3224 3225    0
    1 parent 2043 3224 3226
    1 parent 3224 3225 3227
    1 child     1 3227    0
    1 child     1 3226    0

第一步:在父进程中,指令执行到for循环中,i=0,接着执行fork,fork执行完后,系统中出现两个进程,分别是p3224和p3225(后面我都用pxxxx表示进程id为xxxx的进程)。可以看到父进程p3224的父进程是p2043,子进程p3225的父进程正好是p3224。我们用一个链表来表示这个关系:
    p2043->p3224->p3225

 

读者可能注意到p3226,p3227的父进程难道不该是p3224和p3225吗,怎么会是1呢?这里得讲到进程的创建和死亡的过程,在p3224和p3225执行完第二个循环后,main函数就该退出了,也即进程该死亡了,因为它已经做完所有事情了。p3224和p3225死亡后,p3226,p3227就没有父进程了,这在操作系统是不被允许的,所以p3226,p3227的父进程就被置为p1了,p1是永远不会死亡的。
总结一下,这个程序执行的流程如下:


还有,如果去掉”\n“后每次程序运行的结果都不一样了,这是跟printf的缓冲机制有关,printf某些内容时,操作系统仅仅是把该内容放到了stdout的缓冲队列里了,并没有实际的写到屏幕上。但是,只要看到有\n 则会立即刷新stdout,因此就马上能够打印了。
运行了printf("fork!")后,“fork!”仅仅被放到了缓冲里,程序运行到fork时缓冲里面的“fork!” 被子进程复制过去了。因此在子进程的stdout缓冲里面就也有了fork! 。所以,你最终看到的会是fork!  被printf了2次!!!!
而运行printf("fork!\n")后,“fork!”被立即打印到了屏幕上,之后fork到的子进程里的stdout缓冲里不会有fork! 内容。因此你看到的结果会是fork! 被printf了1次!!!!

最后,看看下面程序一共创建了多少个进程:

 

#include <stdio.h>
#include <unistd.h>
int main(int argc, char* argv[])
{
   fork();
   fork() && fork() || fork();
   fork();
   return 0;
}


答案是总共20个进程。fork() && fork() || fork();的解释如下图:

 



fork() && fork() || fork();额外fork出了4个进程。子进程的返回值是0。

三、总结

  • fork函数通过系统调用创建一个与原来进程几乎完全相同的进程,从此可以将这两个父子进程看做完全不相干的两个进程。
  • fork函数返回值,父进程返回子进程的ID,子进程返回0,错误返回负值。
  • 现在Linux系统中fork子进程是利用写时复制技术实现的,也就说内核只为子进程创建虚拟空间,不为其分配实际内存,共享父进程的物理空间。且内核通常将子进程放置于父进程前面执行。
  • 当父子进程中任何一个发生更改相应段的数据时,系统才会为子进程分配相应段的物理空间。
  • 通常fork的子进程都会立即调用exec(),此时fork()的实际开销就是复制父进程的页表以及给子进程创建惟一的进程描述符。
  • 若fork后并不是立即执行exec(),而是其他指令,那么此时子进程首先会创建一个栈。
下面是摘自网络上关于fork的几段话:
第一代Unix系统实现了一种傻瓜式的进程创建:当发出fork()系统调用时,内核原样复制父进程的整个地址空间并把复制的那一份分配给子进程。这种行为是非常耗时的,因为它需要:
- 为子进程的页表分配页帧
- 为子进程的页分配页帧
- 初始化子进程的页表
- 把父进程的页复制到子进程相应的页中

fork之后内核会通过将子进程放在队列的前面,以让子进程先执行,以免父进程执行导致写时复制,而后子进程执行exec系统调用,因无意义的复制而造成效率的下降。

传统的fork()系统调用直接把所有的资源复制给新创建的进程。这种实现过于简单并且效率低下,因为它拷贝的数据也许并不共享,更糟的情况是,如果新进程打算立即执行一个新的映像,那么所有的拷贝都将前功尽弃。Linux的fork()使用写时拷贝(copy-on-write)页实现。写时拷贝是一种可以推迟甚至免除拷贝数据的技术。内核此时并不复制整个进程地址空间,而是让父进程和子进程共享同一个拷贝。只有在需要写入的时候,数据才会被复制,从而使各个进程拥有各自的拷贝。也就是说,资源的复制只有在需要写入的时候才进行,在此之前,只是以只读方式共享。这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候。在页根本不会被写入的情况下——举例来说,fork()后立即调用exec()——它们就无需复制了。fork()的实际开销就是复制父进程的页表以及给子进程创建惟一的进程描述符。在一般情况下,进程创建后都会马上运行一个可执行的文件,这种优化可以避免拷贝大量根本就不会被使用的数据(地址空间里常常包含数十兆的数据)。由于Unix强调进程快速执行的能力,所以这个优化是很重要的。

扩展
  • fork实现写时复制,图形描述,如下图

  • vfork实现更加暴力,连虚拟空间都共享父进程的。

最后,关于写时复制技术,很多地方都会用到,随后我将尝试实现string的写时复制,关注吧。

 

posted on 2015-07-16 20:47  奔跑的小河  阅读(273)  评论(0编辑  收藏  举报