前言
函数fork()用来创建一个新的进程,该进程几乎是当前进程的一个完全拷贝,继承了了父进程整个进程的地址空间(代码段、堆栈段、数据段),包括:进程上下文、进程堆栈、内存信息、打开的文件描述符、信号控制设置、进程优先级、进程组号、当前工作目录、根目录、资源限制、控制终端等。当父子进程中对共有的数据段进行重新设值或调用不同方法时,才会导致数据段及堆栈段的不同。
函数族exec()用来启动另外的进程以取代当前运行的进程,除了PID仍是原来的值外,代码段、堆栈段、数据段已经完全被改写了。fork函数
#include <unistd.h>
pid_t fork(void);
当执行fork()函数后,会生成一个子进程,子进程的执行从fork()的返回值开始,且代码继续往下执行。
fork()执行一次后会有两次返回值:第一次为原来的进程,即父进程会有一次返回值,表示新生成的子进程的进程ID;第二次为子进程的起始执行,返回值为0。
如果返回值为-1,则表示创建子进程失败,可以通过errno定位失败原因。
示例代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main (int argc, char ** argv) {
int flag = 0;
pid_t pId = fork();
if (pId == -1) {
perror("fork error");
exit(EXIT_FAILURE);
} else if (pId == 0) {
int myPid = getpid();
int parentPid = getppid();
printf("Child:SelfID=%d ParentID=%d \n", myPid, parentPid);
flag = 123;
printf("Child:flag=%d %p \n", flag, &flag);
int count = 0;
do {
count++;
sleep(1);
printf("Child count=%d \n", count);
if (count >= 5) {
break;
}
} while (1);
return EXIT_SUCCESS;
} else {
printf("Parent:SelfID=%d MyChildPID=%d \n", getpid(), pId);
flag = 456;
printf("Parent:flag=%d %p \n", flag, &flag); // 连地址都一样,说明是真的完全拷贝,但值已经是不同的了..
int count = 0;
do {
count++;
sleep(1);
printf("Parent count=%d \n", count);
if (count >= 2) {
break;
}
} while (1);
}
return EXIT_SUCCESS;
}
以上代码中,使用fork()创建了一个子进程。返回值pId有两个作用:一是判断fork()是否正常执行;二是判断fork()正常执行后如何区分父子进程。在父子进程中,都各自打印出自己的进程ID及父/子进程ID。
通过flag的值可以验证创建的子进程是完全复制了父进程的堆栈段(因为flag是在main()方法内声明的),两个进程都输出了flag=0的信息。接下来进程可以各自对flag再次更新值,做到了互不干扰。但从打印的int指针地址来看,指针地址值都是一样的,再次印证了子进程是对父进程的完全复制。
接下来,父进程只执行了两次打印,然后就结束且进程销毁退出了;但父进程的结束并不影响子进程的运行,子进程一直打印到数字5才正常退出。所以验证了fork()出来的进程是各自独立的,完全按照自己的代码逻辑运行直至执行完毕。
以下是运行结果截图。
物理地址和逻辑地址
父子进程中,flag变量的地址一样,但值不一样,这是为什么呢?这里就涉及到物理地址和逻辑地址(虚拟地址)的概念。逻辑地址:CPU所生成的地址。CPU产生的逻辑地址被分为 :p(页号),它包含每个页在物理内存中的基址,用来作为页表的索引;d(页偏移),同基址相结合,用来确定送入内存设备的物理内存地址。
物理地址:内存单元所看到的地址。
用户进程看不见真正的物理地址。用户进程只生成逻辑地址,且认为进程的地址空间为0到max。物理地址范围从R+0到R+max,R为基地址。地址映射将程序地址空间中使用的逻辑地址变换成内存中的物理地址的过程,由内存管理单元(MMU)来完成。
从逻辑地址到物理地址的映射称为地址重定向。分为:
静态重定向--在程序装入主存时已经完成了逻辑地址到物理地址和变换,在程序执行期间不会再发生改变。
动态重定向--程序执行期间完成,其实现依赖于硬件地址变换机构,如基址寄存器。
写时复制
fork()会产生一个和父进程完全相同的子进程,但子进程在此后多会执行exec()系统调用,出于效率考虑,linux引入了“写时复制“技术,也就是只有进程空间的各段的内容要发生变化时,才会将父进程的内容复制一份给子进程。在fork()之后exec()之前,两个进程用的是相同的物理空间(内存区),子进程的代码段、数据段、堆栈都是指向父进程的物理空间,也就是说,两者的虚拟空间不同,但其对应的物理空间是同一个。当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间,内核会给子进程的数据段、堆栈段分配相应的物理空间(至此两者有各自的进程空间,互不影响),而代码段继续共享父进程的物理空间(两者的代码完全相同)。而为exec()后两者执行的代码不同,子进程的代码段也会分配单独的物理空间。fork()之后内核会通过将子进程放在队列的前面,以让子进程先执行,以免父进程执行导致写时复制,而后子进程执行exec()系统调用,因无意义的复制而造成效率的下降。
fork()时子进程获得父进程数据空间、堆和栈的复制,所以变量的地址(当然是虚拟地址)也是一样的。
每个进程都有自己的虚拟地址空间,不同进程的相同的虚拟地址显然可以对应不同的物理地址。因此地址相同(虚拟地址)而值不同没什么奇怪。
具体过程是这样的:
fork()子进程完全复制父进程的栈空间,也复制了页表,但没有复制物理页面,所以这时虚拟地址相同,物理地址也相同,但是会把父子共享的页面标记为“只读”(类似mmap的private的方式),父子进程一直共享同一个页面,直到其中任何一个进程要对共享的页面执行“写操作”,这时内核会复制一个物理页面给这个进程使用,同时修改页表。而把原来的“只读”页面标记为“可写”,留给另外一个进程
这就是所谓的“写时复制”。正因为fork()采用了这种写时复制的机制,所以fork()出来子进程之后,父子进程哪个先调度呢?内核一般会先调度子进程,因为很多情况下子进程是要马上执行exec,会清空栈、堆这些和父进程共享的空间,加载新的代码段。这就避免了“写时复制”拷贝共享页面的机会。如果父进程先调度很可能“写”共享页面,会产生“写时复制”的无用功。所以,一般是子进程先调度。
子进程复制了父进程的行缓冲
使用。
#include <stdio.h>
#include <unistd.h>
int main(void)
{
printf("one");
fork();
printf("two\n");
return 0;
}
输出onetwo
onetwo
首先要清楚stdin和stdout都是行缓冲,stderr是无缓冲。"one"存放在父进程的行缓冲里,子进程复制了父进行程的行缓冲,所以子进程也会打印输出"one"。
#include <stdio.h>
#include <unistd.h>
int main(void)
{
printf("one");
fflush(stdout);
fork();
printf("two\n");
return 0;
}
输出onetwo
two
想消除行缓冲所带来的困扰,方法如下
1、输出数据后加换行符
2、使用fflush之类的函数强制刷新
3、使用setbuf,setvbuf函数设置缓冲区大小
4、使用非缓冲的的流,如stderr.
孤儿进程、僵尸进程
fork系统调用之后,父子进程将交替执行,执行顺序不定。如果父进程先退出,子进程还没退出那么子进程的父进程将变为init进程(托孤给了init进程)。(注:任何一个进程都必须有父进程)
如果子进程先退出,父进程还没退出,那么子进程必须等到父进程捕获到了子进程的退出状态才真正结束,否则这个时候子进程就成为僵尸进程(僵尸进程:只保留一些退出信息供父进程查询)
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#define ERR_EXIT(m) \
do\
{\
perror(m);\
exit(EXIT_FAILURE);\
}\
while (0)\
int main(void)
{
pid_t pid;
printf("before calling fork,calling process pid = %d\n",getpid());
pid = fork();
if(pid == -1)
ERR_EXIT("fork error");
if(pid == 0){
printf("this is child process and child's pid = %d,parent's pid = %d\n",getpid(),getppid());
}
if(pid > 0){
sleep(100);
printf("this is parent process and pid =%d ,child's pid = %d\n",getpid(),pid);
}
return 0;
}
从上可以看到,子进程先退出,但进程列表中还可以查看到子进程,(a.out),即僵尸进程,如果系统中存在过多的僵尸进程,将会使得新的进程不能产生。
父子进程共享文件
fork产生的子进程与父进程相同的文件描述符指向相同的文件表,引用计数增加,共享文件偏移指针。#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <fcntl.h>
#define ERR_EXIT(m) \
do\
{\
perror(m);\
exit(EXIT_FAILURE);\
}\
while (0)\
int main(void)
{
pid_t pid;
int fd;
fd = open("test.txt",O_WRONLY);
if(fd == -1)
ERR_EXIT("OPEN ERROR");
pid = fork();
if(pid == -1)
ERR_EXIT("fork error");
if(pid == 0){
write(fd,"child",5);
}
if(pid > 0){
//sleep(1);
write(fd,"parent",6);
}
return 0;
}
从运行结果可知父子进程共享文件偏移指针,父进程写完后文件偏移到parent后,子进程开始接着写。
fork使用场景
守护进程有时为了保护主进程不被杀,或者主进程异外退出后仍可再次启动(或后台运行),就执行fork(),让子进程监控主进程的运行状态,根据监听保护主进程的运行。
框架扩展
主进程只负责生成子进程,派出子进程去执行应用框架下的子任务,这些任务可能多变、可能更新频繁,但配合fork()及exec()函数,一切都是so easy。还保证了主进程的稳定,避免频繁更新程序。