Linux从0到1------Linux进程概念(上)
- [1. 冯诺依曼体系结构](#1. 冯诺依曼体系结构)
- [2. 操作系统(Operator System)](#2. 操作系统(Operator System))
- [3. 进程](#3. 进程)
-
- [3.1 进程的概念、PCB](#3.1 进程的概念、PCB)
- [3.2 task_struct:Linux中的PCB](#3.2 task_struct:Linux中的PCB)
- [3.3 查看进程](#3.3 查看进程)
-
- [3.3.1 验证进程的存在](#3.3.1 验证进程的存在)
- [3.3.2 获取进程id的接口:getpid和getppid](#3.3.2 获取进程id的接口:getpid和getppid)
- [3.3.3 proc目录](#3.3.3 proc目录)
- [3.3.4 当前工作目录cwd](#3.3.4 当前工作目录cwd)
- [3.4 fork()接口,用代码创建进程](#3.4 fork()接口,用代码创建进程)
-
- [3.4.1 fork()的使用](#3.4.1 fork()的使用)
- [3.4.2 fork()原理讲解------第一讲](#3.4.2 fork()原理讲解——第一讲)
- [4. 进程的状态](#4. 进程的状态)
-
- [4.1 什么是进程状态?](#4.1 什么是进程状态?)
- [4.2 操作系统层面的进程状态](#4.2 操作系统层面的进程状态)
-
- [4.2.1 运行状态](#4.2.1 运行状态)
- [4.2.2 阻塞状态](#4.2.2 阻塞状态)
- [4.2.3 挂起状态](#4.2.3 挂起状态)
- [4.3 Linux中具体的进程状态](#4.3 Linux中具体的进程状态)
-
- [4.3.1 休眠状态和运行状态](#4.3.1 休眠状态和运行状态)
- [4.3.2 前台进程和后台进程](#4.3.2 前台进程和后台进程)
- [4.3.3 浅度睡眠和深度睡眠](#4.3.3 浅度睡眠和深度睡眠)
- [4.3.4 暂停状态和追踪暂停状态](#4.3.4 暂停状态和追踪暂停状态)
- [4.3.5 僵尸进程](#4.3.5 僵尸进程)
- [4.3.6 孤儿进程](#4.3.6 孤儿进程)
1. 冯诺依曼体系结构
我们常见的计算机,如笔记本。我们不常见的计算机,如服务器,大部分都遵守冯诺依曼体系结构。
1. 截至目前,我们所认识的计算机,都是有一个个的硬件组件组成的:
- 输入单元:包括键盘, 鼠标,扫描仪, 写板等;
- 中央处理器(CPU):含有运算器和控制器等;
- 输出单元:显示器,打印机等;
2. 关于冯诺依曼,必须强调几点:
- 这里的存储器指的就是内存;
- 不考虑缓存情况,这里的CPU能且只能对内存进行读写,不能访问外设(输入或输出设备);
- 外设(输入或输出设备)要输入或者输出数据,也只能写入内存或者从内存中读取;
- 一句话,所有设备都只能直接和内存打交道。
3. 深入理解:
1)要区分内存和磁盘,固态硬盘(ssd)的概念。
- 内存指的是内存条,是存储器中的内容;磁盘和固态硬盘存在于输入和输出设备中;
- 内存具有断电即失的特性,而磁盘和硬盘是永久存储设备;
- 内存的处理速度快,磁盘硬盘等外设的处理速度慢。
2)数据层面上,CPU不直接和外设交互,CPU要优先和内存打交互,为什么?
- 我们都知道木桶效应,一个木桶所能乘的水量取决于它最短的那根木头;
- 由于CPU的处理速度极快,基本上是纳秒级别的,远大于磁盘、硬盘等外设,所以如果让CPU直接和外设交互,就相当于降低了计算机整体的速度;
- 内存的处理速度在外设和CPU之间,我们可以让外设先将数据传给内存,然后CPU再从内存中去拿数据,处理数据。这样的话,就可以在外设传输数据给内存的同时,解放CPU,让CPU去处理数据,提高计算机的速度;
- 所以,内存其实可以看作是一个硬件级别的大的缓存。
3)程序在运行之前,必须先加载到内存,为什么?
- 程序=代码+数据,最终都要由CPU来执行处理,CPU需要先读取到这些代码和数据,而CPU只能和内存有"数据(二进制层面)"的交互。
- 而形成的可执行程序,本质上就是一个文件,是存储在磁盘中的。所以程序在运行之前,必须先加载到内存,这是由体系结构决定的。
4)可不可以将各单元的存储设备都换成寄存器?
- 计算机中,几乎所有的设备都有存储数据的能力。像网卡、显卡这种设备,就是有存储能力的,只不过能存储的数据量很小;
- CPU处理速度快的原因,是因为它的存储设备是寄存器,寄存器的处理速度是很快的,但是造价也高,很昂贵;各种外设速度慢的原因,是因为他们的存储设备是磁盘或固态硬盘,这种存储设备的处理速度就较慢;
- 那假如说我是一个土豪,我可不可以造一台外设,存储器,CPU的存储设备全是寄存器的计算机,这样它的速度岂不是快的飞起?我如果图便宜,是不是可以让CPU,存储器的存储设备也换成磁盘?
- 技术上是可行的,但是这样做造成的后果就是,速度超快的计算机价格昂贵,普通人根本用不起;速度慢的计算机,速度慢到根本用不了。
5)冯诺依曼体系结构被普遍使用的本质。
- 冯诺依曼体系结构其实是存储分级的,距离CPU近的设备,它的存储设备就速度快,造价高;离CPU远的设备,它的存储设备就速度慢,造价低;这是一个折中的方案。
- 所以我们选择冯诺依曼的一个最根本的原因,是因为时代选择了它,基于冯诺依曼体系结构的计算机,可以用比较少的钱,做出效率不错的计算机。
4. 对冯诺依曼的理解,不能停留在概念上,要深入到对软件数据流理解上,请解释,从你登录上qq开始和某位朋友聊天开始,数据的流动过程:
- qq也是一个软件,它要运行也是必须加载到内存中的;
- 你想用qq给朋友发送一条信息"你好",你要先从键盘上输入"你好",这条信息会先被加载到内存中。由于"你好"这条信息不单单只有"你好",还有发送时间,发送人,id头像等各种信息需要处理,所以这条信息会再传个CPU,进行处理后再传给内存,最后传给输出设备网卡;
- 你电脑上的信息,通过网络(这里我们还没有接触网络,先把它当作一个黑箱,不用管)发给了你朋友的电脑,此时你朋友的电脑上,网卡作为输入设备,接收了信息。网卡再将其传给内存,内存再传给CPU,CPU进行解析等处理后再传回内存。最后这条信息再传给你朋友电脑的显示器,显示出"你好"这条信息。
2. 操作系统(Operator System)
1. 概念:
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。笼统的理解,操作系统就是一款进行软硬件资源管理的软件,也是电脑开机时,第一个启动的软件,包括:
- 内核(进程管理,内存管理,文件管理,驱动管理)
- 其他程序(例如函数库,shell程序等等)
2. 设计OS的目的:
- 与硬件交互,管理所有的软硬件资源;
- 为用户程序(应用程序)提供一个良好的执行环境。
3. 如何管理:
1)管理的本质是管理结构化的数据:
- 讲一个小故事,大家认为一个大学的管理者究竟是谁?毫无疑问是校长,但是我们在日常生活中,很少见到校长,几乎感觉不到校长的存在,那他又是如何对我们进行管理的?
- 比如现在学校要发奖学金,由校长来做决策决定发给谁。校长首先拿到的东西是学生的历年的学习成绩,是一个一个的数据,谁的学习成绩高就发给谁。并且校长拿到的数据不单单是成绩,肯定是包括学生姓名,学号,学院,班级,成绩,在内的一整套数据。假设现在校长将这些数据存在了一个纸质表格中,那上述的各种属性就是字段,对应的各种信息就是一个一个的数据。
- 但是这样还是不行的,因为学校的人太多了,数据量太大了,校长要在这些数据中找到学习成绩最高的人无疑是一项大海捞针的工作。所以我们的校长进化了,他学会了编程。校长先是创建了一个结构体:
c
struct student{
char name[32]; // 名字
char sex; // 性别
int id[20]; // 学号
int score; // 成绩
//...
struct student *next; // 链表结构
}
- 并且为了查找方便,设计了一个链表结构,以便通过各种接口,实现对数据的增删改查操作,以及排序等各种操作。这样一来,校长对学生的管理,就变成了对各种结构化数据的管理 。以前校长每天整理数据到半夜,没有时间休息,现在校长不但不需要熬夜,还有时间喝下午茶了。所以,管理的本质不是管人,而是管理各种结构化的数据。
2)先描述,后组织:
- 我们将上面的理论运用到计算机世界,现在操作系统想管理各种的硬件,就需要先将这个硬件用相应的高级语言描述出来(C语言),也就是要有一个结构体,里面有这个硬件的各种属性。拿硬盘来说,结构体里需要放的属性就有:已经用了多少空间,剩余多少空间,设备的状态(是正在运行还是故障),等等各种属性。先将这个设备描述出来,再用各种合适的数据结构(链表,栈,二叉树,搜索二叉树等)把这些硬件统统组织起来,这样一个过程我们也叫先描述,后组织,这是一个十分重要的思想。这也是一个建模的过程。
3)先做决策,再执行:
- 我们生活中的任何事情,都可以抽象成两个过程,做决策,和执行。比如中午吃饭,我们要先决定吃什么,再然后才会有买这个动作;就算我们不知道吃什么随便买,那这个随便买也是我们做的决策,买就是执行动作。
- 校长已经通过对成绩排序,知道了应该发给谁奖学金,但是这个发奖学金的动作肯定不可能由校长亲自完成,要不然校长会累死。所以,校长将一份奖学金名单,分发给了各个学院的辅导员,由这些辅导员,去执行发奖学金的动作。在这个过程中,校长是决策者,辅导员是执行者,学生则是被管理者。
- 我们再进入到计算机的世界,操作系统要管理硬件,也肯定不能直接管理,它需要通过各种各样的驱动程序,来完成这个执行操作。在这个过程中,操作系统是决策者,驱动程序是执行者,硬件则是被管理者。至此,我们已经将上图中的下三层讲解完了,希望同学们理解为什么计算机要设计成这样的层状结构。
4)用户不可以直接访问操作系统,更不可以直接去访问硬件:
-
用户之所以不能直接访问操作系统,是因为操作系统不信任任何用户,但是同时,它又要给用户提供服务。大家可以思考一下生活中有没有别人不信任你,但是又要给你提供服务的例子。没错,就是银行。
-
银行并不信任老百姓,所以要有厚厚的玻璃窗口,要有厚厚的金库大门。设想一下,你去银行存钱,可以直接去金库里拿两百块钱,然后自觉的自己给自己账单上划掉两百块钱吗?显然是不现实的,因为总有一些坏人拿了钱也不往自己账单上记录,空手套白狼。
-
所以银行也建立了一整套的管理系统,如图:
最下层是硬件层,有各种基础设施,这些基础设施又由各驱动部门进行管理,当然下达管理命令的是在操作系统层的行长。同时,在操作系统层上还部署了很多的业务,这些业务需要通过各窗口即系统接口来进行办理。但是此时有一个大妈,普通话也说不清楚,也不识字,来银行办理业务,就需要有一个人(大堂经理,或窗口服务人员)来帮助大妈完成操作,将复杂的操作简化成大妈易于理解的操作。这样就降低了普通人在银行办理业务的成本,并且提供了安全、稳定、可靠的业务环境。
-
计算机中也是一样,用户不可以直接去访问操作系统,因为难免会有一些坏用户,有可能人家就是做逆向的,或者恶意去更改你的一些硬件信息,例如本来磁盘好好的,他把磁盘状态设置成异常,然后你也不知道电脑出问题到底出在了哪,这样就很不安全。就像坏人直接越过窗口,在工作人员的电脑上搞来搞去,本来自己账户上没钱设置成有钱,这样肯定是不行的。
-
用户也不能直接去访问底层硬件(除了一些做嵌入式开发的,这里的用户都是指应用产品开发者或普通用户),例如用户越过操作系统,直接操作磁盘,把上面的数据都删删,这和坏人直接去金库里拿钱是一个性质的。
5)外壳程序的必要性:
- 对于普通老百姓而言,大家对计算机的了解其实就和上面例子中的大妈对银行的了解差不多,需要有一个人来帮助自己简化操作。例如Windows系统上,我们双击打开一个文件,我们真正的需求是打开它,而不是双击它,那么双击这个操作其实就被shell外壳程序解析成了打开操作,大大降低了我们普通人使用计算机的成本。又例如Linux系统中的各种命令(
cd/ls/ll
),其实都要被shell外壳程序解析成更加复杂的操作(Linux中的外壳程序叫bash
,shell只是外壳程序的统称,不同的操作系统中外壳程序的名称不同)。
4. 系统调用和库函数:
用户要想访问到操作系统,以及各种硬件,必须通过操作系统提供的各种接口即系统调用,从上到下贯穿整个层状结构。
系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以,有心的开发者可以对部分系统调用进行适度封装,从而形成库,有了库,就很有利于更上层用户或者开发者进行二次开发。例如我们用printf
输出一段语句,它的本质是访问显示器硬件,让语句打印在显示器上。所以简简单单的一句printf
,其实就封装了很多系统调用的接口,帮助我们实现了向显示器写入的这样一个操作。printf
就是C标准库中的一个库函数。
5. 广义和狭义操作系统:
狭义的操作系统只包含操作系统和系统调用。库是在系统调用之上的,是系统调用的封装。所以我们如果想开发一门操作语言,就需要开发对应的库,语法,和编译器。
我们现在接触到的很多不同的操作系统,CentOS,kali,红帽,忍者系统,unbuntu等,他们的Linux内核其实都是一样的,即系统调用这一层往下都是一样的,只是提供的库变一变,外壳程序变一变。
3. 进程
3.1 进程的概念、PCB
1. 基本概念:
- 课本概念:程序的一个执行实例,正在执行的程序等。
- 内核观点:担当分配系统资源(CPU时间,内存)的实体。
- 我的理解:
一个可执行程序,被加载到了内存,就产生了一个进程。那么操作系统想要对进程进行管理,就必须要做一件事,先描述再组织。即先将进程描述出来,再用合适的数据结构组织起来。
2. PCB:
我们首先要知道,操作系统是用C语言写的,那么对应的描述进程,其实就是创建一个struct
结构体,里面存放进程的各种属性。这样一个描述进程用的结构体,统称为PCB(process control block),Linux中的PCB为task_struct
。
c
struct PCB
{
// pid 每一个进程都要有一个唯一的标识符
// 代码地址&&数据地址
// 状态
// 优先级
// 链接字段
struct PCB* next; // 假设PCB设计成了双向链表结构,将PCB链接起来
struct PCB* prev;
}
这里假设PCB是双向链表结构,往后还会学到更多关于PCB的结构。
3. 深入理解:
- 进程 = 可执行程序 + 内核数据结构
利用PCB将进程描述起来之后,每一个PCB都指向自己对应的可执行程序,并且不同的PCB通过双向链表链接起来,形成了有序的数据结构。
操作系统想执行一个可执行程序时,CPU要先找到对应的PCB,然后由这个PCB里存的代码地址和数据地址,找到对应的可执行程序,再将这个可执行程序的数据从内存拿到CPU中进行处理,这个过程也叫调度,执行了一次进程;结束进程也是同理,先由PCB找到对应的可执行程序,终止这个可执行程序,然后将对应的PCB从链表中删除。
往后操作系统想对进程做管理,直接管理PCB即可,将对进程的管理建模成了对链表的增删改查。
大家可能还听过进程排队的概念,拿运行队列来说,其实就是根据优先级,将对应的PCB拿出来放在有序队列中,进行排队等待。
4. 如何理解进程的动态属性?
- 一个可执行程序放在磁盘中,它永远是静态的,不会被执行。
- 一旦这个可执行程序加载到内存,产生了进程,那么就随时有可能被CPU进行执行调度,没有被调度的时候就在内存中等待。一个进程可能不会被一次执行完,可能是执行一段时间停下,然后再执行。这样进程就好像有了生命一般,具有了动态属性。
- 至于进程更多的动态属性理解,我们到进程切换的时候再深入学习。
3.2 task_struct:Linux中的PCB
1. 什么是task_struct?
- 就跟
shell
和bash
的概念一样,PCB是进程描述结构体的统称,task_struct
则是Linux中具体的PCB。
2. task_strcut特殊的双链表数据结构:
- 如果让大家设计一个双链表结构,大家会怎么设计?有一种最简单的设计方法如下,相信也是大家最先想到的:
c
struct task_struct
{
// 进程的各种属性
struct task_struct* next; // 指向后序节点
struct task_struct* prev; // 指向前序节点
}
- 可是
task_struct
中偏偏不这样设计,它是这样设计的:
c
struct dist
{
struct dist* next;
struct dist* prev;
}
struct task_struct
{
// 进程的各种属性
struct dist list;
}
task_struct
中内置了一个dist
双向链表结构体,task_struct
对象彼此指向其实是通过dist
实现的,即每一个task_struct
对象内部的list
形成一个双链表结构。
- 如果我们想通过
list
的地址访问task_struct
对象,需要一个接口,我们可以这样设计:
c
// 这里强转成int是为了方便大家理解
#define curr(list) (struct task_struct*)\
((int)&list-(int)&((struct task_struct*)0->list))
// (int)&((task_struct*)0->list) 记录的时list的偏移量
当我们拿到了对应的list
,直接使用接口curr()
就可以访问到对应task_struct
对象的属性了,例如curr(list)->pid;
访问进程id
。
3. 为什么要这样设计?
- 因为进程可能需要被链入到不止一个结构体中,回顾前面所讲的,进程之间不仅可以彼此组成双链表结构,有时候还要被链入到运行队列等各种队列中。所以这样设计的原因,是因为进程不止需要链入到一个结构体中:
c
struct task_struct
{
// 各种属性...
dlist list; // 系统所有进程所在的列表
dlist queque; // 同时这个进程还可以在队列中
// ...等等结构体
}
3.3 查看进程
3.3.1 验证进程的存在
1. 进程都有哪些?
- 我们运行的所有指令,软件,自己写的程序,最终都是进程。
2. 查看进程:
我们来验证一下,先写一个C程序如下:
c
#include<stdio.h>
#include<unistd.h>
int main()
{
while(1) // 死循环
{
printf("这是一个进程...\n");
sleep(1);
}
return 0;
}
编译形成可执行程序,并运行(这里我可执行程序的名字叫mybin
);在命令行输入ps ajx | head -1 && ps ajx | grep mybin
,查看是否有当前可执行程序的进程存在(ps
指令我们后期会讲,&&
表示两条指令同时运行):
在上图中,我们可以看到进程的一些属性标签,其中pid
就是每一个进程(任务)对应的标识,ppid
是当前进程的父进程标签。运行的指令也是进程,所以我们在图中还看到了grep
的进程(grep
进程在完成自己的任务后就自动结束了,图中之所以还显示出grep
进程,是因为显示它时,它还没结束,显示以后就结束了)。
使用命令kill 进程id
就可以杀死对应的进程,即结束它(这个指令我们之后再详讲):
再用指令ps ajx | head -1 && ps ajx | grep mybin
查看,就只能看到grep
的进程了:
3.3.2 获取进程id的接口:getpid和getppid
1. 一个进程不光有自己的id,还有父进程的id:
pid
(process id
):当前进程的id;ppid
(parents process id
):父进程的id、
2. 使用man指令,可以查看对应的接口:
这里截图只列出最关键的信息,返回值,参数,还有所依赖的库。其中pid_t
类型是有符号整型(int
)。
2. 编写一个C程序:
c
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
int i = 0;
while(i < 100)
{
pid_t pid = getpid();
pid_t ppid = getppid();
printf("这是一个进程... 我的进程id是:%d,我的父进程id是:%d\n", pid, ppid);
sleep(1);
}
return 0;
}
3. 编译并执行可执行程序:
发现问题,每一次pid
是不一样的,而父进程ppid
一直都一样。pid
每次都不一样我们好理解,因为每次给进程分配的内存几乎都不一样,但是ppid
为什么相同?这个父进程是谁?
Linux中创建进程的方式:
- 命令行中直接启动进程------手动启动;
- 通过代码来进行进程创建。
启动进程,本质上就是创建进程,一般是由父进程创建的。
发现父进程为bash
,这里告诉大家一个结论:凡是我们在命令行启动的进程,都是bash
的子进程。
3.3.3 proc目录
1. 验证proc目录的存在即功能:
Linux中有一个动态的目录结构/proc
,里面会存放所有存在的进程,目录的名称就是以这个进程的id命名的。
来看一看这个目录:
2. 一个进程,可以找到自己的可执行程序:
再次将我们的可执行程序mybin
跑起来,此时的进程id
是4799,进入到对应的进程目录下看看都有哪些内容。
还有一个很重要的知识点是当前工作目录cwd
,我们单独拎出来讲解。
3.3.4 当前工作目录cwd
1. 当前工作目录的概念,如何确定:
一个进程会记录它的当前工作目录,这个当前工作目录可以通过/proc
下的进程文件的cwd
查看。默认情况下,进程启动所处的当前路径,就是当前工作目录。
我们来写一段代码验证:
c
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
// cwd/test.txt
FILE *fp = fopen("test.txt", "w");
if(fp == NULL) return 1;
fclose(fp);
return 0;
}
我们都能猜到这段代码的运行结果,就是在当前代码所在目录下创建一个test.txt
文件,那么这个当前工作目录,就是根据cwd
确定的。
2. 当前工作目录可以被更改:
使用函数chdir
更改当前工作目录,上代码,将下面的代码编译成mybin
可执行程序。
c
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
chdir("/home/LHY/test"); // 改变当前工作目录
// cwd/test.txt
FILE *fp = fopen("test.txt", "w");
if(fp == NULL) return 1;
fclose(fp);
// 将这一套死循环的逻辑放开,方便查看进程
while(1)
{
pid_t pid = getpid();
pid_t ppid = getppid();
printf("这是一个进程... 我的进程id是:%d,我的父进程id是:%d\n", pid, ppid);
sleep(1);
}
return 0;
}
编译并运行,然后查看对应的进程管理文件:
发现test.txt
文件已经到了对应的当前工作目录下,之前的路径下将不会产生test.txt
文件:
3.4 fork()接口,用代码创建进程
3.4.1 fork()的使用
1. 如何理解启动和创建进程?
- 启动一个进程,本质上就是系统多一个进程,OS要管理的进程也就多了一个;
- 创建一个进程,就是在内存中申请空间,存放可执行程序和对应的
task_struct
对象,并将task_struct
对象,添加到进程列表中。
2. 使用man指令查看fork()接口手册:
看一下fork()
的返回值描述:
手册中说,fork()
失败返回-1,如果成功了,子进程的pid
会返回给父进程,0会返回给子进程。(那岂不是说,一个函数有两个返回值?)
3. 观察现象:
c
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
printf("我是父进程,我的pid:%d\n", getpid());
// bash它也是用C语言写的,命令行启动的进程,都是bash的子进程,大概估摸着,bash源代码中创建子进程就是用的这个
fork();
while(1)
{
printf("我是一个进程,我的pid:%d,ppid:%d\n", getpid(), getppid()); // 这个函数只调用了一次
sleep(1); // 如果不加这句代码,实验结果不明显,会很奇怪
}
return 0;
}
使用命令行语句,每隔一秒循环监视mybin
进程(先运行后监视):
0
while :; do ps ajx | head -1 && ps ajx | grep mybin | grep -v "grep"; sleep 1; echo "------------------------------------------------------------------"; done
结合目前看到的现象得出结论:只有父进程执行fork()
之前的代码,fork()
之后,父子进程都要执行后续代码。
4. 验证fork()的返回值机制
c
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
printf("我是父进程,我的pid:%d\n", getpid());
// bash它也是用C语言写的,命令行启动的进程,都是bash的子进程,大概估摸着,bash源代码中创建子进程就是用的这个
pid_t value = fork();
while(1)
{
printf("我是一个进程,我的pid:%d,ppid:%d,fork return value:%d\n", getpid(), getppid(), value); // 这个函数只调用了一次
sleep(1); // 如果不加这句代码,实验结果不明显,会很奇怪
}
return 0;
}
子进程的pid
会返回给父进程,0会返回给子进程。
5. 多进程存在的意义:
- 举一个不太恰当的例子(因为多进程一般出现在服务器端,而不是客户端),我们在使用迅雷观看电影时,想让它边下载边播放,那这样一个需求就可以通过多进程实现,一个进程用来下载,一个进程用来播放;
- 我们创建子进程的目的,就是让他们做不一样的事情,执行不一样的代码;
- 实现:可以通过
fork()
的返回值,判断谁是子进程,谁是父进程,然后让他们执行不一样的代码。
c
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
printf("我是父进程,我的pid:%d\n", getpid());
// bash它也是用C语言写的,命令行启动的进程,都是bash的子进程,大概估摸着,bash源代码中创建子进程就是用的这个
pid_t value = fork();
if(value < 0) return 1; // 执行失败直接退出
else if(value == 0)
{
// child
while(1)
{
printf("我是子进程,pid:%d, ppid:%d, ret:%d, 我正在执行下载任务\n", getpid(), getppid(), value);
sleep(1);
}
}
else
{
// parent
while(1)
{
printf("我是父进程,pid:%d, ppid:%d, ret:%d, 我正在执行播放任务\n", getpid(), getppid(), value);
sleep(1);
}
}
return 0;
}
3.4.2 fork()原理讲解------第一讲
问题引入:
fork
干了什么事情?- 为什么
fork
会有两个返回值? - 为什么
fork
的两个返回值,一个会给父进程返回子进程pid
,一个给子进程返回0? fork
之后,父子进程谁先运行?- 如何理解同一个变量会有不同的值?
1. fork干的事情:
以父进程为模版,为子进程创建PCB。所谓以父进程为模版,其实就是将父进程中的大部分属性直接拷贝进子进程(例如当前工作目录这些,并不是拷贝全部属性)。
但是上面例子中fork
创建的子进程,是没有自己的代码和数据的,目前和父进程共享代码和数据。所以fork
之后,父子进程会执行一样的代码。
2. 一个父亲,可以有多个儿子:
现实生活中,一个父亲是可以有多个子女的,而子女只会有一个父亲。所以父进程想要管理子进程,需要拿到子进程的唯一标识,也就是子进程pid
。对于子进程而言,它的父进程是谁永远都是清楚地,因为它只有一个爹。所以fork
给父进程返回了子进程的pid
,而给子进程返回0即可。
3. 父子进程的优先级不确定:
创建完成子进程,只是一个开始。创建完子进程后,系统的所有进程要被调度执行(包括刚刚创建的父进程和子进程)。当父子进程的PCB被创建,并在运行队列中排队等待时,哪个进程的PCB先被选择调度,哪个进程就先执行。这个选择的任务完全由操作系统调度算法器,根据进程PCB中的调度信息共同决定。
4. 如果一个函数已经执行到了return,它的核心工作做完了吗?
fork
就是一个函数,它的内部实现可以先简单的这样理解:
c
pid_t fork()
{
// 找到父进程的PCB对象
// malloc(task_struct)
// 根据父进程PCB,初始化子进程PCB
// 让子进程的PCB,指向父进程的代码和数据
// 让子进程放入调度队列中,和父进程一样去排队
...
return pid; // 返回子进程pid或0
}
一个函数的核心工作,其实在return
之前就已经做完了,参考上面代码,也就是说分流的工作在return
之前已经做完了,子进程执行子进程的return
,父进程执行父进程的return
,所以不是函数有两个返回值,而是return
被执行了两次。
5. 进程之间是有独立性的,无论是什么关系:
- 观察一个现象:杀掉父子进程任意一个,另一个不会受到影响,还会继续跑(
mntpro
是我自己重命名的指令,就是上面的监视命令行语句)。
- 进程之间的独立性体现在:
- 各自有各自的PCB;
- 代码本身是只读的,任何一个可执行程序不可能在执行过程中修改代码;
- 代码不会修改,但是数据是可以改的,所以各个进程会写时拷贝数据,各自私有一份。
1)所谓写时拷贝,大家可以简单的理解成,子进程和父进程有任何一个要修改数据时,就会发生深拷贝;对于不需要修改或没被修改的数据,采用浅拷贝。这样做的目的是提高效率,一股脑的将不需要修改的和需要修改的数据全部拷贝,是一种低效的行为。
2)对应我们上面写的代码,操作系统是怎么做的呢?
return
的本质其实也是写入,它将返回的值写入到了一个变量value
中。value
本身就是在父进程中定义的,也有默认的值,子进程中的value
是继承父进程的。现在相当于子进程要对value
进行修改了,此时子进程就会对value
进行深拷贝。
6. 引出新问题:
c
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main()
{
printf("我是父进程,我的pid:%d\n", getpid());
// bash它也是用C语言写的,命令行启动的进程,都是bash的子进程,大概估摸着,bash源代码中创建子进程就是用的这个
pid_t value = fork();
if(value < 0) return 1; // 执行失败直接退出
else if(value == 0)
{
// child
while(1)
{
printf("我是子进程,pid:%d, ppid:%d, ret:%d, 我正在执行下载任务, &ret: %p\n", getpid(), getppid(), value, &value);
sleep(1);
}
}
else
{
// parent
while(1)
{
printf("我是父进程,pid:%d, ppid:%d, ret:%d, 我正在执行播放任务, &ret: %p\n", getpid(), getppid(), value, &value);
sleep(1);
}
}
return 0;
}
按照我们上面的理解,value
应该是一个深拷贝,那子进程和父进程value
的内存地址肯定是不同的。现在看到的现象却是,两个value
是同一个地址,这又是怎么回事?
我们在这里只能得出一个结论:这个地址,肯定不是物理地址!!!
这些问题我们想要彻底了解清楚,需要学习进程地址空间,这一块我们后面再说,循序渐进。
4. 进程的状态
4.1 什么是进程状态?
1. 课本上的概念,进程转换图:
课本上的概念太宽泛了,进程的状态要看具体的操作系统。接下来我们要学习的事Linux中进程的状态。
2. 进程的状态,就是PCB中的一个字段
假设,我们设计一个操作系统的PCB
中有如下定义:
c
#define NEW 1
#define RUNNING 2
#define BLOCK 3
...
struct PCB
{
...
int status; // 记录进程状态的变量
...
}
其中NEW
就对应创建状态,RUNNING
就对应运行状态,BLOCK
就对应阻塞状态。变量status
就是记录进程状态的变量。
操作系统就可以根据进程中status
的值,来判断该进程应该存在的位置。例如:if(pcb->status == BLOCK)
,PCB放入阻塞队列;if(pcb->status == RUNNING)
,PCB放入运行队列。
4.2 操作系统层面的进程状态
4.2.1 运行状态
每一个CPU在系统层面都会维护一个运行队列,如果你是双CPU的电脑,那它就是有两个运行队列。
在比较老的操作系统中,只有一个进程的PCB放在了CPU中,调用了CPU资源,才被称为运行状态;现在的操作系统中,只要在运行队列中的进程,它的状态都是运行状态。
所以运行状态的含义就变了,可以理解为:我已经准备好了,可以随时被调度。
4.1图中的创建状态,就绪状态,执行状态,其实已经不再有明显的区分了,可以统统归为运行状态。
4.2.2 阻塞状态
我们的代码中,一定或多或少会访问系统中的某些资源。比如:磁盘,键盘,网卡等各种硬件设备(软件我们先不谈,现阶段不好理解)。
我们都使用过C/C++中的scanf
或cin
,它的本质就是从键盘中读取数据。如果我们就是不输入数据,它就会一直卡在那里,可以说是键盘上的数据没有就绪,我们进程要访问的资源没有就绪。如果一个硬件上的数据没有就绪,这个硬件就不具备访问条件,代码就无法向后执行。
操作系统不光要管理进程,还需要管理各种硬件,所以操作系统必须拿到各个设备的各种信息,其中就包括状态信息。谈到管理,又可以用到我们的六字真言,先描述,再组织。操作系统可以为硬件设备创建一个结构体,然后采用链表的结构将他们组织起来。
c
#define DISK 1 // 磁盘
#define KEYBOARD 2 // 键盘
#define NETCARD 3 // 网卡
... // 等各种设备
struct dev
{
int type; // 具体是什么设备
int status; // 状态信息
int datastatus; // 数据状态信息
... // 更多属性
struct dev* next; // 以链表结构进行管理
PCB* wait_queque; // 设备维护的进程等待队列
}
如果一个硬件的数据没有准备好,那么需要这个硬件上数据的进程肯定就不能在运行队列中了,操作系统就会将该进程对应的PCB链入到具体设备的等待队列中,并且将该进程的状态设置为阻塞状态。
进程状态变化的本质:
- 更改PCB中
status
整数变量; - 将PCB链入不同的队列中。
具体的操作系统中肯定更加复杂,这里我们只是简单的讲解一下原理。
4.2.3 挂起状态
如果一个进程当前被阻塞了,注定了这个进程在它所等待的资源没有就绪的时候,该进程是无法被调度的,要被放到某个等待队列中。但是等待队列是要消耗内存资源的,如果此时,恰好OS内的资源已经严重不足了,怎么办?
操作系统有一套自己的解决方案,它会将该阻塞进程对应的代码和数据交换到磁盘中,然后释放内存中等待队列的资源。完成换出操作后,该进程进入挂起状态。
- 将内存数据置换到外设,是针对所有阻塞进程的。
- 不用担心慢的问题,因为这个是必然的,总比操作系统挂掉强的多。
- 磁盘中会有一个swap分区,专门用于和操作系统中的数据进行置换。
- 当挂起进程被OS调度时,曾经被置换出去的进程的代码和数据,需要重新被加载进来。
一般不建议将swap分区设置的太大,一般设置成跟内存一样大已经了不起了。因为如果将swap分区设置的太大,就很难将swap分区填满,操作系统在内存资源不足时就容易过度依赖swap分区,频繁跟外设交互,导致速度变慢。所以我们将swap设置的小一点,也是倒逼操作系统,能不交换就别交换。设置swap需要找到一个平衡点,不能太大也不能太小。
4.3 Linux中具体的进程状态
看看Linux源代码(内核版本2.6.32
):
c
/*
* 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 */
"X (dead)", /* 16 */
"Z (zombie)", /* 32 */
};
- R运行状态(running): 并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里。
- S睡眠状态(sleeping): 意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠(interruptible sleep))。
- D磁盘休眠状态(disk sleep)有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束。
- T停止状态(stopped): 可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。
- X死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态(瞬时状态,看不到,没什么可讲的)。
大家可以发现,这些状态和我们之前讲的操作系统层面的运行状态还是有很大差别的,这也是为什么我们在最开始就强调,进程状态要看具体的操作系统。
4.3.1 休眠状态和运行状态
1. 模拟休眠状态:
c
#include<stdio.h>
int main()
{
while(1)
{
printf("hello Linux!\n");
}
return 0;
}
编译并运行,同时查看进程状态:
可以发现,mybin
进程的STAT
状态为S
,这个状态也可以对应阻塞状态。可是为什么该进程不是运行状态?
因为我们使用printf
实际上是在访问显示器,可是这个和显示器交互的过程太慢了,可能百分之99的时间我们都在等待显示器资源就绪,只有百分之1的时间程序在跑,所以我们几乎只能看到该进程的状态是S
,休眠状态(阻塞)。
2. 模拟运行状态:
c
#include<stdio.h>
int main()
{
while(1) {}
return 0;
}
编译并运行,查看进程状态:
当前进程没有访问外设,本质是在不断使用CPU资源,所以只要调度这个进程,这个进程就一定是R
,运行状态。
4.3.2 前台进程和后台进程
1. 如何区分前台进程和后台进程
细心的大家可能发现了,4.3.1中,我们在查看进程状态时,状态的前面会带一个+
号,这又是什么?
- 带
+
的是前台进程; - 不带
+
的是后台进程。
2. 前台进程和后台进程的区别
我们一旦启动一个前台进程,命令行将不再接受我们输入的任何指令。可是一般的情况是,我们想在下载电影的时候刷刷抖音,这就需要把下载电影的进程挂到后台,不要让他影响我们输入指令,干一些其他的事情。
c
#include<stdio.h>
#include<unistd.h>
int main()
{
while(1)
{
printf("hello Linux!\n");
sleep(1);
}
return 0;
}
使用&
可以让一个进程在后台运行:
发现ctrl+c
无法终止后台进程,后台进程在运行时,命令行仍可以接受指令(上图中输入了指令ls
),想要结束后台进程,可以使用指令kill -9
加进程id
。
4.3.3 浅度睡眠和深度睡眠
1. 区别:
- S(sleeping):休眠状态,浅度睡眠,可以被终止,浅度睡眠会对外部信号做出响应;
- D(disk sleep):专门为磁盘设计的,也是休眠状态,深度睡眠,不可以被杀掉,OS也杀不掉(有了D状态的进程,关机也关不了)。
2. 理解:
假设一种场景,当前操作系统内有一个进程,它的任务是将1GB的数据写入磁盘,可能写入失败。该进程在将数据传给磁盘后,磁盘就开始写入数据的工作,这个进程就要等待磁盘响应,进入S阻塞状态。此时发生了一些状况,操作系统内的资源已经严重不足了,急需释放一部分内存资源以防操作系统当机。操作系统巡逻时发现了处于阻塞状态的该进程,直接就把它干掉了。恰好磁盘写入数据时发生了错误,想要将错误信息报告给该进程时,发现找不到它了,这时它手里拿的那一部分数据就很尴尬,即写不进去,拿着又占用资源,所以磁盘就直接把它删掉了,造成数据丢失。
一般的数据还好,如果是1000个用户的存钱记录呢?把这些数据弄丢麻烦就大了。所以为了避免这种情况的发生,操作系统内设计了一个D状态,又称为深度睡眠状态,处于D状态的进程无法被终止,即使发生了内存资源不足,需要释放的情况,也杀不掉,要杀只能杀那些S状态的进程。
一般情况下,如果让用户观察到D状态的进程,那这个操作系统也离当机不远了。
4.3.4 暂停状态和追踪暂停状态
1. 使用kill -l查看都有哪些信号:
重点关注三个,发送9号信号可以杀死进程;19号信号可以暂停进程;18号信号可以让暂停的进程继续执行。
c
#include<stdio.h>
#include<unistd.h>
int main()
{
while(1)
{
printf("hello Linux!\n");
sleep(1);
}
return 0;
}
细节:进程被暂停后,会自动变成后台进程(没有+
);暂停的进程被重新启动后,还是后台进程。
2. 为什么需要暂停进程(T状态)?
- 在进程访问软件资源的时候,可能暂时不让进程进行访问,就将进程设置为STOP。
3. 追踪暂停的场景:
- 我们在调试程序时,会设置断点,不断地去启动和暂停程序,本质上就是不断暂停和执行进程,就像对进程进行追踪。
4. T进程和t进程是阻塞状态吗?
- 是的,就拿3中
gdb
的例子来说,mybin
进程在暂停时,本质上是在等待gdb
进程的指令,mybin
进程的PCB其实已经被挂到gdb
进程PCB的等待队列中了。 - 由此可见操作系统中有非常多的等待队列,不光各种硬件有,各种软件和进程,都有自己的等待队列。
- 而进程在等待的这个状态,都可以归为阻塞状态。
4.3.5 僵尸进程
1. 什么是僵尸进程?
- 一个进程终止了,就好比一个人死在了大马路上,我们首先要采集这个人(进程)的死亡原因等各种信息,保护现场,再把人抬走。所以当一个进程终止时,这个进程的相关信息并不会被释放,而是存储在PCB里。这些信息需要被相关父进程或操作系统知道,这个进程才可以进入X死亡状态,释放它的资源。一个进程在终止到信息被采集的这段时间窗口内,这个进程就叫做僵尸进程。
2. 细节:
-
进程 = 内核PCB+进程的代码和数据,都要占用内存空间。进程退出的核心工作之一,就是将PCB和自己的代码和数据释放掉。
-
我们创建一个进程,一定是因为我要完成某种任务,我需要知道进程把任务完成的怎么样。所以进程在退出时,要有一些退出信息,表明自己把任务完成的怎么样。
-
我们在写C/C++程序时,
main
函数的返回结果都写的是return 0
,返回0就表示程序正确执行,完成了任务。返回其他值就是发生了错误。这个返回值结果,在程序退出后,就保存在进程的PCB里。 -
进程把任务完成的怎么样这个信息,一般要传给该进程的父进程。当一个进程在退出时,退出信息会由OS写入当前退出进程的PCB中,可以允许进程的代码和数据空间被释放,但是不能允许进程的PCB被立即释放。
-
总结一下就是:一个进程在退出时,要让OS或父进程,读取退出进程PCB中的退出信息,得知子进程退出的原因(关于如何读取之后再谈)。
-
如果进程退出了,即代码和数据已经释放了,但是退出信息还没有被父进程或OS读取,OS必须维护这个退出进程的PCB结构,此时,这个进程就处于Z状态,僵尸状态。
-
父进程或OS读取退出信息之后,该进程PCB中的状态先是被改成X,然后才会释放该进程的PCB。
3. 僵尸进程的危害:
- 如果一个进程进入了Z状态,但是父进程就是不回收它,PCB就要一直存在下去。此时,会发生操作系统层面的内存泄漏。
4. 小实验:模拟僵尸进程
c
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
pid_t id = fork();
if(id < 0) return 1; // 创建失败直接返回
else if(id == 0)
{
// 子进程
int cnt = 5; // 只跑五秒
while(cnt)
{
printf("I am child, run times: %d\n", cnt--);
sleep(1);
}
exit(0); // 退出一个进程
}
else{
// 父进程
while(1)
{
printf("I am father, running any time!\n");
sleep(1);
}
// 一些回收操作,后面说
}
return 0;
}
编译并运行,监控进程:
可以发现,在子进程退出,而父进程还未退出,并且未接受子进程的退出信息时,子进程变为僵尸进程。
4.3.6 孤儿进程
1. 引入
- 刚才我们是先结束的子进程,如果我们先让父进程结束呢?父进程是否会变为僵尸进程?子进程的僵尸状态又由谁来维护?
2. 观察现象
c
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
pid_t id = fork();
if(id < 0) return 1; // 创建失败直接返回
else if(id == 0)
{
// 子进程
while(1)
{
printf("I am child, running any time!\n");
sleep(1);
}
}
else{
// 父进程
int cnt = 5;
while(cnt)
{
printf("I am father, run time: %d\n", cnt--);
sleep(1);
}
exit(0);
}
return 0;
}
编译并运行,监视进程:
可以发现,父进程退出后,并没有进入僵尸状态,而是直接被回收了。子进程也并没有直接退出,而是父亲变成了1号进程,挂后台运行。
3. 解释
- 父进程之所以直接被回收了,是因为他的父进程是
bash
(之前我们说过,在命令行中启动的程序父进程都是bash
),他的退出信息被bash
接收了,所以可以直接释放。 - 进程之间只有父子关系,没有爷孙关系,所以子进程的父进程终止后,子进程不会被
bash
回收,而是被1号进程领养。这里1号进程其实就是操作系统,可以通过top
命令查看。
4. 孤儿进程的概念:
- 这种父进程提前退出,自己被1号进程领养的进程,称为孤儿进程。