目录
- 一、冯诺依曼体系结构
- 1、概念
- 2、硬件层面的数据流
- 3、关于冯诺依曼的知识点强调
- 4、CPU 工作原理
- 5、补充(CPU 和寄存器、高速缓存以及主存之间的关系)
- 二、操作系统(Operating System)
- 1、概念
- 2、定位
- 3、设计 OS 的目的
- 4、如何理解 “管理”(先描述,再组织)
- 5、 计算机体系层状结构
- 6、拓展(库函数和系统调用)
- 三、进程(Process)
- 1、基本概念
- 2、进程控制块- PCB
- 3、描述进程
- 4、组织进程
- 5、查看进程
- (1)进程属性1.1:PID
- (i)通过系统调用获取进程的PID和PPID。
- (ii)终止进程
- (iii)通过ps命令查看该进程的PID
- (vi)关闭进程重新打开,PID会重新分配
- (2)通过系统目录查看进程
- (i)操作系统中的 1 号进程
- (ii)chdir
- (3)通过ps命令查看进程
- (4)进程属性1.2:PPID
- (i)通过系统调用获取进程的PPID。
- (ii)bash
- 6、通过系统调用创建进程 - fork(初识)
- (1)系统调用接口 fork 的介绍
- 7、进程属性2:进程状态(state)
- (1)等待的本质
- (2)挂起
- (3)Linux 内核源码中的进程状态
- (i)R 状态(running)
- (ii)S 睡眠状态(sleeping)和 D 磁盘休眠状态(disk sleep)
- (iii)T 停止状态(stopped):了解即可
- (iv)死亡状态-X
- (v)Z 僵尸状态(zombie)-- 僵尸进程
- (vi)孤儿进程
- 8、进程属性3:进程优先级(priority)
- (1)基本概念
- (2)查看系统进程
- (3)查看进程优先级的命令
- 9、进程属性4:程序计数器(pc)+内存指针+上下文数据
一、冯诺依曼体系结构
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 工作原理
- 取指令(IF,instruction fetch),即将一条指令从主存储器中取到指令寄存器(用于暂存当前正在执行的指令)的过程。程序计数器中的数值,用来指示当前指令在主存中的位置。当 一条指令被取出后,程序计数器(PC、用于存放下一条指令所在单元的地址的地方)中的数值将根据指令字长度自动递增。
- 指令译码阶段(ID,instruction decode),取出指令后,指令译码器按照预定的指令格式,对取回的指令进行拆分和解释,识别区分出不同的指令类 别以及各种获取操作数的方法。现代CISC处理器会将拆分已提高并行率和效率。
- 执行指令阶段(EX,execute),具体实现指令的功能。CPU 的不同部分被连接起来,以执行所需的操作。
- 访存取数阶段(MEM,memory),根据指令需要访问主存、读取操作数,CPU 得到操作数在主存中的地址,并从主存中读取该操作数用于运算。部分指令不需要访问主存,则可以跳过该阶段。
- 结果写回阶段(WB,write back),作为最后一个阶段,结果写回阶段把执行指令阶段的运行结果数据 “写回” 到某种存储形式。结果数据一般会被写到 CPU 的内部寄存器中,以便被后续的指令快速地存取;许多指令还会改变程序状态字寄存器中标志位的状态,这些标志位标识着不同的操作结果,可被用来影响程序的动作。
在指令执行完毕、结果数据写回之后,若无意外事件(如结果溢出等)发生,计算机就从程序计数器中取得下一条指令地址,开始新一轮的循环,下一个指令周期将顺序取出下一条指令。
5、补充(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)
1、基本概念
- 课本概念:程序的一个执行实例,正在执行的程序等。
- 内核观点:担当分配系统资源(CPU 时间、内存)的实体。
在 Linux 中,每个执行的程序都称为一个进程。每一个进程都分配一个 ID 号(PID,进程号)。
代码中的小问题: 上述代码可查看linux中的进程,但是grep出来的结果里有grep,因为在grep查自己启动的程序以为,grep本身也是个进程,他的关键字里有myproc,所以会出现。我们可以在上述代码中继续打入| grep -v 反向匹配grep,即不要grep。
与 Windows 下的任务管理器中的进程意思相同。
2、进程控制块- PCB
操作系统是如何进行进程管理的呢?
先把进程描述起来,再把进程组织起来。—先描述,再组织。
操作系统能不能一次运行多个程序呢?
可以。因为运行的程序有很多,所以 OS 需要将这些运行的程序管理起来。
这些正在运行的程序称之为进程。
- 操作系统会创建一个描述和控制该进程的结构体。这个结构体称之为进程控制块(PCB,Processing Control Block),里面包含了该进程几乎所有的属性信息,同时通过进程控制块也可以找到该进程的代码和数据。每一个进程均有一个 PCB,在创建进程时,建立 PCB,伴随进程的生命周期,直到进程终止时,PCB 将被删除。
- 在 Linux 中,进程控制块就是 struct task_struct 结构体。task_struct 是 Linux 内核的一种数据结构,它会被装载到 RAM(内存)里并且包含着进程的属性信息。
- 描述好所有的进程后,还需要将所有进程的 PCB 给组织起来(通过双链表的方式),此时操作系统只需要拿到双链表的头指针,就可以找到所有进程的 PCB。
- OS 把对进程的管理就转换成对数据结构中 PCB 的管理,即对双链表的增删查改操作。
假设这里有一个可执行程序 test,它存储在磁盘上,就是一个普通文件,当 ./test 运行此程序,操作系统会做以下事情:
将该程序从磁盘加载到内存中,并为该程序创建对应的进程,申请进程控制块(PCB)。
为什么要存在 PCB 呢?
因为 OS 要对进程进行管理。
目前对于进程的理解:进程 = 程序(代码 + 数据) + 内核申请的与该进程对应的数据结构(PCB)。
3、描述进程
由第二点可知,操作系统通过进程控制块 PCB 来描述进程。那么 PCB 是如何来描述的呢?
通过进程属性来描述进程。
task_struct 有以下进程属性保存在进程控制块中,并随进程的状态而变化:
- 标示符: 描述本进程的唯一标示符,用来区别其他进程。
- 状态: 任务状态,退出代码,退出信号等。
- 优先级: 相对于其他进程的优先级。
- 程序计数器(pc): 程序中即将被执行的下一条指令的地址。
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
- 上下文数据: 进程执行时处理器的寄存器中的数据。
- I/O状态信息: 包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
- 记账信息: 可能包括处理器时间总和,使用的时钟总和,时间限制,记账号等。
- 其他信息。
4、组织进程
可以在内核源代码里找到它,所有运行在系统里的进程都以 task_struct 链表的形式存在内核里。
5、查看进程
每个进程都有各自的进程名,但进程名是字符串,比较或者查找起来特别麻烦。在计算机内部,末尾需要给每个进程取一个编号,这个编号就叫 PID ,即进程属性里的标识符。
因此要查看进程,要通过 PID,我们先来了解 PID。
(1)进程属性1.1:PID
(i)通过系统调用获取进程的PID和PPID。
进程 ID(PID)
父进程 ID(PPID)
PPID后续再讲。
通过使用系统调用函数,getpid即可分别获取进程的PID。
对于 getpid,不了解的话可以 man getpid 查看:
我们可以通过一段代码来进行测试。
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
pid_t id = getpid();
while(1)
{
printf("hello bit!, i am a process ,pid:%d\n",id); //返回正在调用进程的进程ID
sleep(1);
}
return 0;
}
当运行该代码生成的可执行程序后,便可循环打印该进程的PID。
运行结果:
注意:pid_t 是无符号整数。
(ii)终止进程
kill 有很多, -9 是杀掉一个进程。类似Windows下打开任务管理器关闭一个进程
(iii)通过ps命令查看该进程的PID
通过 ps 可以查看进程,因此当然可以查看PID,这个我们后续再谈。
(vi)关闭进程重新打开,PID会重新分配
(2)通过系统目录查看进程
在根目录下有一个名为proc的系统文件夹。
文件夹当中包含大量进程信息,其中有些子目录的目录名为数字。
这些数字其实是某一进程的PID,对应文件夹当中记录着对应进程的各种信息。我们若想查看对应PID的进程的进程信息,则查看名字为他的PID的文件夹即可。
比如:如果要进一步查看myproc这个可执行文件的信息,即进程信息,查看 /proc/PID号 文件目录即可。即 ll /proc/PID
(i)操作系统中的 1 号进程
操作系统中的 1 号进程是什么呢?
我们无法用自己号访问
要用超级用户,我们切换到root
我们可以发现,每个进程的cwd 和 exe 都不一样。
- cwd 是 进程运行的目录
- exe 是 可执行程序的绝对路径
那么对于上面的一号进程,他的 cwd 就是进程运行的目录为根目录,他的 exe 就是可执行程序的绝对路径,这个路径就称之我们的操作系统。
(ii)chdir
我们可以通过 chdir 来改变进程的运行目录,工作目录,让他与可执行文件的路径不在一块。
可以看到工作路径确实被改变了。
但是这次更改还是错误的,我们查看一下根目录,会发现并没有proc的可执行文件存在。这是为什么呢?
如上图,这是因为我们没有 w 权限,导致可执行文件并没有被创建成功。因此我们的代码有漏洞,并没有设置if语句判断文件未创建如何
这样我们就能检测出来问题是此目录下并没有w权限,需要换一个目录。这样与前面学习的知识也是联动了。
(3)通过ps命令查看进程
我们在基本概念中演示过 ps 的用法。
命令:ps ajx
a:所有
j:任务
x:把所有的信息全部输出
一般搭配管道使用。ps命令与grep命令搭配使用,即可只显示某一进程的信息。
如:ps ajx | head - l && ps ajx | grep myproc,其中 ps ajx | head - l 是把 ps ajx 输出的信息中的第一行信息(属性列)输出。
(4)进程属性1.2:PPID
进程标识符中除了PID,还有父进程 ID(PPID)。
(i)通过系统调用获取进程的PPID。
通过使用系统调用函数,getppid即可分别获取进程的PPID。
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
while(1)
{
pid_t ppid = getppid();
pid_t id = getpid();
printf("hello bit!, i am a process ,pid: %d,ppid: %d\n",id,ppid);
sleep(1);
}
return 0;
}
当运行该代码生成的可执行程序后,便可循环打印该进程的PPID。
我们可以通过ps命令查看该进程的信息。
(ii)bash
命令行中,执行命令/执行程序,本质上都是 bash 的进程,创建的子进程,由子进程来执行我们的代码。
这个 bash 是命令行解释器。shell 是所有命令行解释器外壳程序的统称。而linux系统里用到的就是 bash。因此所有可执行程序例如myproc都叫做命令行解释器的子进程,由bash创建子进程并执行代码。
每打开一个xshell登入一个账号,就会多一个bash。
6、通过系统调用创建进程 - fork(初识)
(1)系统调用接口 fork 的介绍
平时创建进程一般是通过 ./myproc 运行某个存储在磁盘上的可执行程序来创建。而我们还可以通过系统调用接口/函数来创建进程:
fork是一个系统调用级别的函数,其功能就是创建一个子进程。
我们可以通过 man 指令来查看一下 fork 函数
$ man fork
我们来看一下其中的返回值描述:
一旦创建成功了,他会返回子进程的PID给父进程,返回0给子进程。如果创建失败的话,会把-1返回给父进程,没有子进程会被创建。
fork 给父进程返回子进程的id,而给子进程返回0的根本原因是:
父进程与子进程的比例是1:n 的,这样返回,方便日后父进程对子进程的管理。
代码验证fork如何创建子进程
我们写几个代码来验证fork是如何创建一个子进程的。
代码一:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
printf("I am a process,pid: %d,ppid: %d\n",getpid(),getppid());
pid_t id = fork();
(void)id;//保证id暂时不用,骗过编译器
printf("I am a 分支!pid: %d,ppid: %d\n",getpid(),getppid());
sleep(1);
}
根据结果我们可以看到 printf 打印了两次。这是因为 fork 会创建一个父进程和一个子进程,两个进程都会执行printf。
代码二:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
printf("I am a process,pid: %d,ppid: %d\n",getpid(),getppid());
pid_t id = fork();
if(id > 0)
{
while(1)
{
printf("我是父进程,pid: %d,ppid: %d,ret id: %d\n",getpid(),getppid(),id);
sleep(1);
}
}
else if(id == 0)
{
while(1)
{
printf("我是子进程,pid %d,ppid: %d,ret id: %d\n",getpid(),getppid(),id);
sleep(1);
}
}
}
在上述代码中,我们可以发现,if的两个判断语句居然都成立了。
这是因为我们介绍过 fork 如果创建子进程成功,那么他会给父进程返回子进程的 id,而给子进程返回 0,因此两个进程分别进入两个 if 语句中去执行 while 的循环。
那么父子进程执行对应的if语句,其实他们的代码是共享的,但是数据是各自私有一份的。
我们先理解前半句:代码是共享的。
更具体的说就是:myproc这个二进制文件开始时候是在磁盘中,第一次加载到内存中,在内存中有一份代码+数据,操作系统也会给他创建一个PCB,PCB指向他的代码与数据,然后CPU开始执行进程。当执行代码到fork时候,会创建一个进程,操作系统首先要给他创建PCB。这个子进程与父进程相比较,父进程的代码与数据是从磁盘中加载进来的,但是新创建的子进程并没有代码,只有数据结构,因此系统的设定上就要求子进程的PCB执行父进程的代码,继承父进程的代码
我们再来理解后半句:数据是各自私有一份的
代码修改如下:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
printf("I am a process,pid: %d,ppid: %d\n",getpid(),getppid());
pid_t id = fork();
if(id > 0)
{
while(1)
{
printf("我是父进程,pid: %d,ppid: %d,ret id: %d, gval: %d\n",getpid(),getppid(),id,gval);
sleep(1);
}
}
else if(id == 0)
{
while(1)
{
printf("我是子进程,pid: %d,ppid: %d,ret id: %d,gval: %d\n",getpid(),getppid(),id,gval);
gval++;
sleep(1);
}
}
}
结果如下:
我们可以发现代码相同,但是 gval 却不同。
这是因为进程具有很强的独立性。多个进程之间,运行时,互不影响,即使是父子。
- 代码是只读的
- 数据各自私有一份
也可以创建多个进程
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
#include <vector>
using namespace std;
const int num = 10;
void SubProcessRun()
{
while(true)
{
sleep(5);
cout << "i am sub process, pid:"<< getpid() <<" , ppid: "<<getppid() << std::endl;
}
}
int main()
{
vector<pid_t> allchild;
for(int i = 0;i < num ;i++)
{
pid_t id = fork();
if(id == 0)
{
SubProcessRun();
}
allchild.push_back(id);
}
//父进程
cout << "我的所有孩子是:";
for(auto child: allchild)
{
cout << child <<" ";
}
cout << endl;
sleep(10);
while(true)
{
cout << "我是父进程,pid:"<<getpid() << endl;
sleep(1);
}
return 0;
}
7、进程属性2:进程状态(state)
我们在上面学习了进程属性:PID 与 PPID。接下来我们再来学习另一个进程属性:进程状态。
进程状态查看命令:ps aux / ps axj
一个进程的生命周期可以划分为一组状态,这些状态刻画了整个进程。
进程状态即体现一个进程的生命状态。
操作系统描述的状态,放在任何操作系统中都是这样的:
对于一个进程,刚开始被加载到内存,我们把他称为新建状态。
当他准备好被调度,就称为就绪状态。
接下来放在CPU上运行,就是运行状态。
当CPU执行代码执行到需要io交互时候,比如scanf,键盘并没有输入,代码不往后走,操作会把这个运行进程放在等待队列,称为阻塞状态。当键盘输入后,就再次变成就绪状态,这样就完成了一个三角形的过程。
⚪补充知识
- 时间片:linux / windows民用级别的操作系统,都是分时操作系统,即每个任务都给他分一个时间片的,每一个进程把自己的时间耗尽了,就要从CPU上剥离下来,把另一个进程再放上去,这种系统追求的是调度任务追求公平。
- 并行:多个进程在多个 CPU 下同时运行。(描述的是时刻,任何一个时刻,都可能有多个进程在运行)
- 并发:多个进程在一个 CPU 下采用进程切换的方式,在同一段时间内,让多个进程都得以推进。 意思就是 CPU 执行进程代码,不是把进程代码执行完毕,才开始执行下一个,而是给每一个进程预分配一个时间片,基于时间片,进行调度轮转(单CPU下)。CPU在疯狂的切换各种进程,但是切换和运行的速度非常快,人眼无法观察到。
- 独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰。(这也是 OS 设计进程的一个原则)
- 竞争性:系统进程数目众多,而 CPU 的资源很少,甚至只有一个,所以进程之间是具有竞争属性的。为了更高效的完成任务,更合理的竞争相关资源,便有了优先级。
(1)等待的本质
-
上面图中运行和阻塞中存在一个等待事件,但我们先来讲讲运行,运行状态:
对于任何一个 CPU 来讲,在 CPU 内部,每一个 CPU 一般的操作系统都要给 CPU 提供一个运行队列的东西,我们称他为rqueue,就在操作系统内核中提供的一个 struct runqueue 的结构体,每一个 CPU 都要对应匹配上一个 runqueue 运行队列。(假如不是单CPU,是多个,那每个CPU都要匹配一个 runqueue)。这个 struct runqueue 里包含int nums 等,但最重要的是包含一个 task struct*head。系统中现在加载进来一个进程,创建出来一个 PCB,即 task_struct。当新建一个task_struct,我们要运行时,我们要把对应的 task_struct 链入到运行队列,让 head 指针指向 task_struct。后来随着时间的推移,要调度的进程越来越多,task_struct 也链接的越来越多,所有的进程统一被链入到 runqueue,进程都放在运行队列中。CPU 要执行进程时,只需要找到 runqueue,根据 FIFO 调度算法,选择对应的进程,把进程对应的代码,task_struct拿出来放到某些寄存器里,开始执行。当进程执行完毕后,把 task_struct 链到尾部
只要进程在这个运行队列里,就叫运行状态,可以随时被 CPU 调度。 -
我们再来讲讲 阻塞 ,键盘没有输入,才会阻塞,因此我们先从硬件方面开始:
操作系统需要管理硬件,通过先描述,再组织进行管理。
描述:用 struct device,有几个硬件设备,就有几个 struct device,每个设备都要有自己对应的数据结构来对设备进行统一管理。
组织:通过 next 指针把他们链接起来。
-
再来讲等待:
硬件设备在操作系统里描述结构体对象,是一个类,task_struct 也是一个类,那我们可以在每一种设备的结构体加一个 task_struct * wait _queue。当要执行优先级最高的一个进程时,CPU 拿着这个进程开始执行,执行到 scanf 时候,需要访问键盘,访问硬件,是操作系统帮我们访问的,那么 scanf 底层封装的系统调用,会帮我查,通过 struct device ,通过驱动程序,识别键盘上有没有数据。这个进程无法被调度了,因此操作系统不把进程放到运行队列里,而把进程链入到设备当中(实际上还有文件的存在)即把进程的 PCB 链入到设备的 wait queue 里,后面也会有其他设备通过键盘获取输入,也想获得键盘数据,那么在键盘设备上排队,这种在键盘上排队的状态叫阻塞状态。 。那么这个进程由等待 CPU 运行资源,变成了等待键盘运行资源。键盘输入了之后,操作系统会把进程的阻塞状态改成运行状态,把 PCB链入到运行队列里。
因此运行和阻塞的本质:是让不同的进程,处在不同的队列中。
(2)挂起
阻塞状态发生时,进程不会被调度,在排队,但是他们占着内存,此时会发生内存资源严重不足。操作系统是软件,自己也需要申请内存,加载进程时需要申请 PCB 空间。如果操作系统的内存都申请不下来,就会报错了。此时特定进程的 PCB 留着,代码和数据会换出到磁盘当中。在 CPU 准备执行代码之前,先把磁盘中的数据换入,这个给代码换入换出的区域叫swap分区。这个时候进程属于阻塞状态,进程同时被挂起到了外设上,也属于挂起状态,称为阻塞挂起状态。
本质是用时间换空间。
但操作系统描述的状态,是属于一种整体宏观的描述。所以我们还需要进一步来学习具体一种操作系统,比如 Linux 中的进程状态
(3)Linux 内核源码中的进程状态
为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程可以有几个状态(在 Linux 内核里,进程有时候也叫做任务)。
Linux操作系统的源代码当中对于进程状态有如下定义:
/*
* 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):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
小贴士: 进程的当前状态是保存到自己的进程控制块(PCB)当中的,在Linux操作系统当中也就是保存在task_struct当中的。
在Linux操作系统当中我们可以通过 ps aux 或 ps axj 命令查看进程的状态。
(i)R 状态(running)
一个进程是 R 状态,它一定在 CPU 上面运行吗?
不一定,进程在运行队列中也是 R 状态。如果一个进程想被 CPU 运行,就必须处在 R 状态才行。
R 状态是:可运行状态。(准备好了,可以被调度。)
为什么该进程的状态是 S 状态,不是 R 状态呢?
因为该进程大部分时间都在休眠(sleep(1);)。
因为 printf 是往显示器上打印,涉及到 IO,所以效率比较低,该进程需要等待操作系统把数据刷新到显示器中。
所以,该进程绝大多数时间都在休眠,只有极少数的时间在运行,所以很难看到该进程处在 R 状态。
那如何可以看到该进程是 R 状态呢?
写一个空死循环 while (1) {} 就可以看到了。
(ii)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 无法终止后台进程)
- [./code & ] 指令可以把前台进程变成后台进程
- [kill -9 PID] 指令可以结束后台进程
(iii)T 停止状态(stopped):了解即可
kill 命令:可以向目标进程发信号。
syc@VM-4-17-ubuntu:/home/ubuntu$ 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 停止状态。恢复运行。
在 gdb 里,打断点也会进入 T 停止状态。
(iv)死亡状态-X
死亡状态只是一个返回状态,当一个进程的退出信息被读取后,该进程所申请的资源就会立即被释放,该进程也就不存在了,所以你不会在任务列表当中看到死亡状态(dead)。
补:[ echo $? ]指令表达最近程序退出的退出信息.
(v)Z 僵尸状态(zombie)-- 僵尸进程
要知道,进程退出,一般不是立马就让操作系统回收进程的所有资源。
因为创建进程的目的,是为了让它完成某个任务和工作。当它退出时,我们得知道它把任务完成的怎么样,所以需要知道这个进程是正常还是异常退出的。
如果进程是正常退出的,那么交给进程的任务有没有正常完成呢?
所以,进程退出时,会自动将自己的退出信息,保存到进程的 PCB 中,供 OS 或者父进程来进行读取。
- 进程退出但父进程还没有读取,进程此时就处于僵尸状态。
- 读取成功后,该进程才算是真正的死亡,变成 X 死亡状态。
僵尸进程例子:
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("父进程运行:pid: %d,ppid: %d\n",getpid(),getppid());
pid_t id = fork();
if(id == 0)
{
//子进程
int cnt = 10;
while(cnt)
{
printf("我是子进程,我的pid: %d,ppid: %d,cnt: %d\n",getpid(),getppid(), cnt);
sleep(2);
cnt--;
}
}
else
{
//父进程
while(1)//父进程不退出,死循环
{
printf("我是父进程,我的pid:%d,ppid: %d\n",getpid(),getppid());
sleep(1);
}
}
}
观察子进程状态的变化:子进程退出后,因为父进程没有进行回收,都变成了僵尸状态。
defunct 代表了失效的无效的,表示进程已经退了
僵尸进程的危害:
- 僵尸进程是一种问题,必须得到解决,否则会导致内存泄漏。
- 进程的退出状态必须被维持下去,因为他要告诉关心它的进程(父进程),你交给我的任务,我办的怎么样了。可父进程如果一直不读取,那子进程就会一直处于 Z 状态。
- 维护退出状态本身就是要用数据维护,也属于进程的基本信息,所以保存在 task_struct(PCB) 中,换句话说,Z 状态一直不退出,PCB 就要一直维护。
- 如果一个父进程创建了很多子进程,但就是不回收,会造成内存资源的浪费。因为数据结构要占用内存,定义一个 task_struct(PCB) 结构体变量要在内存的某个位置开辟空间,这就是内存泄漏。
如何避免内存泄漏?
进程等待。
补充:假如 malloc 或者 new 了空间,结束进程了但不释放,会存在内存泄漏吗?
malloc 或者 new 出来的空间属于数据空间,属于代码和数据,因此不会泄露。语言层面的内存泄漏,如果在常驻内存的进程中出现,影响比较大。
(vi)孤儿进程
在Linux当中的进程关系大多数是父子关系。
若子进程先退出而父进程没有对子进程的退出信息进行读取,那么我们称该进程为僵尸进程。
但若是父进程先退出,父进程的父进程bash会把父进程回收,那么将来子进程进入僵尸状态时就没有父进程对其进行处理,此时该子进程就称之为孤儿进程。
若是一直不处理孤儿进程的退出信息,那么孤儿进程就会一直占用资源,此时就会造成内存泄漏。因此,当出现孤儿进程的时候,孤儿进程会被1号init进程领养,此后当孤儿进程进入僵尸状态时就由int进程进行处理回收。
8、进程属性3:进程优先级(priority)
(1)基本概念
- CPU 资源分配的先后顺序,就是指进程的优先级(priority)。比如:排队的本质就是在确认优先级。
- 优先级高的进程有优先执行权利。配置进程优先级对多任务环境下的 Linux 很有用,可以改善系统性能。
- 还可以把进程运行到指定的 CPU 上,这样一来,把不重要的进程安排到某个 CPU,可以大大改善系统整体性能。
优先级 vs 权限,两者有什么区别呢?
权限:决定能不能得到某种资源。
优先级:在资源有限的前提下,确立多个进程中谁先访问资源,谁后访问资源。(已经得到资源,谁先谁后问题)
(2)查看系统进程
在 Linux 或者 Unix 系统中,使用命令 ps -al 查看当前系统进程的信息:
- UID:代表执行者的 ID,通过命令 ll -n / ls -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)查看进程优先级的命令
(i)通过 renice 命令更改已存在进程的 nice,从而调整进程优先级
(ii)通过 top 命令(类似于 Windows 的任务管理器)更改已存在进程的 nice:
- 调整前:
- 调整第一步:输入 top 指令
- 调整第二步:按 r 键,输入进程的 PID,然后回车
- 调整第三步:输入 nice 值,然后回车。
- 调整后
注意:
top -r 按照提示修改即可。OS禁止频繁修改,没有权限的也不能修改。
每次输入 nice 值调整进程优先级,都是默认从 PRI = 80 开始调整的。
输入的 nice 值如果超过 [-20, 19] 这个范围,默认是按照最左/最右范围来取的,如我们图中输入100,实际上只有19。
为什么每次都要默认从 PRI = 80 开始调整呢? 即每次修改都会重置成 80,不用管历史的PRI是多少。
- 有一个基准值,方便调整。
- 在设计上,实现比较简单。
为什么 nice 值的范围是 [-20, 19] 呢?
是一种可控状态,保证了进程的优先级始终在 [60, 99] 这个范围内,保证了 OS 调度器的公平。但公平并不是平均。根据每个进程的特性尽可能公平的去调度它们,而不是指每个进程的调度时间必须完全一样。
9、进程属性4:程序计数器(pc)+内存指针+上下文数据
我们在并发中了解到 CPU 在疯狂的 切换 各种进程,那么进行切换的核心是:进程上下文数据的保存和恢复。
上下文数据:进程在运行的时候,会有很多的临时数据,都在 CPU 的寄存器中保存,这些数据,是进程执行时的瞬时状态信息数据,被称做上下文数据。
我们讲的是PCB的三个进程属性,但我们首先要来讲解一下 CPU 的一些内容,这是为什么呢?后续便会知晓为何。
CPU 处理指令的方式:取指令,更新pc,分析指令,执行指令
- CPU首先指向struct *current指针,指针指向要调度的进程。
- 然后控制器先让 pc 保存程序中即将被执行的一条指令的地址(即第一条)。再让 ir 读取第一条指令,然后 CPU 分析执行第一条指令:
- 再让 pc 更新保存要执行的下一条指令的地址(即第二条),让 ir 读取,然后 CPU 分析执行:(图中的10,20便是上下文数据)
注:pc 读取下一条地址计算方式:当前地址+读进来的指令长度。便会到第二条指令开头。
- 此时执行到这,又来了一个2号进程,他有自己的代码和数据,我们需要切换进程,把一号进程放回调度队列。若是不给进程上下文数据进行保存,执行2号进程时候eax,ebx都会被覆盖。一号进程再切回来时候,一号进程的临时数据都没有记录了,他不知道从哪里开始运行了。
因此不做保存,无法完成调度与切换
进程上下文数据的保存和恢复的方式:
切走时:将相关寄存器的内容保存起来;
切回时:将历史保存的寄存器数据,恢复到寄存器中。
每次切换时,保存完上下文内容,CPU都是全新的,不影响下一个进程使用。在CPU寄存器内部,寄存器只有一套,被多个进程共享使用。
现在我们可以回答开头的问题,”为什么我们讲的是PCB的三个进程属性,但我们首先要来讲解一下 CPU 的一些内容“:
因为 CPU 里的临时数据也是数据,每个进程又都有 task_struct,因此进程的上下文寄存器数据保存到 PCB 里就可以了,所以进程属性里有程序计数器(pc)+内存指针+上下文数据。
如图:
tss_struct中保存的就是上面说到的 eax,ebx等