一、冯诺依曼体系结构
1、概念
(1)什么是冯诺伊曼体系结构?
数学家冯·诺伊曼于 1946 年提出存储程序原理,把程序本身当作数据来对待,程序和该程序处理的数据用同样的方式储存。
冯·诺伊曼理论的要点是:计算机的数制采用二进制逻辑;计算机应该按照程序顺序执行。人们把冯·诺伊曼的这个理论称为冯·诺伊曼体系结构。
- 输入单元:包括键盘、鼠标、扫描仪、写板等。
- 中央处理器(CPU):含有运算器和控制器等。
- 输出单元:显示器、打印机等。
(2)冯诺依曼体系结构的计算机是如何工作的呢?
输入设备输入数据时,必须先将数据写入存储器,而存储器本身没有计算能力。CPU 会通过某种方式读取存储器中的数据,进行指定的运算和逻辑操作等加工后,然后再将处理完的数据通过某种方式写回到存储器中,最后输出设备再从存储器中读取数据并输出。
(3)在磁盘中编写好的可执行程序(文件),运行时必须先加载到内存中。这是为什么呢?
因为冯诺依曼体系规定:可执行程序是二进制指令,CPU 要执行这些指令,必须先将磁盘中的可执行程序加载到内存中,CPU 才能访问执行这些指令。
分析:
存储器的层次结构中,越往上速度越快,外设最慢 < 主存其次 < 高速缓存 < CPU寄存器,可以看到,CPU 离寄存器最近,离高速缓存也很近,主存(存储器)次之,所以 CPU 间接从主存中访问数据,效率更高。
而让 CPU 直接访问外设(输入/输出设备)肯定是不行的,因为 CPU 特别快,但输入输出设备特别慢,所以导致效率低。
当一个快的设备和一个慢的设备协同工作时,整个体系最终的运算效率肯定以慢的为主。
类似木桶效应,当我们让 CPU 直接访问磁盘时,那么木桶的短板的就在磁盘上,整个计算机体系的效率就会被磁盘拖累,这显然不是我们想看到的,所以我们必须把数据写入到存储器中,再让 CPU 一级一级的去访问,而且 CPU 在运算的同时,输入/输出设备还可以继续将数据写入内存或从内存中读出,这样就可以将 IO 的时间和运算的时间重合,从而提升效率。
2、硬件层面的数据流
对冯诺依曼的理解,不能只停留在概念上,要深入到对软件数据流理解上。
请解释下,从你登录上 QQ 开始和某位朋友聊天开始,数据的流动过程。从你打开窗口,开始给他发消息,到他看到消息之后的数据流动过程。如果是在 QQ 上发送文件呢?
在 QQ 上发送消息,数据的流动过程:
电脑联网后,我用键盘敲下要发送的消息:“在吗?”,此时输入设备是键盘,键盘将该消息写入到内存中,CPU 间接从内存中读取到消息。对其进行运算处理后,再写回内存,此时输出设备网卡从内存中读取消息,并经过网络发送到对方的网卡,同时输出设备显示器从内存中读取消息并刷新出来,显示在我的电脑上。
我朋友的电脑的输入设备是网卡,接收到消息后,网卡将该消息写入到内存中,CPU 间接从内存中读取到消息,对其进行运算处理后,再写回内存,此时输出设备显示器从内存中读取消息并刷新出来,显示在我朋友的电脑上。
所以,我们就知道了硬件层面的数据流:
键盘→内存→CPU→内存→网卡→网卡经过网络到对方网卡→内存→CPU→内存→显示器
3、关于冯诺依曼的知识点强调
- 这里的存储器指的是内存。
- 不考虑缓存情况,这里的 CPU 能且只能对内存进行读写,不能访问外设(输入/输出设备)。
- 冯诺依曼规定了硬件层面上的数据的流向。
- 在数据层面上,CPU 不和外设(输入/输出设备)打交道,外设只和存储器打交道。(可以将存储器理解为是 CPU 和所有外设的缓存)(而在硬件层面上,外设是可以直接给 CPU 发中断的)
- 外设(输入/输出设备)要输入/输出数据只能写入内存或者从内存中读取。
- 所有设备都只能直接和内存打交道。
4、CPU 工作原理
冯诺依曼体系结构是现代计算机的基础。在该体系结构下,程序和数据统一存储,指令和数据需要从同一存储空间存取,经由同一总线传输,无法重叠执行。根据冯诺依曼体系,CPU 的工作分为以下 5 个阶段:取指令阶段、指令译码阶段、执行指令阶段、访存取数和结果写回。
- 取指令(IF,instruction fetch),即将一条指令从主存储器中取到指令寄存器(用于暂存当前正在执行的指令)的过程。程序计数器中的数值,用来指示当前指令在主存中的位置。当 一条指令被取出后,程序计数器(PC、用于存放下一条指令所在单元的地址的地方)中的数值将根据指令字长度自动递增。
- 指令译码阶段(ID,instruction decode),取出指令后,指令译码器按照预定的指令格式,对取回的指令进行拆分和解释,识别区分出不同的指令类 别以及各种获取操作数的方法。现代CISC处理器会将拆分已提高并行率和效率。
- 执行指令阶段(EX,execute),具体实现指令的功能。CPU 的不同部分被连接起来,以执行所需的操作。
- 访存取数阶段(MEM,memory),根据指令需要访问主存、读取操作数,CPU 得到操作数在主存中的地址,并从主存中读取该操作数用于运算。部分指令不需要访问主存,则可以跳过该阶段。
- 结果写回阶段(WB,write back),作为最后一个阶段,结果写回阶段把执行指令阶段的运行结果数据 “写回” 到某种存储形式。结果数据一般会被写到 CPU 的内部寄存器中,以便被后续的指令快速地存取;许多指令还会改变程序状态字寄存器中标志位的状态,这些标志位标识着不同的操作结果,可被用来影响程序的动作。
在指令执行完毕、结果数据写回之后,若无意外事件(如结果溢出等)发生,计算机就从程序计数器中取得下一条指令地址,开始新一轮的循环,下一个指令周期将顺序取出下一条指令。
5、补充(CPU 和寄存器、高速缓存以及主存之间的关系)
CPU 运算速度快,读取内存,内存速度跟不上,CPU 一般就不会直接访问内存,而是把要访问的数据先加载到缓存体系,如果是小于 8byte 的数据,直接到寄存器,如果是大的数据会到三级缓存,CPU 直接跟缓存交互。
二、操作系统(Operating System)
操作系统被称为计算机的哲学。
1、概念
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。
笼统的理解,操作系统包括:
- 内核 Kernel(操作系统最核心的部分,包含进程管理、内存管理、文件管理、驱动管理等)
- 其他程序(例如函数库、shell 程序等等)
2、定位
在整个计算机软硬件架构中,操作系统的定位是:一款纯正的 “搞管理” 的软件。
3、设计 OS 的目的
学习操作系统前,需要先弄明白:
(1)操作系统是什么?
操作系统是进行软硬件资源管理的软件。
(2)为什么会存在操作系统?设计操作系统的目的?
- 方便用户使用,减少了用户使用计算机的成本。
对上,为用户程序(应用程序)提供一个稳定高效的执行环境。- 对下,与硬件交互,管理好所有的软硬件资源(充分高效的使用软硬件资源)。
4、如何理解 “管理”(先描述,再组织)
思考:操作系统是一款纯正的 “ 搞管理 ” 的软件,那么究竟什么是管理呢?
人的世界要做的就只有两类事情:做决策和做执行。
假设学校模型里面有三部分人构成,学生,辅导员,校长,他们有着不同的身份。
我们在学校里面很少见到校长,说明管理者和被管理者可以不见面和直接打交道(就像公司里的员工和董事长平时并不见面和直接打交道)。
既不见面又不直接打交道,那么校长如何对学生进行管理呢?校方又是如何知道你是该学校的学生呢?
因为你的个人信息在学校的系统中,所以你是该学校的学生。
举个例子:
比如 23 级计科专业有 50 名学生,我们想要给其中特定的一名学生发奖学金,那是否需要校长跑到该专业学生的宿舍里面挨个询问同学们的各科成绩和学分绩点是多少呢?显然不是的,当他想要做发奖学金这个决策时,他只需要通过学校的教务系统,拉取 23 级计科专业 50 名学生的名单,按照学分绩点来进行排名,在排名后再根据其它的一些要求,综合一批数据来做出一个决策:给张三同学发奖学金。当校长做完决策后,通知计科专业的辅导员过来,让他开个表彰大会奖励下张三同学。辅导员说:“好的,校长。”,此时辅导员就开始做执行。
以上就完成了一个管理过程。
既然是管理数据,就一定先要把学生的个人信息抽取出来,而抽取要管理的数据的这个过程,就可以称之为:描述学生。
C 语言用什么来描述学生呢?
C 语言用 struct 结构体来描述学生。如果要管理 1w 个学生,那就有 1w 个结构体变量,每个结构体变量里面保存着每一个学生的所有信息。
// 描述学生
struct student
{
char name[10]; //名字
char sex; //性别
int age; //年龄
double score; //分数
char addr[100]; //家庭住址
// ...
};
如果我们想找出成绩最好的同学,只需要将其每个同学的成绩拿出来进行比较即可。但如果每个结构体变量之间没有任何关联的话,是不方便进行管理的,也很难快速找到成绩最好的同学。
这个时候就需要将这些结构体变量组织起来,比如在 struct 中包含一些指针信息,将所有的结构体变量链接起来,此时就形成了一个双链表。
校长要管理学生,只要有双链表的头指针就行。如果校长想要开除某位学生,只需要遍历双链表,再将该学生所属的节点从双链表中删除即可;如果有新生报到,只需要将该学生所属节点插入到双链表中即可。所以校长并不是单独对一个人进行管理的,而是将学生的个人信息组织起来,对数据结构进行管理。
经过上面的过程,最终我们就将对学生的管理工作转化成对双链表的增删查改操作。
结论:
- 所有管理的工作,本质上就是对数据的管理。
- 管理的本质:先描述,再组织。
【总结】
我们在实际生活中的管理变成了对某种数据结构下的结构体变量的管理,这是操作系统管理的本质。
- 描述起来,用 struct 结构体。
- 组织起来,用链表或其他高效的数据结构(不同的数据结构决定了不同的增删查改的特征和效率,也决定了不同的组织方式)。
在计算机中,校长通常指的是操作系统,辅导员可以称为驱动,学生可以称为软硬件。
操作系统不会直接和硬件(比如磁盘,网卡,鼠标)打交道,而是通过驱动程序和硬件打交道,那操作系统怎么去管理硬件呢?
先描述,再组织。所以操作系统要描述各种各样的硬件,然后形成特定的数据结构,对硬件的管理,最后变成了对数据结构的管理。
举例:操作系统要管理磁盘,那得要有一个描述硬盘的 struct 结构体,而描述一个事物,通常用的是事物的属性,比如磁盘的大小、磁盘的型号等等;操作系统卸载一个硬件,并不是要把这个硬件从电脑中拆卸走,而是把这个硬件对应的描述信息给删除掉。
所以操作系统为了管理好被管理对象,在系统内部维护了大量的数据结构。
5、 计算机体系层状结构
(1)硬件部分
遵循冯诺依曼体系结构。
(2)驱动程序
操作系统中默认会有一部分驱动。如果有新外设,就需要单独安装驱动程序,该驱动程序会通过某种方式将该硬件的信息上报给操作系统,告诉操作系统,多了这个硬件。(驱动程序更多是一种执行者的角色)
(3)操作系统
操作系统最重要的四个功能:进程管理、内存管理、文件管理、驱动管理。
(4)系统调用接口
操作系统是不信任何用户的,任何对硬件或者系统软件的访问,都必须通过操作系统的手(好比银行是不信任任何用户的,用户想要取钱存钱,都必须经过银行的手),所以用户对操作系统中资源的访问,都必须调用对应的系统接口。(比如:在 Linux 中执行命令,或运行一个 C 程序,底层都用到了系统接口)。
- 系统调用接口,本质是操作系统为了方便用户使用操作系统中的某种资源,给用户提供的一些调用接口。但即使这样,系统调用接口用起来也不是特别方便。所以一般我们会在系统调用接口上再封装一层(比如:shell 外壳,系统库,部分指令,这些的底层一般都是封装的系统调用接口)。
- 不断的封装,也是为了让用户用起来更简单。比如:安装 C/C++ 环境时,系统会默认带上 C/C++ 标准库,这些库提供给用户的接口是一样的,但是底层可能不一样,在 Windows 中调用的就是 Windows 的系统接口,在 Linux 中调用的就是 Linux 的系统接口。
(5)用户操作接口
底层大都是封装的系统调用接口。
6、拓展(库函数和系统调用)
- 库函数:语言或者第三方库给我们提供的接口。(实际上我们使用的函数,底层一般就两种情况,要么调用了系统接口,比如 printf;要么没有调用系统接口,比如自己写的 add 函数,自己写的循环等)。
- 系统调用:操作系统提供的接口。
在开发的角度,操作系统对外会表现成一个整体,但还是会暴露自己的部分接口,供上层开发使用,这部分由操作系统提供的接口,叫做系统调用。
系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以,有心的开发者可以对部分系统调用进行适度封装,从而形成库,有了库,就很有利于更上层用户或者开发者进行二次开发。
三、进程(Process)
在 Linux 中,每个执行的程序都称为一个进程。每一个进程都分配一个 ID 号(PID,进程号)。
与 Windows 下的任务管理器中的进程意思相同。
操作系统是如何进行进程管理的呢?
先把进程描述起来,再把进程组织起来。
1、前言
操作系统能不能一次运行多个程序呢?
可以。因为运行的程序有很多,所以 OS 需要将这些运行的程序管理起来。
这些正在运行的程序称之为进程。
如何管理进程呢?
先描述,再组织。
- 操作系统会创建一个描述和控制该进程的结构体。这个结构体称之为进程控制块(PCB,Processing Control Block),里面包含了该进程几乎所有的属性信息,同时通过进程控制块也可以找到该进程的代码和数据。
- 在 Linux 中,进程控制块就是 struct task_struct 结构体。
- 描述好所有的进程后,还需要将所有进程的 PCB 给组织起来(通过双链表的方式),此时操作系统只需要拿到双链表的头指针,就可以找到所有进程的 PCB。
- OS 把对进程的管理就转换成对数据结构中 PCB 的管理,即对双链表的增删查改操作。
假设这里有一个可执行程序 test,它存储在磁盘上,就是一个普通文件,当 ./test 运行此程序,操作系统会做以下事情:
将该程序从磁盘加载到内存中,并为该程序创建对应的进程,申请进程控制块(PCB)。
为什么要存在 PCB 呢?
因为 OS 要对进程进行管理。
目前对于进程的理解:进程 = 程序(代码 + 数据) + 内核申请的与该进程对应的数据结构(PCB)。
2、基本概念
- 课本概念:程序的一个执行实例,正在执行的程序等。
- 内核观点:担当分配系统资源(CPU 时间、内存)的实体。
3、描述进程 - PCB
人类认识事物是通过事物的属性,而计算机是通过进程的属性去描述和认识进程。
那为什么又要用到数据结构呢?
因为数据结构是把数据组织起来的艺术,可以把被描述对象的属性集组织起来,而不同的数据结构,时间空间运算特征是完全不一样的,可以满足不同的场景。所以操作系统中充斥着大量的数据结构,用来组织被管理的对象。
- 进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合。
- 课本上称之为 PCB(process control block),Linux 操作系统下的 PCB 是:task_struct(可参考:【Linux】Linux 中进程控制块 PCB —— task_struct 结构体结构-CSDN博客
- 为了描述和控制进程的运行,系统中存放进程的管理和控制信息的数据结构称为进程控制块(PCB,Process Control Block),它是进程实体的一部分,是操作系统中最重要的记录性数据结构,它是进程管理和控制的最重要的数据结构。每一个进程均有一个 PCB,在创建进程时,建立 PCB,伴随进程的生命周期,直到进程终止时,PCB 将被删除。
- 在 Linux 中描述进程的结构体叫做 task_struct。
- task_struct 是 Linux 内核的一种数据结构,它会被装载到 RAM(内存)里并且包含着进程的属性信息。
PCB 如何描述进程呢?
通过进程属性来描述进程。
task_struct 有以下进程属性保存在进程控制块中,并随进程的状态而变化:
- 标示符:描述本进程的唯一标示符,用来区别其他进程。
- 状态:任务状态,退出代码,退出信号等。
- 优先级:相对于其他进程的优先级。
假设食堂只有一个窗口,一次只能给一个人打饭,那么我们就需要排队,而排队的本质就是在确立优先级,决定你是先吃饭还是后吃饭,而插队的本质就是在更改优先级。
- 程序计数器:程序中即将被执行的下一条指令的地址。
简单一点理解,CPU 的核心工作流程是:取指令、分析指令、执行指令。
进程在运行,实际上是 CPU 在执行该进程的代码,那 CPU 如何得知应该取进程中的哪行指令呢?
在 CPU 中有一个寄存器叫做 EIP,这个寄存器通常被称为 PC 指针,保存着当前正在执行指令的下一条指令的地址。
如果某个进程没有跑完,不想运行时,可以把 EIP 中的内容保存进这个进程的 PCB 中,方便后面恢复运行(这样说只是为了方便理解,实际上并没有这么简单的)。
- 内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
CPU 只认识 PCB,不认识程序代码和数据。可以理解成,通过 PCB 中的内存指针,可以帮我们找到该进程对应的代码和数据。
- 上下文数据:进程执行时处理器的寄存器中的数据。
- I/O 状态信息:包括显示的 I/O 请求,分配给进程的 I/O 设备和被进程使用的文件列表。
- 记账信息:可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
比如:调度一个进程,该进程运行多长时间了,累计被切换多少次了等等。
记账信息是可以指导操作系统去做某些任务的。
举例:假设一个进程被调度了 50s,一个进程被调度了 5s,两个进程优先级一样,那么在下次调度时,应该调度哪个进程呢?
一般是调度时间短的进程。
- 其他信息。
4、组织进程
可以在内核源代码里找到它,所有运行在系统里的进程都以 task_struct 链表的形式存在内核里。
5、查看进程
命令:ps ajx
- a:所有
- j:任务
- x:把所有的信息全部输出
- 一般搭配管道使用,如:ps ajx | head - l && ps ajx | grep test,其中 ps ajx | head - l 是把 ps ajx 输出的信息中的第一行信息(属性列)输出。
[ll@VM-0-12-centos 8]$ ps ajx | head -1 && ps ajx | grep test
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND # 属性列
26837 27406 27406 26837 pts/1 27406 S+ 1001 0:00 ./test
22628 27923 27922 22628 pts/0 27922 R+ 1001 0:00 grep --color=auto test # grep进程
- 使用 top 命令实时显示进程(process)的动态。
- 通过 /proc 系统文件目录查看。
如果要进一步查看 pid 为 2559 的进程信息,查看 /proc/2559 文件目录即可。
操作系统中的 1 号进程是什么呢?
在 root 下查看下 1 号的进程信息:
6、通过系统调用获取进程标示符
- 进程 ID(PID)
- 父进程 ID(PPID)
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
while(1)
{
printf("I am a process, my pid is:%u\n", getpid()); //返回正在调用进程的进程ID
printf("I am a process, my ppid is:%u\n", getppid()); //返回正在调用进程的父进程ID
sleep(1);
}
return 0;
}
注意:pid_t 是无符号整数。
补充:
shell 是命令行解释器(command Interpreter)。
shell 是对所有外壳程序的统称,而 bash 是某一个具体的 shell。bash 也是许多 Linux 发行版的默认 shell。
在执行命令的时候,一般情况下,往往不是由 bash 来解释和执行,而是由 bash 创建子进程,让子进程去执行。
7、进程切换和上下文数据
(1)进程切换
- 进程在 CPU 上运行,并不是一直运行到进程结束。每个进程都有一个运行时间单位:时间片。
时间片:从进程开始运行直到被抢占的时间。
比如:进程 1 运行了 50ms,即使进程 1 没有运行完,但它的时间片耗尽了,必须剥离此进程,让出 CPU,切换下一个进程运行。
一般情况下,进程让出 CPU,进行进程切换,有几种情况:
- 来了一个优先级更高的进程。(要求:OS 必须支持抢占)
- 时间片到了。
- 操作系统允许同时运行多个进程。但事实上,一个单核 CPU 永远不可能真正地同时运行多个任务,这些进程 “看起来像” 同时运行的,实则是通过进程快速切换的方式,在一段时间内让所有的进程代码都得到推进,这就是并发。但由于时间片通常很短(在 Linux 上为 5ms-800ms),用户不会感觉到。
多核 CPU / 多个 CPU,允许多个进程同时执行,这就是并行。
大多数操作系统是并发和并行在同时起作用。
(2)上下文数据
进程在 CPU 上运行,CPU 寄存器上会产生很多临时数据,当一个进程被切换时,这些数据是需要被保存的,而这些数据被称为进程的上下文数据。
举例:张三大一上完后,家中有事想要休学一年,给学校提出申请,保留学籍一年,这时才能正常离开学校。一年后,张三再次回到学校,给学校提出申请,恢复学籍,这时才能继续正常上学。
上下文数据的保存和恢复:
- 上下文数据的保存:当一个进程在运行中,因为某些原因(比如时间片到了),需要暂时停止运行,让出 CPU,此时进程需要保存好自己所有的临时数据(即当前进程的上下文数据)到对应的 PCB 中,保存的目的是为了恢复。
- 上下文数据的恢复:当这个进程又被切换回来时,或者切换到下一个新进程运行时,只需要把该进程的 PCB 中的上下文数据重新写入到 CPU 寄存器中,即可恢复运行。
进程切换最重要的一步就是:
进行硬件上下文的保存。
运行队列:
- 假如当前操作系统中,有 4 个进程是处于可运行状态的,操作系统会形成一个运行队列。
- 每一个 PCB 用全局的链表连起来,其中可能有若干处于可运行状态的进程,同时也属于运行队列。
- CPU 要执行任务时就从这个运行队列中寻找就行了。
- 当 Linux 内核要寻找一个新的进程在 CPU 上运行时,必须只考虑处于可运行状态的进程(即在 R 状态的进程),因为扫描整个进程链表是相当低效的,所以引入了容纳可运行状态的进程的双向循环链表,也叫运行队列(runqueue)。
- 运行队列容纳了系统中所有可以运行的进程,它是一个双向循环队列。
-
该队列通过 task_struct 结构中的两个指针 run_list 链表来维持。队列的标志有两个:一个是 “空进程” idle_task、一个是队列的长度。
-
操作系统为每个进程状态管理各种类型的队列,与进程相关的 PCB 也存储在相同状态的队列中。如果进程从一种状态转移到另一种状态,则其 PCB 也从相应的队列中断开,并被添加到进行转换的另一个状态队列中。
-
所以 PCB 是可以被列入多种数据结构内的。比如 PCB 在被调度的时候,以及在等待某种资源的时候会被从调度队列移入或移出,包括等待某种资源的等待队列。
8、通过系统调用创建进程 - fork(初识)
(1)系统调用接口 fork 的介绍
平时创建进程一般是通过 ./myproc 运行某个存储在磁盘上的可执行程序来创建。而我们还可以通过系统调用接口来创建进程:
#include <unistd.h>
// pid_t是无符号整数
pid_t fork(void); // fork函数功能:创建一个子进程
fork 函数说明:
运行 man fork 认识 fork。
通过复制调用进程创建一个新进程。
fork 有两个返回值。
父子进程代码共享,数据各自私有一份(采用写时拷贝)。
举例:
fork 之后,如果不做任何的分流,fork 下面的所有代码是被父子进程共享的。
#include <stdio.h>
#include <sys/types.h> // getpid, getppid
#include <unistd.h> // getpid, getppid, fork, sleep
int main()
{
printf("I am a father: %u\n", getpid());
fork();
while(1)
{
printf("I am a process, pid: %u, ppid: %u\n", getpid(), getppid());
sleep(1);
}
return 0;
}
此时查看进程:
(2)fork 的两种理解
站在程序员的角度:
父子进程共享用户代码(代码是只读的,不可写),而用户数据各自私有一份(为了不让进程互相干扰),采用写时拷贝技术。
打开 Windows 的任务管理器,可以看到有很多进程,假如我把微信进程关掉,会不会影响到 QQ 进程呢?
不会。
总结:
操作系统中,所有进程是互相独立的,进程具有独立性。为了不让进程互相干扰。
注意:
fork 之后子进程会被创建成功,然后父子进程都会继续运行,但谁先运行是不确定的,由系统调度优先级决定。
站在内核的角度:
fork 之后,站在操作系统的角度,是不是系统多了一个进程?
是的。
fork 创建子进程,通常以父进程为模板,其中子进程默认使用的是父进程的代码和数据(写时拷贝)。
既然多了一个进程,OS 就会为子进程创建新的 PCB,并把父进程 PCB 中的部分内容拷贝过来。
(3)fork 的常规用法
我们创建子进程的目的是为了让子进程给我们完成任务,所以 fork 之后通常要用 if 进行分流,让父子进程执行不同的代码,实现一个并行的效果。(比如父进程播放音乐,子进程下载文件)
通过 fork 的两个返回值来进行分流:
- 如果 fork 执行成功,在父进程中返回子进程的 pid,在子进程中返回 0。
- 如果 fork 执行失败,在父进程中返回 -1,不创建子进程,并适当地设置 errno。
#include <stdio.h>
#include <sys/types.h> // getpid, getppid
#include <unistd.h> // getpid, getppid, fork
int main()
{
printf("I'm a father: %u\n", getpid());
pid_t ret = fork();
if (ret == 0)
{
// child process
while (1)
{
printf("child process, pid:%u, ppid:%u\n", getpid(), getppid());
sleep(1);
}
}
else if (ret > 0)
{
// father process
while (1)
{
printf("father process, pid:%u, ppid:%u\n", getpid(), getppid());
sleep(1);
}
}
else
{
// failure
perror("fork");
return 1;
}
return 0;
}
站在语言的角度,是不可能同时进入两个执行流的,既进入 if 也进入 else if 的,即不可能同时执行两个死循环。
但实际的运行结果:
(4)理解 fork 的返回值(浅层理解)
fork 为什么会有两个返回值?调用一个函数时,这个函数准备 return 了,请问这个函数的功能执行完成了吗?
执行完了。
画图分析 fork 函数:
如果 fork 执行成功,为什么在父进程中返回子进程的 pid,在子进程中返回的是 0 呢?
在人类世界里每个小孩只有一个亲生父亲,而父亲可以有多个孩子。
所以儿子找父亲是特别简单的,是唯一的;而父亲为了更好的找孩子,需要给每个孩子标识,并且记住他。(比如:张三、李四、王五... ...)
所以在父进程中需要返回子进程的 pid,因为得让父进程知道自己的子进程(儿子)是谁。
而子进程只需要知道自己被创建成功了就行,所以在子进程中返回 0 即可。
如果创建多个子进程呢?
通过循环创建,下面这段代码并不完善,只是为了简单理解如果创建多个子进程的情况:
#include <stdio.h>
#include <stdlib.h> // exit
#include <sys/types.h> // getpid, getppid
#include <unistd.h> // getpid, getppid, fork, sleep
int main()
{
// 创建5个子进程
for (int i = 0; i < 5; i++)
{
pid_t ret = fork();
if (ret == 0)
{
// child process
printf("child%d, pid:%u, ppid:%u\n", i, getpid(), getppid());
sleep(1);
exit(1); // 子进程退出
}
}
getchar(); // getchar()目的是不让父进程退出,否则无法回收子进程。
return 0;
}
运行结果:成功创建了 5 个子进程。但程序会一直卡在这里,不会自己退出。
为什么上述代码中,fork 的返回值 ret 有两个值,既等于 0 又大于 0 呢?fork 之后,父子进程如何做到共享用户代码,如何做到用户数据各自私有的呢?
这两个问题学习了进程地址空间就能够很好的理解了。
9、进程状态(state)
进程状态查看命令:ps aux / ps axj
一个进程的生命周期可以划分为一组状态,这些状态刻画了整个进程。
进程状态即体现一个进程的生命状态。
操作系统描述的状态,放在任何操作系统中都是这样的:
但操作系统描述的状态,是属于一种整体宏观的描述。所以我们还需要进一步来学习具体一种操作系统,比如 Linux 中的进程状态。
(1)Linux 内核源码中的进程状态
为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程可以有几个状态(在 Linux 内核里,进程有时候也叫做任务)。
Linux Kernel 源码下载地址:The Linux Kernel Archives
下面的状态在 kernel 源代码(2.6版本)里定义:
/*
* The task state array is a strange "bitmap" of
* reasons to sleep. Thus "running" is zero, and
* you can test for combinations of others with
* simple bit tests.
*/
static const char* const task_state_array[] = {
"R (running)", /* 0 */
"S (sleeping)", /* 1 */
"D (disk sleep)", /* 2 */
"T (stopped)", /* 4 */
"t (tracing stop)", /* 8 */ // 比如调试程序打断点,在断点处停下来的状态
"Z (zombie)", /* 16 */
"X (dead)", /* 32 */
};
- R 运行状态(running):并不意味着进程一定在运行中,它表明进程要么在运行中,要么在运行队列里。
- S 睡眠状态(sleeping):意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠 interruptible sleep)。
- D 磁盘休眠状态(disk sleep):有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待 IO 的结束。
- T 停止状态(stopped):可以通过发送 SIGSTOP 信号给进程来停止进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。
- Z 僵尸状态(zombie)
- X 死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
(2)R 状态(running)
一个进程是 R 状态,它一定在 CPU 上面运行吗?
不一定,进程在运行队列中也是 R 状态。如果一个进程想被 CPU 运行,就必须处在 R 状态才行。
R 状态是:可运行状态。(准备好了,可以被调度。)
为什么该进程的状态是 S 状态,不是 R 状态呢?
- 因为该进程大部分时间都在休眠(sleep(1);)。
- 因为 printf 是往显示器上打印,涉及到 IO,所以效率比较低,该进程需要等待操作系统把数据刷新到显示器中。
所以,该进程绝大多数时间都在休眠,只有极少数的时间在运行,所以很难看到该进程处在 R 状态。
那如何可以看到该进程是 R 状态呢?
写一个空死循环 while (1) {} 就可以看到了。
(3)S 睡眠状态(sleeping)和 D 磁盘休眠状态(disk sleep)
S:休眠状态(sleeping)(浅度休眠,大部分情况)
- 表示进程虽然是一种休眠状态,但随时可以接受外部的信号,处理外部的请求,被唤醒。
D:磁盘休眠状态(disk sleep)(深度休眠)
比如:进程 A 想要把一些数据写入磁盘中,因为 IO 需要时间,所以进程 A 需要等待。但因为内存资源不足,在等待期间进程 A 被操作系统 kill 掉了,而此时磁盘因为空间不足,写入这些数据失败了,却不能把情况汇报给进程 A,那这些数据该如何处理呢?
很可能导致这些数据被丢失,操作系统 kill 掉进程 A 导致了此次事故的发生。
所以诞生了 D 状态,不可以被杀掉,即便是操作系统。只能等待 D 状态自动醒来,或者是关机重启。
【总结】
S 状态和 D 状态都是一种等待状态,因为某种条件没被满足。
比如:QQ 进程想要给网卡发消息,但网卡太忙了,所以可以把 QQ 进程设置成休眠状态,等网卡闲了再把QQ进程唤醒,去发消息。
【补充】
查看进程状态时,会看到 S+ 状态和 S 状态,那两个有什么区别吗?
S+ 状态:表示前台进程。(前台进程一旦运行,bash 就无法进行命令行解释,使用 Ctrl+C 可以终止前台进程)
S 状态:表示后台进程。(后台进程在运行时,bash 可以进行命令行解释,使用 Ctrl+C 无法终止后台进程)
(4)T 停止状态(stopped):了解即可
kill 命令:可以向目标进程发信号。
[ll@VM-0-12-centos 9]$ kill -l
1) SIGHUP 2) SIGINT 3) SIGQUIT 4) SIGILL 5) SIGTRAP
6) SIGABRT 7) SIGBUS 8) SIGFPE 9) SIGKILL 10) SIGUSR1
11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
16) SIGSTKFLT 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP
21) SIGTTIN 22) SIGTTOU 23) SIGURG 24) SIGXCPU 25) SIGXFSZ
26) SIGVTALRM 27) SIGPROF 28) SIGWINCH 29) SIGIO 30) SIGPWR
31) SIGSYS 34) SIGRTMIN 35) SIGRTMIN+1 36) SIGRTMIN+2 37) SIGRTMIN+3
38) SIGRTMIN+4 39) SIGRTMIN+5 40) SIGRTMIN+6 41) SIGRTMIN+7 42) SIGRTMIN+8
43) SIGRTMIN+9 44) SIGRTMIN+10 45) SIGRTMIN+11 46) SIGRTMIN+12 47) SIGRTMIN+13
48) SIGRTMIN+14 49) SIGRTMIN+15 50) SIGRTMAX-14 51) SIGRTMAX-13 52) SIGRTMAX-12
53) SIGRTMAX-11 54) SIGRTMAX-10 55) SIGRTMAX-9 56) SIGRTMAX-8 57) SIGRTMAX-7
58) SIGRTMAX-6 59) SIGRTMAX-5 60) SIGRTMAX-4 61) SIGRTMAX-3 62) SIGRTMAX-2
63) SIGRTMAX-1 64) SIGRTMAX
举个例子:
-
我们给进程发 19 号信号 SIGSTOP,可以让进程进入 T 停止状态。停止运行。
-
我们给进程发 18 号信号 SIGCONT,可以让进程停止 T 停止状态。恢复运行。
(5)Z 僵尸状态(zombie)-- 僵尸进程
要知道,进程退出,一般不是立马就让操作系统回收进程的所有资源。
因为创建进程的目的,是为了让它完成某个任务和工作。当它退出时,我们得知道它把任务完成的怎么样,所以需要知道这个进程是正常还是异常退出的。
如果进程是正常退出的,那么交给进程的任务有没有正常完成呢?
所以,进程退出时,会自动将自己的退出信息,保存到进程的 PCB 中,供 OS 或者父进程来进行读取。
- 进程退出但父进程还没有读取,进程此时就处于僵尸状态。
- 读取成功后,该进程才算是真正的死亡,变成 X 死亡状态。
⚪僵尸状态的概念
- 僵死状态(Zombies)是一个比较特殊的状态。当子进程退出,并且父进程没有读取到子进程退出时的返回代码时就会产生僵死(尸)进程。(父进程使用系统调用 wait() 让 OS 回收子进程)
- 僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出代码。
- 所以,只要子进程退出,父进程还在运行,但父进程没有读取到子进程状态,子进程就会进入 Z 状态。
僵尸进程例子:
#include <stdio.h>
#include <stdlib.h> // exit
#include <sys/types.h> // getpid, getppid
#include <unistd.h> // getpid, getppid, fork, sleep
int main()
{
// 创建5个子进程
for (int i = 0; i < 5; i++)
{
pid_t ret = fork();
if (ret == 0)
{
// child process
printf("child%d, pid:%u, ppid:%u\n", i, getpid(), getppid());
sleep(1);
exit(1); // 子进程退出
}
}
getchar(); // getchar()目的是不让父进程退出,则无法回收子进程。
return 0;
}
运行结果:成功创建了 5 个子进程。但程序会一直卡在这里,不会自己退出。
值得注意的是,子进程的打印顺序并不是按照创建顺序(即编号 0-4)来的,这是因为操作系统调度进程的执行没有固定的顺序,进程的执行顺序可能会因为多种因素(如调度策略、系统负载等)而有所不同。在这个例子中,child4 输出首先出现,随后是其他子进程的输出,这展示了进程调度的非确定性特性。
观察子进程状态的变化:5 个子进程退出后,因为父进程没有进行回收,都变成了僵尸状态。
注意:父进程通过调用 getchar() 函数来等待用户输入,这样做可以防止父进程过早退出,在大多数情况下,这也意味着父进程不会立即回收结束的子进程资源,因为没有调用 wait / waitpid 函数来等待子进程结束。
虽然父进程通过 getchar() 等待,但这并不是处理僵尸进程(已结束但未被父进程回收的子进程)的正确做法。在实际应用中,父进程应该使用 wait / waitpid 函数来等待子进程结束,并回收它们的资源,以避免僵尸进程的产生。
ptrace 系统调用追踪进程运行,有兴趣研究一下:【Linux】Ptrace -- 详解-CSDN博客
(6)僵尸进程的危害
- 僵尸进程是一种问题,必须得到解决,否则会导致内存泄漏。
- 进程的退出状态必须被维持下去,因为他要告诉关心它的进程(父进程),你交给我的任务,我办的怎么样了。可父进程如果一直不读取,那子进程就会一直处于 Z 状态。
- 维护退出状态本身就是要用数据维护,也属于进程的基本信息,所以保存在 task_struct(PCB) 中,换句话说,Z 状态一直不退出,PCB 就要一直维护。
- 如果一个父进程创建了很多子进程,但就是不回收,会造成内存资源的浪费。因为数据结构要占用内存,定义一个 task_struct(PCB) 结构体变量要在内存的某个位置开辟空间,这就是内存泄漏。
如何避免内存泄漏?
进程等待。
(7)进程状态总结
现在再回过头来看操作系统描述的状态,分别对应的是 Linux Kernel 中的哪一种进程状态呢?
- 就绪 / 运行:R 状态
- 阻塞:S / D / T 状态
- 退出:Z / X 状态
如果一个进程是 D 状态,能够被 kill 杀掉吗?
不能,因为进程在深度休眠等待资源。
如果一个进程是 Z 状态,能够被 kill 杀掉吗?
不能,因为这种进程已经死了。
(8)孤儿进程
父进程先退出,子进程就称之为 “孤儿进程”,孤儿进程是一种特殊的进程。
孤儿进程被 1 号 systemd 进程领养,孤儿进程退出时,由 1 号 systemd 进程回收。
(注:不同的系统版本,1 号进程的名称可能不一样,比如 centos 6.5 的 1 号进程叫 initd)
孤儿进程例子:
#include <stdio.h>
#include <stdlib.h> // exit
#include <sys/types.h> // getpid, getppid
#include <unistd.h> // getpid, getppid, fork, sleep
int main()
{
// 孤儿进程演示
if (fork() > 0)
{
// father process
sleep(3); // 父进程休眠3s后退出
printf("father process exits!\n");
exit(0);
}
// 子进程将执行这段代码
while (1)
{
printf("child process, pid: %u, ppid: %u\n", getpid(), getppid());
sleep(1);
}
return 0;
}
运行结果:
观察子进程状态的变化:
1 号进程:
10、进程优先级(priority)
优先级 vs 权限,两者有什么区别呢?
- 优先级:在资源有限的前提下,确立多个进程中谁先访问资源,谁后访问资源。
- 权限:决定能不能得到某种资源。
(1)基本概念
- CPU 资源分配的先后顺序,就是指进程的优先级(priority)。
- 优先级高的进程有优先执行权利。配置进程优先级对多任务环境下的 Linux 很有用,可以改善系统性能。
- 还可以把进程运行到指定的 CPU 上,这样一来,把不重要的进程安排到某个 CPU,可以大大改善系统整体性能。
(2)查看系统进程
在 Linux 或者 Unix 系统中,使用命令 ps -al 查看当前系统进程的信息:
- UID:代表执行者的 ID,通过命令 ll -n 可以查看。
在 Linux 中,标识一个用户,不是通过用户名来标识的,而是通过用户的 UID。
计算机比较善于处理数据,UID 是给计算机看的,UID 对应的用户名是方便给人看的。
比如 QQ 可以随意更改昵称,那就说明昵称不是唯一标识这个 QQ 用户的,而是通过 QQ 号。
- PID:代表这个进程的代号。
- PPID:代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号。
- PRI:表示这个进程可被执行的优先级。
其值越小,优先级越高,越早被执行。
其值越大,优先级越低,越晚被执行。
- NI:nice 值,表示进程可被执行的优先级的修正数值:[-20, 19],一共 40 个级别。
- 进程新的优先级:PRI(new) = PRI(old, 默认都是 80) + nice
注意:
优先级不可能一味的高,也不可能一味的低。因为 OS 的调度器也要考虑公平问题。
进程的 nice 值不是进程的优先级,他们不是一个概念,但是进程的 nice 值会影响到进程的优先级变化。
(3)查看进程优先级的命令
通过 top 命令(类似于 Windows 的任务管理器)更改已存在进程的 nice:
- 执行 top 命令后,按 r 键,输入进程的 PID,输入 nice 值。
注意:
每次输入 nice 值调整进程优先级,都是默认从 PRI = 80 开始调整的。
输入的 nice 值如果超过 [-20, 19] 这个范围,默认是按照最左/最右范围来取的。
为什么每次都要默认从 PRI = 80 开始调整呢?
- 有一个基准值,方便调整。
- 在设计上,实现比较简单。
为什么 nice 值的范围是 [-20, 19] 呢?
是一种可控状态,保证了进程的优先级始终在 [60, 99] 这个范围内,保证了 OS 调度器的公平。但公平并不是平均。根据每个进程的特性尽可能公平的去调度它们,而不是指每个进程的调度时间必须完全一样。
参考:【Linux】Linux 的进程优先级 NI 和 PR-CSDN博客
⚪补充
- 竞争性:系统进程数目众多,而 CPU 的资源很少,甚至只有一个,所以进程之间是具有竞争属性的。为了更高效的完成任务,更合理的竞争相关资源,便有了优先级。
- 独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰。(这也是 OS 设计进程的一个原则)
- 并发:多个进程在一个 CPU 下采用进程切换的方式,在同一段时间内,让多个进程都得以推进。(描述的时间段)
- 并行:多个进程在多个 CPU 下同时运行。(描述的是时刻,任何一个时刻,都可能有多个进程在运行)