博客
关于我
强烈建议你试试无所不能的chatGPT,快点击我
18-用fork函数创建新进程
阅读量:2043 次
发布时间:2019-04-28

本文共 7189 字,大约阅读时间需要 23 分钟。

1. 浅谈程序和进程

  简单来说,程序是一组存储在磁盘的机器语言指令集合(本质上是一个二进制文件),不占用cpu、内存等系统资源。

  而进程是一个可执行程序的运行实例,说白了就是运行着的程序,每一个运行着的程序都可以看做是一个独立的进程,会占用系统资源。

2. 感受进程

  如果同一个程序多次运行,每次都会在内存中创建出不同的进程,每个进程都有自己的代码空间和数据空间,且进程间彼此独立,互不影响。

  比如编译完process.c文件,在磁盘中生成的process的可执行文件就是一个二进制程序,且process程序只占用磁盘的存储空间,当使用执行命令./process执行时,shell终端就会产生一个属于process.out文件的进程在运行,并占用了cpu,内存等系统资源,这时候process.out就是一个进程。

例如,当多次执行./process时,每次执行./process命令都会产生一个process进程,如下图所示:

这里写图片描述
图1-感受进程

  产生的这三个process进程彼此间独立,互不影响。

  还可以通过ps -u test u命令可以帮助查看当前系统属于test用户的所有进程,最后的u表示显示格式,这里以用户格式显示。ps 后面的选项以短破拆号-开头和没有它开头的含义是不同的。一般来说,有 - 开头的表示的是”UNIX options”,没有-开头的叫 “BSD options”,而以双破折号 - - 开头的叫 “GNU long options”。

这里写图片描述
图2-查看进程

图2中列出了当前用户test的进程。每一列的含义如下:

名称 含义

USER 进程的属主
PID 进程的 id 号
%CPU 进程占用的 CPU 百分比
%MEM 占用的内存百分比
VSZ 进程虚拟大小
RSS 驻留页的数量
TTY 终端 id 号
STAT 进程状态(D、R、S、T、W、X、Z、<、N、L、s 等)
START 进程开始运行时间
TIME 进程累积使用的CPU时间
COMMAND 使用的命令

以下是进程状态值的含义( 从这里也可以看出,进程它是有状态的):

名称 含义

D 不可中断睡眠
R 运行或就绪态
S 休眠状态
T 停止或被追踪
W 进入内存交换(从内核2.6开始无效)
X 死掉的进程
Z 僵尸进程
< 优先级高的进程
N 优先级较低的进程
L 有些页被锁进内存
s 进程的领导者(在它之下有子进程)

3. 程序和进程之间的特点

  无论是C/C++,还是java语言编写的源码程序经过编译器编译成一个可执行的二进制机器指令文件,然后交给计算机运行,当这个程序的运行起来就是进程(为了方便,我们可以理解为进程是程序的运行状态)。

  它们的区别在于程序作为一个静态的二进制可执行文件永久存储在磁盘空间中,没有执行的意义。而进程是由操作系统创建,调度运行,分配系统资源,完成任务后销毁等等。整个过程进程是处于动态的,由操作系统维护管理。

总体来说可以从以下方面来理解:

  程序是静态的文件,进程是处于动态运行的程序。

  程序是一组指定的集合,无执行意义,只占用磁盘存储空间,而进程占会一定的系统资源,进程是有状态的(休眠,运行,僵死等),有一定的生命周期(从进程创建到进程结束)。

  同一个程序运行不同的数据集就是不同的进程,进程间是独立的,数据集不相同。比如:同时开两个终端,各自都有一个bash进程,但彼此的bash进程pid不同。

4. 身份标识——进程pid

  前面在图2中还看了pid,pid就是进程的id,每个进程都有一个非负整数且唯一的进程id。用于唯一标识系统的某个进程。对于系统调用来说,进程id可以作为参数传入,也可以作为函数返回值。

  比如接下来要讲的神奇的fork系统调用,它的神奇之处在于调用成功后会有两个返回值。

5. 神奇的fork函数

fork函数用于创建一个新的进程。

pid_t fork(void);

返回值:fork调用成功后,子进程返回0,父进程返回子进程pid,fork出错返回-1

  返回值pid_t类型表示进程ID,但为了表示- 1,它是有符号整型。注意fork的返回值,由于fork调用成功后子进程也复制了一个fork函数,父子进程中各自有一个fork函数,父进程的fork返回值大于0(子进程pid),子进程的fork则返回0,如果fork调用失败,当前进程返回-1。

fork程序示例:

#include 
#include
#include
int main(){ pid_t pid; pid = fork(); //当fork调用成功后,子进程开始从fork后开始执行 //创建进程失败 if(-1 == pid){ perror("fork failed"); return -1; //父进程 }else if(pid > 0){ //pid用于接收当前进程id, ppid用于接收父进程id printf("parent : pid = %d , ppid = %d\n", getpid(), getppid()); sleep(1); //子进程 }else if(pid == 0){ printf("child: pid = %d, ppid = %d\n", getpid(), getppid()); sleep(1); } return 0;}

执行结果如下:
这里写图片描述

  通过程序的执行结果我们发现,父子进程各自打印了自己的pid,同时也打印了父进程的pid。注意,一般来说调用fork创建子进程成功后,父进程和子进程谁先执行是不确定的,这取决于内核的调度算法。

6. 关于fork的2个返回值

这里写图片描述
图3-fork的返回值

  父进程一行一行代码执行,调用fork( )函数时会产生一个子进程,且复制了同一份代码给子进程,有些小伙伴可能会很奇怪为啥调用一次fork却返回2个返回值? 真实的情况是:这2个返回值是由父进程和子进程各自返回的

   如图3所示,对于父进程来说,父进程调用fork函数成功产生一个子进程,子进程会从父进程“复制”一份数据空间,堆和栈作为副本(注意:并不是完全的复制,但是接近于97%的复制,子进程会修改自己的数据空间中的某些变量值),这两个进程拥有相同的代码文本,但各自拥有不同的栈段,数据段等。

   父进程中的fork返回值是子进程pid (如果调用fork失败则返回 -1),fork成功调用后,会把子进程中的fork的返回值直接修改为0,然后子进程会从fork后开始执行,这样我们可以通过fork函数的返回值来区分并且控制父子进程的代码流程

   当然,为了能深入了解fork函数的返回值,可以去看看读共享写复制机制,然后再回过头来看fork函数的返回值,你就非常清楚了。

7. 父子进程间的共享问题

  大家有没有想过这么一个问题,父进程在fork后,父子进程之间的数据共享的问题。比如哪些数据共享,哪些是不共享的,但至少从前面的学习中来看,我们知道父子进程的进程id,fork返回值等是不共享的,好了,还是来看下面这个示例程序吧。

#include 
#include
#include
//全局变量int var = 100;int main(void) { pid_t pid; pid = fork(); if (pid == -1) { perror("fork error"); exit(1); } else if (pid > 0) { //这是写操作,修改的是父进程中的全局变量var var = 288; printf("parent, var = %d\n", var); printf("I'am parent pid= %d, getppid = %d\n", getpid(), getppid()); } else if (pid == 0) { //写操作,修改的是子进程中全局变量var var = 200; printf("I'am child pid= %d, ppid = %d\n", getpid(), getppid()); printf("child, var = %d\n", var); } printf("------------finish---------------\n"); return 0;}

程序执行结果:

这里写图片描述
图4-进程间共享问题

  父进程打印var = 288,子进程打印var = 200,说明父子进程在写时是不共享全局变量的,父子进程是修改各自的全局变量。

  从上面这个例子来看,父子进程的运行时间也是不共享的。

  例如fork之后父子进程谁先运行,运行时间多久都是不确定,因为父进程和子进程都会争取CPU的执行权,谁先抢到谁就执行,这取决于内核所使用的调度算法。

   如图4所示,pid为2591进程的父进程是bash进程,bash进程一启动,2591进程开始运行,然后bash进程就转到后台去了,把前台让给了2591进程去执行,那bash进程什么时候回到前台呢?

   按理说bash进程把前台让给2591进程执行,当2591执行完毕应该把前台让回来给bash进程,所以bash进程恢复的时机就是判断2591进程是否执行完毕,bash进程恢复的标记就是把终端提示符(test@test-virtual-machine:)打印出来,但是bash进程并不知道2591进程还有子进程(即2592进程)。因此2592进程还会占着前台继续执行,所以当2592进程运行结束,bash进程才会回到前台打印终端提示符。

8. 进程的虚拟地址空间

   为了深入了解进程间共享问题,我们先来了解下进程的地址空间的概念。所谓的地址空间说的是进程虚拟地址空间。就是每个进程都有自己的4GB虚拟地址空间。

#include 
#include
#include
#include
int g_v = 30; //全局变量int main(void){ int a_v = 30; //局部变量 static int s_v = 30; //静态变量 pid_t pid; printf("pid = %d\n", getpid()); pid = fork(); //父进程 if(pid > 0){ //父进程针对这三个变量进行修改 g_v = 40; a_v = 40; s_v =40; //打印值 printf("father = %d : g_v = %d , a_v = %d , s_v = %d\n", getpid() , g_v , a_v , s_v); //打印地址 printf("father = %d : g_v = %p , a_v = %p , s_v = %p\n", getpid() , &g_v , &a_v , &s_v); //子进程 }else if(0 == pid){ //子进程针对这三个变量进行修改 g_v = 50; a_v = 50; s_v = 50; //打印值 printf("child = %d : g_v = %d , a_v = %d , s_v = %d\n", getpid() , g_v , a_v , s_v); //打印地址 printf("child = %d : g_v = %p , a_v = %p , s_v = %p\n", getpid() , &g_v , &a_v , &s_v); }else{ perror("fork"); return -1; } //最后子进程打印数据,父进程也打印数据 printf("pid = %d : g_v = %d , a_v = %d , s_v = %d\n", getpid() , g_v , a_v , s_v); wait(NULL); //回收子进程 //sleep(1); return 0;}

程序执行结果:
这里写图片描述

  通过程序运行时发现,父子进程打印的全局,静态,局部变量值不一样的,但是它们的地址是一样的。所以我们可以确定父进程在fork子进程时,子进程几乎把整个父进程复制了过去(包括0-4G虚拟地址空间)

  在修改数据时,虽然父子进程的数据的虚拟地址相同,但是虚拟地址实际映射到的物理地址却是不同的。

  换句话说,虚拟地址在映射到物理内存的地址时,系统会在物理内存中找一块还没有用,空闲的物理内存,把这个虚拟地址映射到这块空闲的内存的物理地址

9. 读共享写复制

fork之后,父子进程在进行读写操作时各自的数据空间发生了以下变化:

这里写图片描述
图5-虚拟地址到物理地址的映射过程

  父子进程的虚拟地址空间如图5所示,也就是说在父子进程的数据空间中,虚拟地址空间是一样的,但是这并不意味着父子进程的物理地址空间就是一样的。

父子进程数据空间分析:
   从图5可以看出父子进程打印出来的数据时不同的,这意味着子进程的数据空间在进行写操作前并没有额外的开辟物理内存映射,而是和父进程共享的同一块物理内存(也间接说明了虚拟内存空间是共享的)。换句话说,当父子进程任何一个进程发生写操作的时候,都会先针对部分写操作的数据开辟新的物理内存,然后把复制的数据映射到物理内存当中。

虚拟地址到物理地址的映射过程:
  实际上在系统中有一个MMU单元,主要负责虚拟地址到物理地址的映射(感兴趣的同学可以去看看操作系统哈)。

  首先它会根据虚拟地址在物理内存中找一块还没有被使用,空闲的内存块,然后把虚拟地址映射到这块物理内存中。那么进程是怎么找到MMU的呢?答案是三级页表,那么这个页表的映射过程又得另说了,可以确定的是这个映射过程实际上是非常复杂的(这里只是方便理解,简化了一下,有兴趣的可以参考这位大佬的OS笔记:)。

  我们可以得出一个结论,对于读操作,父子进程间是共享的;对于写操作,父子进程间是不共享的。这种机制就是写时复制机制(copy on write)。

10. 写时复制(copy on write)机制

  也就是说写时复制机制(copy on write)是一种推迟或免除复制数据的方法,此时内核并不去复制进程的整个地址空间的数据,而是让父进程和子进程共享同一数据,当进程A调用fork创建出子进程B时,由于子进程B实际是进程A的拷贝,所以进程B会拥有和进程A同一物理页面,也是为了达到节约内存和提高创建进程效率的目标,fork函数实际只会以只读的形式让子进程B共享进程A的物理页面

  同时父进程A也对这些页面设置为只读权限,也就是对此共享物理页面进行了写保护,这样一来,只有A,B任何一个进程对这些共享物理页面进行写操作时都会产生页面异常中断,此时CPU会对此异常进行处理,取消对共享物理页面的写操作,然后为执行写操作的进程复制一块新的物理页面,使A,B进程各自拥有一块相同的物理页面,这才真正的执行了复制操作(其实只复制了这一块物理页面),然后将这块复制的物理页面标记改为可写状态(原先是只读的),因此,在对进程间虚拟地址空间范围内执行写操作时,才会触发写时复制操作。

  在复制之前,会申请一块物理页面来存放复制的物理页面,然后将此物理页面取消共享,并标记的读状态改为可写状态(因为共享属性和读写属性也复制了,所以必须把这些属性改掉),这块物理页面只属于当前执行写操作的进程,其他进程不能对此物理页面进行读写操作,同时在复制时也只会复制针对部分写操作的数据,而不是复制整个数据空间,因此其他部分还是共享的,这样做的目的是为了高效。(以上来自linux 0.11内核版本)

11. 总结

1 . 了解进程和程序的区别

2 . 理解进程空间等相关概念

3 . 掌握fork函数的使用和返回值

4 . 理解写时复制(copy on write)机制

你可能感兴趣的文章
Leetcode C++《热题 Hot 100-70》23.合并K个升序链表
查看>>
6月13日-健身15
查看>>
6月17日-健身17
查看>>
6月20日-健身18
查看>>
有的人遇见就是一生
查看>>
冒险的勇气
查看>>
等待中邂逅命运
查看>>
北方姑娘初见海
查看>>
6月22日-健身19
查看>>
从你的全世界路过
查看>>
世界以痛吻我,我却报之以歌
查看>>
雕刻时光
查看>>
5月25日-健身9-下肢
查看>>
5月18日-健身7-上肢
查看>>
6月4日-健身12-下肢
查看>>
5月23日-健身8-上肢
查看>>
5月29日-健身11-下肢
查看>>
腾讯技术面试官如是说
查看>>
5月27日-健身10-下肢
查看>>
5月16日-健身6-下肢
查看>>