有了上一节的基础,那我们现在就来学习如何创建一个进程,父进程和子进程的关系等知识点吧!
文章目录
一、fork()创建子进程
进程不是凭空出现的,我们知道所有进程有一个祖先进程init,那么第二个进程是如何出来的呢?第二个进程是由父进程创建出来的,我们一般将第二个进程称为子进程,那么父生子,子生子……这样就有很多进程了,那下面我们看一下如何创建出来一个进程。
(一)基础概念
【1. 创建进程】
创建进程需要用到fork()函数,我们可以通过命令查看相关信息:
man 2 fork //查看调用这个函数需要的头文件
man fork //查看函数原型等
那我们先来看一下fork()的相关函数:
(1) fork()函数 需要引入头文件#include<unistd.h>,fork函数原型为:
pid_t fork(void)
参数含义:无参传入,返回pid_t类型的数值。pid_t 的实质是int类型的,Linux内核2.4.0版本的定义是:
typedef int _kernel_pid_t
typedef _kernel_pid_t pid_t
(2)获取当前进程PID函数:
pid_t getpid();
(3)获取当前父进程PID函数:
pit_t getppid();
当我们执行fork函数时: 用fork()函数生成一个进程,调用fork函数的进程为父进程,新生成的进程为子进程,可以理解为复制一份一样的代码;在父进程中返回子进程的pid,在子进程中返回0,失败返回-1。创建后父子进程是两个独立的进程,各自拥有一份代码。fork()方法调用之后,父,子进程都从fork()调用之后的代码开始运行。
fork()函数的特点:
- 父进程和子进程的返回值:父进程中返回子进程的pid,在子进程中返回0。那么可不可以父进程返回0,子进程返回父进程的PID?不可以,因为子进程的父进程只有1个,但父进程有多个子进程。内核在每个子进程中记录父进程的PID,但是在父进程中没有记录子进程的PID。所以为了方便父进程知道和处理子进程,fork()返回子进程的pid。
- fork()执行后父子进程是两个独立的进程:fork之后是两个独立的进程,所以两个进程会被交给操作系统,然后操作系统进行调度,调度到谁就运行谁,故父子进程并发执行,不是按顺序的,父进程和子进程输出的结果是交替的,随机的。
(二)示例
我们现在用一下fork(),我们写一段代码,打印父子进程PID,实现父进程循环打印fater,子进程循环打印child,代码如下:
# include<stdio.h>
# include<stdlib.h>
# include<unistd.h>
# include<string.h>
# include<assert.h>
int main()
{
pid_t pid=fork();
assert(pid!=-1);
if(pid==0)//子进程
{
printf("child:PPID=%d,PID=%d\n",getppid(),getpid());
int i=0;
for(;i<5;i++)
{
printf("child\n");
sleep(1);
}
}
else//父进程
{
printf("father:PPID=%d,PID=%d\n",getppid(),getpid());
int i=0;
for(;i<5;i++)
{
printf("father\n");
sleep(1);
}
}
exit(0);
}
我们对父子进程fork之后的描述:
我们运行查看结果:
可以看到:
- 父子进程均从fork()之后的代码开始执行,也就是执行 if 判断。
- 父进程打印father,子进程打印child,我们可以看到输出的顺序不是固定的,而是随机的,每一次运行的结果是不一样的,这就是我们说的fork()之后父子进程并发执行。
二、父子进程的存储空间
进程的中的变量会用到内存空间,那么父进程创建子进程时,是顺带把空间也给子进程拷贝了一份还是一直共用父进程的?这就是下面我们要学习的。
我们已经知道了内存空间布局,主要由数据段,堆栈空间等组成,那么我们现在用代码来测试一下父子进程的内存空间:
思路:
- 代码定义全局初始化变量,局部初始化变量,动态开辟指针,那么它们分别在.data段,栈,堆存储。
- 子进程对这三个变量修改数值,子进程中输出数值和地址,父进程也输出三个变量的值和地址。
- 我们用sleep来保证父进程在子进程修改数值之后执行,先让父进程睡眠,这样我们就可以根据父进程输出的数值判断子进程的修改是否影响父进程。
- 进而判断父子进程的三个变量是否存储在一块存储空间中。
那代码如下:
# include<stdio.h>
# include<stdlib.h>
# include<assert.h>
# include<unistd.h>
int gdata=10;//全局初始化
int main()
{
int ldata=10;
int *hdata=(int*)malloc(4);
*hdata=10;
pid_t pid=fork();
assert(pid!=-1);
//子进程
if(pid==0)
{
printf("child:%d,%d,%d\n",gdata,ldata,*hdata);
printf("child addr:0x%x,0x%x,0x%x\n",&gdata,&ldata,hdata);
gdata=20;
ldata=20;
*hdata=20;
printf("change num after:\n");
printf("child:%d,%d,%d\n",gdata,ldata,*hdata);
printf("child addr:0x%x,0x%x,0x%x\n",&gdata,&ldata,hdata);
sleep(3);
}
else
{
sleep(3);//保证子进程先修改了变量值
printf("father:%d,%d,%d\n",gdata,ldata,*hdata);
printf("father addr:0x%x,0x%x,0x%x\n",&gdata,&ldata,hdata);
}
free(hdata);
}
我们编译运行这份代码:
从上图我们可以看到可以得出以下几点:
- 输出的变量的地址是一样的,但是子进程修改了变量的值,父进程的值没有改变,这就表示父子进程中的变量存储不在一个空间,是独立两个存储空间。
- 但是为啥地址一样,我们在前面也讲过逻辑地址和物理地址,输出函数不会那么麻烦的去给你输出物理地址,它只会图方便给你逻辑地址,即程序上的偏移地址,这个地址需要经过页表映射才可以转换为物理地址,操作系统为每一个进程维护一个页表,所以父子进程有自己的页表,所以即使逻辑地址一样,但是页表不一样,那么物理地址就不一样,就是在不同的存储空间。故我们不能通过地址来判断变量是否在一块存储空间,需要通过变量改变是否影响另一个变量来判断。
- 我们开辟了动态内存,malloc只调用了一次,但free会调用两次,因为父进程开辟了空间,然后拷贝给了子进程,子进程拥有了独立的堆空间,所以会free调用两次。
三、父进程把存储空间拷贝给子进程的时机和方式
那么父进程是什么时候把自己的存储空间拷贝给子进程的呢?是一下子全部拷贝过去,还是慢慢的一部分拷贝过去呢?
(一)执行fork()时拷贝?一次性拷贝?
我们先判断一下是否是在fork()函数执行后,父进程把自己的存储空间拷贝给子进程,然后通过系统资源管理器查看内存变化,判断是怎样拷贝的,那么还是用代码来测试,思路如下:
- 我们先在主函数中开辟1G的内存,然后初始化为0,这是父进程的存储空间。
- 我们在fork()函数执行之前打印一句话,标识开始创建子进程,我们就要观察系统内存的变化了。
- 子进程对这1G的内存空间进行修改。父进程此时啥也不干,睡眠即可。
- 运行程序后,我们就观察内存的变化以及终端的显示,得到何时拷贝空间和拷贝方式的答案。
测试代码如下:
# include<stdio.h>
# include<stdlib.h>
# include<assert.h>
# include<unistd.h>
# include<string.h>
int main()
{
char *hdata=(char*)malloc(1024*1024*1024);
int i=0;
for(;i<32;i++)
{
memset(hdata+i*1024*1024*32,'a',1024*1024*32);//初始化1G的空间
sleep(1);
}
printf("I Will Fork\n");
pid_t pid=fork();
assert(pid!=-1);
if(pid==0)
{
printf("child start\n");
int i=0;
for(;i<32;i++)
{
memset(hdata+i*1024*1024*32,'b',1024*1024*32);//修改
sleep(1);
}
printf("child over\n");
}
else
{
sleep(40);
}
free(hdata);
}
编译运行这段代码,结果如下:
我们可以看到前32秒内存会不断增长到1G,此时父进程正在开辟内存。
终端输出了I Will Fork,表示此时创建了子进程,可以看到内存并没有一下子变为2G,而是以一种缓慢的速度在增长,此时子进程正在修改内存数据。
父子进程都结束了,可以看到内存有两次瞬间降低的过程,此时父子进程都已经结束。
那么我们根据上面的结果,可以得到以下结论:
- malloc申请空间并不是malloc成功后就直接将物理内存空间分配给用户,而是在用户使用的时候才会给用户分配物理内存空间,malloc调用成功只是将虚拟地址空间上的堆区空间分配给用户。所以最开始内存慢慢增长到1G.
- fork方法并不会直接将父进程的数据空间复制给子进程,而是子进程在修改数据空间上的数据时,才会给子进程分配空间。所以在fork之后内存缓慢从1G增长到2G
- 释放空间时,会直接将物理内存空间释放,父子进程,谁用完谁释放。所以出现2次递减。
(二)写时拷贝
我们把父进程给子进程拷贝存储空间的方式称为写时拷贝。
写时拷贝是一种可以推迟甚至免除拷贝数据的技术。在父进程创建子进程时,内核此时并不复制整个父进程地址空间给子进程,而是让父进程和子进程共享父进程的地址空间,内核将它们对这块地址空间的权限变为只读的。只有在需要写入修改的时候,内核为修改区域的内存制作一个副本,通常是虚拟存储器系统的一页。从而使父子进程拥有各自的地址空间。
也就是说,新的地址空间只有在需要写入修改的时候才开辟,在此之前,父子进程只是以只读方式共享。这种技术使地址空间上的页的拷贝被推迟到实际发生写入的时候。
四、关于fork的例题
我们来看看常见的几个例题吧。
- 看下面这段代码,会输出几个A几个B:
# include<stdio.h>
# include<stdlib.h>
# include<unistd.h>
# include<string.h>
# include<assert.h>
int main()
{
int i=0;
for(;i<2;i++)
{
pid_t pid=fork();
assert(pid!=-1);
if(pid==0)
{
printf("A\n");
}
else
{
printf("B\n");
}
}
exit(0);
}
分析:fork生成子进程1,父子进程均执行fork之后的代码,那么i=0,父进程打印B,子进程1打印A,i++,i=1,父进程创建子进程2,打印B,子进程2打印A,子进程1创建子进程3,打印B,子进程3打印A,i++,循环结束。所以为3个A3个B,看个图更明白:
验证:
- 去掉上面那段代码的\n,结果是啥,代码如下:
# include<stdio.h>
# include<stdlib.h>
# include<unistd.h>
# include<string.h>
# include<assert.h>
int main()
{
int i=0;
for(;i<2;i++)
{
pid_t pid=fork();
assert(pid!=-1);
if(pid==0)
{
printf("A");
}
else
{
printf("B");
}
}
exit(0);
}
分析:我们上一节说过,如果不刷新缓冲区,那么数据就会留在缓冲区里面,父进程在创建子进程时,会把父进程的缓冲区也给子进程复制一份,那么父进程打印B,子进程1打印A,父进程打印B,子进程2打印BA,子进程1打印B,子进程3打印AA。所以4A4B。如图所示:
验证:
3. 下面一段代码,打印几个A,代码如下:
# include<stdio.h>
# include<stdlib.h>
# include<unistd.h>
# include<string.h>
# include<assert.h>
int main()
{
fork()||fork();
printf("A\n");
sleep(2);
exit(0);
}
分析:||的逻辑是,如果前一个条件为真,那么后面的条件不用判断,如果第一个为假,那么需要判断第二个条件。执行fork(),父进程返回子进程PID,子进程返回0。那么父进程的第一个fork()执行后,返回一个大于0的数,故不必进行下一次判断,打印1个A。而创建的子进程fork()返回0,所以需要判断第二个条件,fork()再创建出另一个子进程,故加上父进程,共3个进程,打印3个A。如图所示:
验证:
4.下面一段代码,输出结果为,代码如下:
# include<stdio.h>
# include<stdlib.h>
# include<unistd.h>
# include<string.h>
# include<assert.h>
int main()
{
printf("A");
write(1,"B",1);
fork();
exit(0);
}
分析:printf为库函数,没有\n,所以先在缓冲区里面。write为系统调用,1代表打印到屏幕,立马打出B,fork创建子进程,父进程有个A,子进程有个A,结束后缓冲区打出AA,所以结果为BAA。
验证:
加油哦!💪。