fork复制进程
fork通过以下步骤来复制进程:
- 分配新的进程控制块:内核为新进程分配一个新的进程控制块(PCB),用于存储进程的相关信息,如进程 ID、状态、寄存器值、内存指针等。
- 复制进程地址空间:将父进程的地址空间(包括代码段、数据段、堆和栈等)复制到新进程的地址空间中。这意味着新进程将拥有与父进程相同的程序代码和数据。
- 复制文件描述符表:父进程打开的文件描述符在子进程中也会被复制,使得子进程可以访问相同的文件资源。
- 设置进程状态和 ID:新进程被设置为就绪状态,等待被调度执行。同时,内核为子进程分配一个唯一的进程 ID。
- 返回控制:fork系统调用在父进程和子进程中都返回。在父进程中,返回值是子进程的进程 ID;在子进程中,返回值是 0。通过检查返回值,父进程和子进程可以区分彼此,并执行不同的代码路径。
简单来说:fork是把已有的进程复制一份,把对应父进程的PCB也复制了一份,然后申请一个PID,子进程,子进程的PID=父进程的PID+1;
通过这些步骤,fork
创建了一个与父进程几乎完全相同的子进程,子进程可以独立于父进程运行,并可以在适当的时候执行自己的代码逻辑。
在Linux上示例:
创建一个main.c,通过if else返回值来操作父子进程做不一样的事情:
#include <stdio.h>#include <unistd.h>#include <assert.h>#include <stdlib.h>int main(){char *s=NULL;int n=0;//控制父子进程执行的次数;pid_t id=fork();assert(id!=-1);if(id==0)//子进程{s="child";n=3;}else//父进程{s="parent";n=7;}//父子进程int i=0;for(;i<n;i++){printf("s=%s\n",s);sleep(1);}exit(0);}
多运行几次就会发现每次执行结果不完全一定:
要解释这执行结果不同的原因,我们先了解下printf的缓冲区机制
下面介绍一下printf
的缓冲区机制
printf
函数通常会将输出先存储在缓冲区中,而不是立即输出到终端或其他目标设备。这样做的目的是为了提高输出效率,减少系统调用的次数。例如,当多次调用printf
输出少量数据时,这些数据会先在缓冲区中积累,直到缓冲区满或者遇到特定的条件(如换行符\n
),才会将缓冲区中的内容一次性输出。
在Linux上用代码演示一下:
创建一个main.c文件
使用exit(0)命令退出程序
其中,exit是先刷新缓冲区,然后再调用_exit(真正的退出); _exit直接退出,不会刷新缓冲区;
编译后运行,结果是三秒后输出hello(注意这里hello后没有加\n,\n会刷新缓冲区,后面会详细总结)
原因是程序执行printf时先将hello放到缓冲区,不是直接打印到屏幕上,然后执行sleep(3)休眠3秒,然后执行到exit(0)时先刷新缓冲区,此时屏幕上显示hello,随后程序结束运行。
强制刷新 (1)方法一:遇到\n自动刷新 printf("hello\n"); (2)使用fflush刷新屏幕 fflush(stdout);
总结: printf将内容先写入到缓冲区中,缓冲区刷新到界面(屏幕)上的条件是:
(1)缓冲区放满
(2)缓冲区未满,强制刷新缓冲区到屏幕(方法一:\n;方法二:主动刷新:fflush(stdout));
(3)程序结束时,自动刷新缓冲区:exit方法
了解printf缓冲区这一特点后便清楚fork实际的运行结果不确定的原因了:
当使用fork
创建子进程时,子进程会继承父进程的内存空间,包括printf
函数的缓冲区。如果在父进程中printf
了一些内容但缓冲区尚未刷新,那么在fork
之后,子进程中也会有一份相同的未刷新缓冲区内容。这可能导致一些意外的输出结果。例如,如果父进程在fork
之前调用printf
输出了一些字符串但没有换行,然后fork
创建了子进程,接着父进程和子进程都继续执行,那么可能会出现父进程和子进程的输出混合在一起的情况,因为它们共享了原来的缓冲区内容,并且在后续的执行中可能会各自刷新缓冲区。
fork的时机:
fork产生的这个子进程不是从头开始执行的,而是从fork之后开始执行的,就是说子进程直接从fork下面的代码开始执行,具体的是说从得到fork的返回值后子进程开始执行,子进程不会再fork了,所以不会出现子进程再去fork产生一个子进程的问题. 也就是说:fork返回值语句,父进程fork返回子进程的PID,子进程fork返回0,二者分别独立执行各自的程序。
补充系统调用函数:getppid和getpid(头文件<unistd.h>
getppid:得到一个进程的父进程的PID;
getpid:得到当前进程的PID;
我们在原先的代码中增加这两个函数调用:
#include <stdio.h>#include <unistd.h>#include <assert.h>#include <stdlib.h>int main(){char *s=NULL;int n=0;//控制父子进程执行的次数;pid_t id=fork();assert(id!=-1);if(id==0)//子进程{s="child";n=3;}else//父进程{s="parent";n=7;}//父子进程int i=0;for(;i<n;i++){printf("s=%s,pid=%d,ppid=%d\n",s,getpid(),getppid());sleep(1);}exit(0);}
运行结果如下图:
观察发现父进程的父进程pid对应bash的pid,这里的bash是系统命令解释器:
此处拓展介绍一下命令解释器:
在计算机科学中,命令解释器Shell俗称壳(用来区别于核),是指“为使用者提供操作界面”的软件(command interpreter,命令解析器)。它类似于DOS下的COMMAND.COM和后来的cmd.exe。它接收用户命令,然后调用相应的应用程序。
我们就是通过命令解释器(称为shell)(bash是命令解释器中的一种)和内核和系统进行交互的(Windows通过图形界面进行交互的);例如我们把ls交给bash,bash帮我们运行ls,然后把结果给用户;
回归正题——fork
fork背后多采用写时拷贝技术
写时拷贝技术
写时拷贝是一种延迟拷贝的策略。在fork
创建子进程时,并不立即复制父进程的所有内存页面,而是让父进程和子进程共享这些页面,并将这些页面标记为只读。只有当父进程或子进程试图对某个共享页面进行写操作时,操作系统才会为该页面分配新的内存空间,并将原页面的内容复制到新的页面中,然后让进行写操作的进程在新的页面上进行修改。
如果不使用写时拷贝技术
第一:复制开销比较大; 第二:占用内存空间;
使用写时拷贝技术:
父子进程逻辑地址一样,但是物理地址是不一样的(多进程逻辑地址相同,对应的物理地址不一定相同)
简单来说就是fork的时候,子进程直接把父进程的页表复制过来,子进程发生写入(修改)的时候才分配内存复制,然后进行相应的页表修改,因此写时拷贝是一种可以推迟甚至免除拷贝数据的技术。
内容来源内核与设计22页:
到此fork及其背后运用的写时拷贝技术就介绍完啦!
如果觉得有用可以点个赞,谢谢支持呀,会持续输出更新知识点,感兴趣可以关注一下!