~~~~
前言
本文将会介绍Linux操作系统进程相关的概念。
推荐书籍:《深入理解计算机系统》
冯诺依曼体系结构(重要)
冯诺依曼体系结构计算机的基本原理:
计算机就是为了完成指定的数据处理,而通过指令按指定流程完成指定功能,指令的合集就是一段程序。计算机就是按照指令执行流程完成对指定数据的处理。
总览
存储器:指内存,特点是掉电易失。
外存:除内存之外的具有永久性存储能力的介质,如磁盘、u盘,光盘等。
外设:相对于内存和CPU(包括运算器、控制器、寄存器等)而言,其他设备都是外围设备。
输入设备:典型的输入设备就是鼠标和键盘了。
输出设备:如显示器。
输入输出设备:如磁盘、网卡等。
设备名 | 用途 | 速度 | 量级 |
---|---|---|---|
CPU | 进行计算 | 很快 | 纳秒级 |
存储器(内存) | 临时存储 | 较快 | 微秒级 |
外设(外围设备) | 永久存储 | 较慢 | 毫秒级、秒级 |
CPU工作方式
CPU是很笨的,只会只能被动接受别人(程序)给它的指令并进行执行和计算,但是CPU执行速度非常非常快。
CPU为什么能认识别人的指令呢?其实是因为CPU存在着指令集,只要在指令集内的指令,CPU都认识。
什么是指令集?
包括精简指令集和复杂指令集。
指令集是CPU中用来计算和控制计算机系统的一套指令的集合。
就像一个人想要听懂其他语言,如法语,就需要先学习法语的语法,记忆法语的单词,形成自己的‘指令集’,然后才能够认识法语文章,听懂法语内容。而CPU在设计之初就已经在硬件层面先天有了自己的一套指令集,编译器对我们的源代码进行编译并形成二进制可执行程序,这个可执行程序内就包含了一条条能够被CPU认识的指令和数据。
CPU受到指令周期的控制而一直读取指令,一刻也不会停下来,就像人一出生就被时间、社会推着跑,小学、初中、高中、大学、工作、结婚…停不下来。
CPU为什么只和内存打交道(数据交换)?
木桶效应:
即木桶能装多少的水不是由最长的木板决定的,而是由最短的木板决定的。CPU处理速度非常非常快,外设速度通常非常慢,CPU直接和外设打交道会严重拖慢CPU的速度。所以CPU只和速度较快的内存(理解为大大的缓存)打交道,虽然也慢但是相比外设速度来说,已经可以接受了。
CPU直接和内存打交道(进行数据交换),目的是为了提高效率。
在数据层面的结论
- CPU直接和内存打交道,不和外设打交道;
- 所有的外设,数据需要载入时,只能先载入到内存中,CPU处理完的数据再经过内存写入到外设中;
程序运行为什么要加载到内存?
程序运行时要加载到内存使我们很早就知道的概念,但是在学习操作系统之前也仅限于知道,而不晓得为什么。
在了解了计算机的体系结构之后,我们就能够知道:这是冯诺依曼体系机构的规定
CPU读取指令和处理的数据都直接从内存中读取,而程序在外设磁盘中,CPU无法直接访问磁盘,不能从磁盘读取程序指令,需要程序先加载到内存,然后CPU读取指令并执行程序。。
进一步理解计算机体系结构
各种软件必须依托于冯诺依曼体系结构设计,即硬件决定软件。软件的底层也天然的依托于体系结构,万变不离其宗(体系结构)。
冯诺依曼体系结构是底层设计,相当于骨架,软硬件依据计算机体系结构就像肌肉组织依托于骨架。
栗子理解:
登陆同一聊天程序(qq)时,A向B发送一条信息"hello B!“到B在自己电脑上看到信息为止,数据流是如何进行流动的?
A通过外设键盘输入信息"hello B”,数据由键盘发送到A电脑的内存中,数据被CPU处理之后再回到到A电脑的内存中,A按下发送键后,数据从内存发送到了A电脑的网卡和显示器内,A电脑的网卡又经过网络传输发送给B电脑的网卡内,再从B电脑网卡发送到B电脑的内存中,数据在经B电脑CPU处理再发送给内存,然后再由内存发送到B显示器上被B所看见。
A向B发送一个文件时,数据流又是如何流动的?
文件事先被保存在外设磁盘中,文件先由磁盘流到A电脑内存,在流到CPU被处理之后流回内存,然后再由内存发送到A电脑的网卡内,经过网络传输,A电脑网卡把文件发送给了B电脑网卡,B电脑网卡再把文件流到内存中,然后CPU对文件处理之后再流回内存,然后再由内存发送到B电脑的外设磁盘内进行保存,发送到外设显示器内进行数据显示。
操作系统(operator system)(重要)
CPU快速但机械的执行一条条的指令并处理数据,那么CPU要读取哪一个进程的哪一条指令?从哪里读取?处理完的数据如何刷新哪里(磁盘、显示器)?
关于如何进行决策的问题,这就需要操作系统进行统一管理了。
什么是操作系统
操作系统核心功能是进行软硬件资源管理的软件。
包括进程管理、文件系统、内存管理、驱动管理等。
主要学习进程管理、文件系统、一点点内存管理、了解驱动管理。
我们主要学计算机生命周期中使用时间最久的部分(进程、文件),操作系统非常复杂,源码达到了千万行级别,一个人穷极一生去学习也不可能学得完,也不需要这样学。我们要了解和学习的是操作系统的整体结构和主要的部分。
为什么要有操作系统
操作系统为什么需要进行管理?
对下,通过合理的管理软硬件资源(手段),对上,为用户提供良好的(稳定的、安全的、高效的)执行环境(目的)。
操作系统怎样进行管理的
先描述,在组织。
例子:在学校中,校长是管理者,学生是被管理者。但是校长并不会直接对具体的某一个学生进行直接管理,那么校长是如何知道学生的具体的情况呢?又是如何进行管理的呢?
校长虽然没有直接和所有学生直接接触和交流,但是校方想要掌握的学生的信息都被持续收集了起来,比如各科期末成绩,基本信息、正在读哪个年级等,虽然管理者和被管理者没有进行直接接触,但是管理者通过对被管理者的各种数据的管理就可以把被管理者给管理起来。
什么是管理呢?就是面对重大事件时具有决策的权力。而管理者想要做出适当的决策是需要知道被管理者各种数据信息作为支撑的,没有数据,管理者对被管理者什么都不了解,也就无法进行有效的管理。
管理的本质是对数据进行管理。
如果只有管理者和被管理者,那么被管理者的数据是如何被拿到的呢?所以还有一个执行者角色(如各种驱动程序),负责执行管理者做的决策,搜集被管理者的各种数据。
用户:使用者;
管理者(操作系统):做决策(依据被管理者的数据);
执行者(驱动程序,每个硬件都会有对应的驱动程序,否则硬件如鼠标将无法正常使用):执行管理者的命令、和被管理者直接接触,持续获取对应的数据;
被管理者(硬件、软件)
在对真实场景中的对象进行管理时,我们通常都需要先考虑用结构体或类把我们需要记录的对象信息抽象归纳为对应的变量,就是描述起来,然后再考虑使用哪一种或哪几种数据结构把一个个的对象结构体给有效的组织起来。
管理的进一步理解
现在校长(操作系统)作为管理者通过执行者(驱动程序)不断搜集的学生的各种数据(被管理者)对学生进行管理。可是一个学校可能会有几千人,几万人,学生产生的各种数据也就会非常多。校长直接面对海量的数据表格进行管理不太现实,需要进行分门别类的处理。学生(被管理者)的数据虽然是众多的,但是对于整个学生群体来说,数据主要包含了学生的姓名、学号、班级、身份证号、电话号码等等。
操作系统面对被管理者海量的数据时,为了有效的对被管理者进行管理,这些数据本身也需要被管理起来。
类似于学生产生的数据,操作系统会先把学生的共同特征进行描述,建立学生结构体类型struct student
,这个结构体类型里面就包含了一个学生的基本信息的集合:
struct student{
name;
number;
class;
id;
telphone;
...
struct student* next;
};
这样,一个学生的所有信息用一个结构体保存和管理,结构体内有一个自身类型的结构体指针,于是对于多个学生结构体,便可以以链表的形式把所有学生结构体链接起来。这样操作系统想找到某一个学生的信息时,只需要遍历这个结构体链表即可。这样对学生数据的管理就转化为了对学生结构体链表进行管理(增删查改)了。
// 以链表结构组织学生信息
struct student *head = (struct student*)malloc(sizeof(struct student));
struct student *stu1 = (struct student*)malloc(sizeof(struct student));
stu1->name = xx;
stu1->number = xxx;
...
head->next = stu1;
先描述:即把学生数据(对象)共同特征抽取出来,进行封装(用结构体表示)
在组织:对学生结构体struct student
采取适当的数据结构组织起来,如链表、队列、二叉树。
操作系统对外设磁盘(硬件)的数据本身进行管理,方式也是先描述:
// 定义设备结构体类型
struct dev{
type;类型
status;//状态
...
};
在组织:
// 以链表形式组织
struct list_node{
struct dev devs;
struct list* next;
}
list_node* head_dev = (listNode*)malloc(sizeof(listNode));
init_list(head_dev);//初始化链表头结点
list_node* disk_dev = (listNode*)malloc(sizeof(listNode));
insert(head_dev, disk_dev);//节点插入链表
list_node* keyboard_dev = (listNode*)malloc(sizeof(listNode));
insert(head_dev, keyboard_dev);
上述代码描述只是一个形象化的描述,是为了表示操作系统对数据是怎样进行管理的。
操作系统作为一款软件,既能管理各种硬件,也能管理各种软件。就像人一样,人可以管理桌椅板凳、也可以管理人本身。管理软件的方式也是对软件的数据本身进行管理,依然要经历先描述在组织的过程。
系统调用和库函数
操作系统最为计算机的管理者,位置实在是太重要了,操作系统只要出了一点问题,计算机可能就会无法正常使用。所以,操作系统不允许其他任何人、任何程序直接访问和修改操作系统内核,而是通过提过系统接口的形式经过操作系统来进行各种操作,如读文件、写磁盘、写显示器等。
操作系统不相信除自己之外的任何人,但是操作系统又必须给上层用户程序提供各种服务,操作系统不允许上层用户程序直接访问操作系统内的数据,而是对外提供了一系列的系统接口函数,用户程序通过这些系统接口函数来间接访问(让操作系统自己访问数据,结果返回给用户程序)操作系统内的数据。
Linux操作系统是由纯C语言写的,所以其系统调用都是以C语言接口函数(使用C语言的编码方式)的形式为上层用户提供的。
操作系统为上层用户程序提供的系统调用对于我们来说并不好用,但是我们用户程序只能使用操作系统提供的难用的众多系统调用。于是在系统调用之上又出现了一层对系统调用进行再封装的程序,隐去了难用的系统调用,为我们提供方便的操作,如shell外壳、编程语言官方提供的丰富的库函数、以及一些其他的命令。
相比于直接使用系统调用,用户操作接口的出现大大方便了我们用户的日常使用,如编程开发、快捷命令、管理操作等。
系统调用把应用程序的请求传输给系统内核执行;
利用系统调用能够得到操作系统提供的多种服务,用户只需要将自己的请求以及数据通过系统调用接口传递给内核,内核中完成对应的设备访问过程,最终返回结果;
系统调用是操作系统向上层提供的用于访问内核特定功能的接口;
系统调用给用户屏蔽了设备访问的细节;
系统调用保护了一些只能在内核模式执行的操作指令,系统向上层提供系统调用接口用于访问内核服务或功能的很大原因也是因为这样可以最大限度的保护内核的稳定运行;
系统调用的运行过程是在内核态完成的,操作系统并不允许用户直接访问内核,也就是说用户运行态并不满足访问内核的权限。
操作系统体系结构
进程
进程概念(重要)
初步理解进程概念
课本概念:
一个运行起来的程序就是进程;在内存中的程序就是进程;进程和程序相比具有动态属性;进程是程序运行的一个实例;虽然并没有说错,但也只是只是了,读了之后没啥感觉,也不知道有啥用。
进一步理解进程概念
想要理解进程,就不能局限于进程本身,我们需要深入到进程运行的背后,理解进程运行背后的一整套逻辑链:
进程是操作系统分配各种资源(CPU、内存、磁盘等)的实体。
内存中有许多已加载的程序,这么多的程序,操作系统如何进行管理呢?如何为这些进程分配CPU等资源呢?
进程控制块(PCB)
操作系统不会直接对某一个进程进行直接管理,而是把所有进程的数据抽象出公共属性,作为一个结构体,称之为进程控制块(PCB),通过对PCB的管理间接的对所有进程进行管理,进程的所有信息都被保存在了PCB(process control block)中。
在Linux中描述进程的结构体是task_struct
,是Linux内核的一种数据结构,记录进程的各种信息。
task_struct
内都有什么:
- 标示符: 描述进程的唯一标示符,用来区别其他进程。
- 状态: 任务状态,退出代码,退出信号等。
- 优先级: 相对于其他进程的优先级。
- 程序计数器: 程序中即将被执行的下一条指令的地址。
- 内存指针: 包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针。
- 上下文数据: 进程执行时处理器的寄存器中的数据。
- I/O状态信息: 包括I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
- 记账信息: 包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
- 其他信息。
对进程要有先描述,再组织的认识:
**进程 **= 内核数据结构(task_struct) + 进程对应的磁盘代码;
为什么会有PCB(struct task_struct)结构体呢?
操作系统是对软硬件资源进行管理的**,管理的本质是对数据进行管理**。而众多数据杂乱无章,数据太多时,数据本身就是一个负担,还需要对收集的数据本身进行管理,借助面向对象的思想,把进程相关的数据进行抽象,归纳出公共属性,以结构体的形式进行描述。
管中窥豹,可见一斑,见见Linux(2.6版本)的PCB源码:
对PCB,即struct task_struct的定义非常复杂,虽然我们现在还看不懂,但是我们知道Linux操作系统把进程的各种信息都定义了一个结构体进行保存,一个这样的结构体对象就可以代表一个进程。对进程的管理就转变为了对进程PCB的管理。
见见进程(见见猪跑):
#include<stdio.h>
#include<unistd.h>
int main(){
while(1){
printf("我是一个进程!\n");
sleep(1);
}
return 0;
}
运行图:
查看进程信息 ps axj
显示Linux系统中所有进程信息:
ps axj 或ps aux
我们来看看刚才进程的运行状态:
head -1表示保留前一行
;
grep myproc1表示在进程信息中搜索出现myproc1的进程并显示
;
杀掉进程
kill -9 进程名
见见与进程相关的系统调用
返回当前进程的id : pid_t getpid()
头文件是unistd.h和sys/types.h
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main(){
while(1){
printf("我是一个进程! 我的pid是 %d\n", getpid());
sleep(1);
}
return 0;
}
另一种查看进程的方式:ls /proc
(了解)
在**/proc目录下有着与进程PID一一对应的目录**,这些目录名就是进程名,目录里存放的就是进程所有的信息。你没看错,在Linux下,一个进程被当成了文件看待。
/proc/21593目录下的exe指向了我的家目录下的源程序路径。
这个目录里面有很多进程相关的信息,以文件或目录的形式存在,我们虽然不知道这些是哪些信息,但是其中一个信息exe
我们是一看就知道的,它指向了进程的可执行程序的路径(即说进程是从磁盘的那个路径下的哪个可执行程序加载而来的)。
当一个进程被创建时,Linux操作系统除了会为进程创建对应的PCB,还会在/proc
目录下生成一个与进程PID同名的目录,如果进程结束运行或意外终止,除了内存中的进程控制块、加载的代码和数据被清理掉外,这个目录也会自动被操作系统删除。
进程与程序的区别与联系
在进程被创建后,如果磁盘上对应的可执行程序被删掉,那么进程还能够正常运行吗?
可执行程序被删掉之后,进程在/proc对应目录下的exe
文件所指的路径变为了闪烁的红色,并在结尾用deleted提示我们当前进程在磁盘上的可执行程序被删掉了。
答案是,能。进程被创建后,可执行程序的代码和数据都已经被加载到了内存,进程运行时CPU直接读取进程在内存对应的指令即可。进程是程序的一个副本,或者说一个实例。
正式认识进程
获取进程id
getpid() 获取当前进程id
getppid() 获取当前进程的父进程id
提示:当系统调用或库函数对应头文件不知道时,使用man # command
查看#号文档的command文档。
进程id如何变化的
多次重复 运行再结束命令行上的进程的过程,我们会发现进程的pid在发生变化(这是理所应当的,因为进程每次创建,数据都会重新加载到内存),但是其父进程id始终没有变化:
我们发现,在命令行上直接运行的进程,其父进程都是bash,即都是以bash的子进程的方式运行的。这样的好处是就算进程运行出1
现异常了,也不会影响到父进程bash(实习生出现问题了不影响公司)。
编译并运行./myproc3
假如myproc因为空指针访问而崩溃了,看看bash会不会崩溃:
杀掉bash例子:命令行不能正常响应
创建子进程fork
所需头文件:unistd.h
pid_t fork(void);
fork创建子进程之后,父进程和子进程共享fork之后的所有代码。
fork之后,如果子进程创建成功,向父进程返回子进程PID,向子进程返回0;如果创建失败返回-1并设置错误码。
图片-》
#include<stdio.h>
#include<unistd.h>
int main(){
pid_t id = fork();
(void)id;
while(1){
printf("pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
}
return 0;
}
即,一个函数(fork)竟然有了两个返回值?你是否觉得十分神奇与不可理解?究竟是怎么回事呢?为什么会有两个返回值呢?
难道我们C语言功底出现问题了?一个函数不是只能有一个返回值吗?
其实这并不是C语言语法本身能够解释的问题,也不代表我们C语言功底不行,这只是因为这是系统调用函数fork。
以前C语言中同一段代码的if和else只能执行一个,即程序只会进入一个if或者else执行代码(死循环),但是现实情况是fork之后,即进入if执行了死循环,也进入了else执行了死循环,非常的难以理解对吧?
这就是涉及到多进程概念了。
即fork之后的代码被父进程和子进程共享;有父进程+子进程两个进程执行后续所有代码;
根据fork返回值的不同,让父进程和子进程分别执行后续代码的一部分,这就是并发的概念。
注意:以后会见到很多类似fork的与多个进程相关的代码,所以进程必须要深入学习和理解,这是以后接触到频率很高(最高)的部分,也是基础中的基础。
进程状态
想要知道正在运行的进程是什么意思,我们需要先知道进程的不同状态。
初步理解进程状态概念
操作系统的课本中一般会提到的进程概念有:
运行、新建、就绪、挂起、阻塞、等待、停止、挂机、死亡等等。
- 在普遍的操作系统层面理解上面的进程各种状态概念
进程这么多的状态,本质都是为了满足不同的运行场景。
运行状态概念(重点)
对于一台计算机来说,一般只有一个CPU,同一时刻也只能执行一个进程的指令和数据。但是内存中有那么多的进程,到底先让CPU执行这一个进程的指令,还是先执行那一个进程的指令呢?操作系统是怎样做(调度)的呢?
通过一个运行队列,把一个个进程对应的进程控制块PCB放入这个队列,然后CPU从队列头开始,按照固定时间间隔通过PCB找到对应的进程,依次执行对应进程的指令和数据。
运行队列runqueuue保存着内存中一个个进程的PCB,
运行队列形象描述:
//PCB形象描述
struct task_struct{
pid;
ppid;
state;
//进程其他对应属性
struct task_struct*next;
};
//运行队列形象描述
struct runqueue{
task_struct*head;
task_struct*tail;
...//其他属性
};
处于运行队列的进程PCB,表示进程处于运行状态,但是并不一定正在被CPU读取和执行指令。
因为运行队列中有很多进程PCB,CPU以时间片轮转的方式依次执行处于运行队列的进程对应的指令,所以运行状态的进程也可能在等待时间片轮转,并没有被CPU执行指令。
与投简历过程做类比:
网上面试流程一般是:应聘者制作自己的简历,把电子简历投递到意向公司,然后公司对简历进行一定的筛选,然后对通过的简历整理排序,安排面试时间。
其中应聘者自己就相当于加载到内存的进程,制作的简历就相当于描述进程各种信息的进程控制块PCB,简历中包含了应聘者的基本信息和技术栈、项目经历、实习经历等,PCB中类似的包含了进程pid(进程身份证)等。应聘者把简历投递到意向公司的邮箱,相当于进程的PCB加入到了运行队列中。公司看中你的简历了,想约你的面试就通过简历上你的联系方式通知你参加面试。相当于,操作系统选择某个进程进入CPU执行了,就通过运行队列里该进程PCB里的指针找到进程的指令和数据在内存中位置,并进行读取和执行。
小结:
- 一个CPU(有限的资源)对应一个运行队列。
- 让进程进入队列,本质就是经该进程的task_struct结构体放入运行队列中。
- 进程PCB只要在运行队列中,那么就是运行状态(R),而不是只有进程在运行时才是运行状态(state)。
- 进程不止会等待或占用CPU资源,其他资源如磁盘、显示器、网卡等也会随时被进程占用或访问。
- 进程所谓的各种状态,不要把它给完全抽象的理解,它在PCB中的具体实现,其实就是一个整型变量,简单和具体。
阻塞状态概念(重点)
进程除了会等待(占用)CPU资源,也会等待外设资源(磁盘、网卡、显示器等),因为外设资源相比于进程来说数量也很少。
一个进程访问外设时,其他进程也想访问外设时应该怎么办呢?
操作系统会分别创建这些外设资源建立各自的等待队列,多个进程需要访问外设,且外设(如磁盘)正在被占用,操作系统就把进程的PCB从运行队列放入了对应外设(磁盘)的等待队列(PCB内进程状态信息由运行状态变成阻塞状态)。这样CPU就不用因为进程需要等待外设资源时自己也随着等待了,转而去执行运行队列中的其他进程,CPU在运行队列里依次高速的执行每一个进程。等到外设(磁盘)资源空闲时,操作系统再把进程的PCB从等待队列放入运行队列,等待CPU继续执行该进程。
- 所谓的进程不同的状态,本质就是进程在不同的队列中,等待某种资源(运行队列也是,在等待CPU资源)。
所谓进程等待某种资源,我们宏观的感受就是这个软件为什么这么卡?下载进度为什么一直不动?
挂起状态概念(重点)
处于阻塞状态的进程一直在等待相关资源空闲,意味着阻塞状态的进程短期内不会被CPU立即调度,也就意味着该进程将会等待一段时间不会被CPU执行。
如果内存中进程太多导致内存空间不足时,操作系统为了保证计算机能够继续正常运行,会把处于阻塞状态且相当长的一段时间内未被调度的进程的代码和数据从内存写入到磁盘的特定区域进行保存。把该进程的代码和数据所占的空间释放了出来供其他进程使用,但是该进程的PCB仍然在内存中保存,这就是挂起状态。直到该进程等待的资源空闲且内存有足够的空间时,操作系统再把该进程对应的代码和数据再次加载到内存,对应PCB从挂起队列放入阻塞队列,再由阻塞队列放入运行队列,进程状态也从挂起状态变成阻塞状态,在从阻塞状态变成运行状态。
一个进程,阻塞不一定挂起,但挂起一定已经阻塞了。
这种将进程的相关数据加载或写入到磁盘的过程,称之为内存数据的换入换出。
操作系统的调度对每一个进程来说都是公平的(相对公平),不可能一直让同一个进程一直占用资源,而让其他进程一直等待。
对进程来说,我的阻塞或挂起都是为了其他进程的运行,那么我运行的时候,其他进程也必然会为了我进行相应的阻塞或挂起。有行有等,有张有弛,这就是操作系统调度的规则。
挂起状态不止会与阻塞状态进行组合,只要不是运行状态,都可能会被操作系统变成对应状态的挂起状态。如就绪挂起,进程刚加载到内存还没有运行就由于内存空间不足又被换出到磁盘,在内存只剩下对应的PCB了。
什么是计算密集型?什么是IO密集型?
简单来说,计算密集型,主要进行各种计算,更多的是需求CPU资源,所以更多(更可能)的处于运行状态。如a++,一直计算。
IO密集型,就是更多的进行数据从内存到外设(磁盘)的换入和换出,更多的处于阻塞状态(睡眠S,暂停T)。如printf,一直写入显示器。
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 */
"X (dead)", /* 16 */
"Z (zombie)", /* 32 */
};
操作系统书上的概念是对进程各种状态的抽象,是一种概念便于我们进行理解和学习而已。
具体的一款操作系统比如Linux操作系统,进程并没有所谓的新建状态、就绪状态、阻塞状态、挂起状态等,倒是有运行状态®,有睡眠状态(S)、停止状态(T)和追踪停止状态(T)、深度睡眠状态(D),有僵尸状态(Z)和死亡状态(X)等。
R运行状态(running) : 并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列
里。
S睡眠状态(sleeping): 意味着进程在等待事件完成(这里的睡眠也叫做可中断睡眠(interruptible sleep))。
D磁盘休眠状态(Disk sleep):也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的
进程通常会等待IO的结束。
T停止状态(stopped): 可以通过发送 SIGSTOP 信号给进程来停止(T)进程,这个被暂停的进程可
以通过发送 SIGCONT 信号让进程继续运行。
X死亡状态(dead):这个状态只是一个返回状态,在任务列表里看不到这个状态 。
运行状态(R)(running)
见见运行状态(R):while(1)死循环
睡眠状态(S)(浅度睡眠)(阻塞状态的一种)
见见睡眠状态(S):printf
printf向显示器打印数据,会访问外设,而CPU速度相比于显示器是非常快的,所以每次访问外设显示器时可能100次有99次显示器都在被占用,CPU执行进程的一条写入指令后,需要等待显示器空闲的时间相比执行的时间很长,进程便进入了显示器的阻塞队列。CPU则继续执行其他进程,直等到显示器空闲,CPU才再次执行该进程的下一条写入指令。
所以用户查看进程的状态时总是看到该进程处于阻塞状态(S),而几乎看不到该进程处于运行状态(R),待写入的数据从CPU执行写入指令开始到被显示器显示的期间,CPU执行指令花费的时间占比可能只有1%(R状态),而其他99%的时间都是数据被写入显示器的时间(S状态)。
当使用ps axj | head -1 && ps axj | grep myproc
查询进程myproc的运行状态时,也会查询到grep命令本身。
暂停状态(T)(stopped)
暂停状态是阻塞状态的一种。
了解命令:
kill -19
进程暂停运行
kill -18
进程继续运行
同时我们注意到Linux的进程状态后面默认带着+
号,而当我们暂停进程之后,进程状态变成了T
而不是T+
,即+
号被去掉了。我们恢复进程的运行后,进程状态变成了R
,而不是R+
。结论:带着+
号的进程是命令行前台进程,不带+
号的是命令行后台进程。
前台进程和后台进程:
前台进程:运行在shell命令行上,在进程运行期间不接受用户输入的大部分命令和操作,除非等前台进程自己退出或用户ctrl+c强制结束,进程状态后会有一个+号
进行标识。
图片-》
后台进程:一个进程运行起来默认就是前台进程,在使用kill -19
暂停之后,进程就变成了后台进程,进程状态不带+号
,然后再使用kill -18
继续进程的运行,我们发现该进程运行时我们在命令行输入的命令可以被命令行执行,按ctrl +c也无法终止该进程了。此时需要输入命令 kill -9杀掉该进程。
挂起状态(用户看不到)
Linux操作系统中用户无法直接看到挂起状态,操作系统没有暴露出该状态给用户。即操作系统不希望用户知道一个进程此时是不是被挂起了,也没有必要知道是否被挂起了,这是操作系统应该要考虑的事,和用户没关系。
追踪的停止状态(t)(tracing stop)
被追踪的停止状态。
对程序myproc4进行调试:
使用gdb等调试工具对进程调试运行时,进程就进入了tracing stop状态,也是一种暂停状态,以便调试器可以读取和修改进程的状态和内存。
死亡状态 (X)
进程处于死亡状态时,操作系统会延迟的对该进程进行回收,但是这个延迟对于我们用户来说也是很快的,所以我们无法查看到死亡状态。
深度睡眠状态(D)(了解,不是重点,但是理解了对操作系统本身很重要)
先说浅度睡眠,就是上文所说的睡眠状态(S)和停止状态(T/t)。浅度睡眠是可以被通过kill -9被操作系统杀死的。
再来看深度睡眠(Disk sleep):
深度睡眠出现的场景:进程在等待进行大量的I/O操作的完成,进程会进入D状态,在大量I/O操作完成后才会切换到其他状态。因为进程的数据通过操作系统向磁盘I/O时,进程需要关注I/O操作是成功了还是失败了,如果失败需要向用户报告I/O失败或者重新向磁盘I/O,需要保证进程一直存在,不能被操作系统为了内存管理而被随便kill -9掉,防止出现磁盘数据写入失败,相关进程又被操作系统意外kill -9掉,用户的数据没被保存,丢了。
处于深度睡眠的进程无法被操作系统通过信号唤醒和杀死,操作系统只能等待该进程自己醒来。
当一台机器的进程出现大量的D状态时,想要解决,就只能重启机器,甚至需要进行断电。
僵尸状态(zombie)
僵尸进程:子进程先于父进程退出,父进程没有对子进程的退出进行处理,因此子进程会保存自己的退出信息而无法释放所有资源成为僵尸进程导致资源泄露。
见见僵尸进程:
例子:子进程运行5秒后退出,父进程一直不退出,也不对子进程进行回收,看看父进程和子进程分别是什么什么状态?
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main(){
pid_t id = fork();
if(id == 0){
// child
int cnt = 5;
while(cnt){
printf("我是子进程! pid: %d, %d秒后退出\n", getpid(), cnt);
sleep(1);
cnt--;
}
}
else{
while(1){
// parent nothing to do
printf("我是父进程! pid: %d\n", getpid());
sleep(1);
}
}
return 0;
}
为什么要有僵尸状态?(先初步理解,深入理解在进程控制部分引入)
我们知道,进程被创建出来是为了完成某一种或几种任务的。进程最后完成任务的情况是怎样的,是完成并成功了、是完成但失败了、还是异常终止了?操作系统或者其父进程需要知道完成的结果,所以进程终止时不能直接释放进程对应的资源,而是保存一段时间,以便让父进程或操作系统读取,之后再被父进程或操作系统释放其对应的资源。
在进程终止之后一直到进程的退出信息被父进程或操作系统读取完成的时间段内,都是僵尸状态,之后该进程才由僵尸状态变为死亡状态,各种相关的资源不久就依次被操作系统回收。
僵尸进程无法被kill -9杀掉:
因为僵尸进程已经死亡了,你无法杀死一个已经死掉的进程。
死亡状态的进程也无法被kill -9杀掉,原理同上。
僵尸进程危害:
僵尸进程可能存在内存泄漏的问题:
僵尸进程的退出信息被保存在了PCB中,一个进程退出了,代码和数据可以被直接释放,但是对应的其他资源(理解为PCB,资源这个词太抽象了,让人不好理解)不会被直接释放,而是等待父进程或操作系统读取退出信息并由父进程或操作系统回收该进程。如果父进程一只不回收僵尸进程,那么僵尸进程的PCB就会一直保存在内存中,PCB相比代码和数据不大,但是也是一个复杂的大型结构体变量,一直在内存中占用空间,内存中可用空间就变少了,这不就是内存泄漏吗。
进程的退出状态必须被维持下去,因为他要告诉关心它的进程(父进程),你交给我的任务,我办的怎
么样了。可父进程如果一直不读取,那么子进程就一直处于Z状态。
维护退出状态本身就是要用数据维护,也属于进程基本信息,所以保存在task_struct(PCB)中,换句话
说, Z状态一直不退出, PCB一直都要维护。
如果一个父进程创建了很多子进程,就是不回收,就会造成内存资源的浪费。因为数据结构对象本身就要占用内存,想想C语言中定义一个结构体变量(对象),是要在内存的某个位置进行开辟空间的。
如何避免内存泄漏呢?
孤儿进程
孤儿进程:父进程先于子进程退出,子进程成为孤儿进程,运行在后台,父进程成为1号进程(而孤儿进程的退出,会被1号进程负责任的进行处理,因此不会成为僵尸进程)。
孤儿进程的产生一般都会带有目的性,比如我们需要一个程序运行在后台,或者不想一个进程退出后成为僵尸进程。
什么是孤儿进程?
一个父进程fork了一个子进程之后,先于子进程退出了。
本来父进程要读取子进程的退出信息,对子进程进行资源(PCB)的回收的,但是现在子进程还在正常运行,但父进程先退出了。
例子:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main(){
pid_t id = fork();
if(id == 0){
// child
while(1){
printf("我是子进程! pid: %d, ppid: %d\n", getpid(), getppid()); sleep(1);
}
}
else{
int cnt = 5;
while(cnt){
printf("我是父进程! pid: %d, %d秒后退出!\n", getpid(), cnt);
sleep(1);
cnt--;
}
}
return 0;
}
运行截图:
现象1:父进程退出后,子进程继续运行,且ppid变成了1;
现象2:父进程退出后,子进程进程状态的+号
被取消了,变成了一个后台进程。
父进程被kill -9后,子进程的PPID有原先父进程的PID变成了1,那么编号为1的是哪一个进程呢?原来是操作系统!
像这样父进程退出,子进程继续运行的子进程将会被操作系统接管(领养),称之为孤儿进程。
并且,父进程先于子进程退出将会导致子进程由前台进程变为后台进程,只能通过kill -9杀掉。
进程运行中会出现父进程先退出的情况吗?
父进程退出后也会被它的父进程进行回收操作。
- 答案一定是存在父进程先退出的情况;
- 结果就是,子进程被操作系统领养,子进程ppid变为1,该进程就被称之为孤儿进程;
- 为什么操作系统要领养孤儿进程?因为操作系统要进行管理,如果操作系统不领养孤儿进程,那么孤儿进程就退出后进入僵尸状态就没有人来回收了(父进程先退出了肯定不考虑,操作系统也没有接收)。
- 如果是前台进程创建的子进程,如果前台进程先退出,那么子进程在被操作系统领养变成孤儿进程的同时,该子进程还会自动变成后台进程。
进程优先级(了解)
优先级其实就是进程怎样进行排队的问题。
什么叫做优先级
CPU调度中进程被调度执行的顺序。
与权限区分:考虑的是能不能做,有没有资格做的问题;
优先级:能做有资格做,考虑的是怎样排队确定谁先谁后的问题;
为什么存在优先级
僧多粥少,狼多肉少。
进程多,资源少(CPU、各种外设等)。
Linux优先级特点 – 很快
怎样表示优先级
优先级本质就是PCB内的整型数字。
用两个数字共同构成了优先级:pri(priority)、ni(nice)
最终优先级 == 老优先级(默认80) + ni;
优先级默认值:80;
查看优先级
ps -la
使用top设置进程优先级ni:r
Linux支持进程运行中对优先级的调整,而调整的策略就是通过更改nice值来实现的。
具体过程:
运行myproc1,查看myproc1的优先级。
sudo top
进入资源管理器
按r
后上方出现输入提示,首先输入要修改的进程pid
之后设置要修改的优先级ni为-100
查看修改后的myproc1的优先级
发现即使优先级ni想要设置为-100,但是操作系统限制ni最低为-20。
下文也会发现优先级ni想要设置为100,但是操作系统限制ni最高为19。
设置下面是设置优先级ni为100:
优先级数字越小,表示进程的优先级越高,类似于排名。
nice 取值范围:[-20, 19];
进程优先级取值默认就是80,老的优先级固定是80,与nice加和之后得到最终的优先级,范围是[60, 99];
比如原优先级是90,需要的新优先级是70,所以设置nice为-10,而不是-20。
虽然用户可以对进程的优先级进行设置,但是操作系统不会随便让用户设置不合理的优先级,这与操作系统的调有关,如果允许用户为一个进程设置很高的优先级,优先级越高的进程越先执行,意味着其抢占CPU资源的能力越高。可能会引起操作系统的调度失衡。
虽然操作系统不能保证进程调度是绝对公平的,但是要保证进程调度是相对公平的,操作系统还是能做到的。
进程具有竞争性:
操作系统中进程很多,而CPU等资源是少量的,所以进程之间具有竞争属性。为了高效完成任务,合理竞争相关资源,就有了进程优先级的概念。
进程具有独立性:
例子:子进程崩溃,退出之后变成僵尸状态等待父进程回收,而父进程并不受子进程崩溃的影响,正常运行。
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main(){
pid_t id = fork();
if(id == 0){
// child
printf("我是子进程! pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
int * p = NULL;
*p = 10;
}
else{
while(1){
printf("我是父进程! pid: %d\n", getpid());
sleep(1);
}
}
return 0;
}
并行:多个进程在多个CPU下分别同时运行。
并发:同一时间间隔内,多个任务(或进程、线程)在CPU中同时执行。
进程切换
CPU是计算机中的一个硬件,CPU内有除了有运算器和控制器,还有着大量的存储容量小但高速存储部件,通常用于暂存指令、数据,称之为寄存器。CPU中的寄存器包括向用户暴露出来的以供用户程序使用,还包括对用户隐藏的供操作系统使用。
寄存器的工作方式:取指令、分析指令、执行指令三种。指令指的就是由操作系统自身提供或用户编写的代码并被编译器翻译而成的二进制。
为什么需要进程切换?
因为每个进程的运行时间是不同的,有的进程一瞬间就运行完了,而有的进程需要长时间运行不会很快结束甚至是我们写的死循环。进程运行时间有长有短,CPU也不可能逮着一个进程一直运行而让其他进程一直等待,而是每个进程执行一段时间,再转而执行下一个进程,循环往复。
一个进程执行时,对应的指令和数据会被CPU读取,在CPU内部的寄存器保存着当前进程执行产生的各种临时数据,当CPU时间片轮转,需要执行下一个进程时,CPU内寄存器包含的进程各种临时数据需要被保存起来,供下一次程序执行继续当前的进度。
PCB内是保存CPU的寄存器内各种临时数据(上下文)的地方。当CPU时间片轮转,进行进程切换时,操作系统会先把该进程在CPU内运行产生的各种临时数据保存在特定的位置(上下文保护),当再次轮到相同的进程执行时,操作系统再把保存的上一次产生的各种临时数据一一恢复到CPU对应寄存器中(上下文恢复),然后进程就可以继续依据上一次的数据继续运行。
即进程在切换时,要进行进程的上下文保护;
进程恢复运行时,要进行上下文的恢复;
上下文指的是CPU内寄存器内的数据,而不是指CPU内的寄存器。
在任何时刻,CPU中寄存器内的数据,看起来是在大家都能看到的寄存器上,但是寄存器里的数据,只属于当前运行的进程。即寄存器硬件被所有进程共享,而寄存器里的数据则是每个进程各自私有的,称之为上下文数据。
上下文的保存和恢复是非常快的。
环境变量
什么是环境变量
操作系统在启动命令行解释器的时候为我们预先设置好的供不同场景下使用的一系列的全局变量(其实就是一个字符串),称之为环境变量。
env:查看所有环境变量。
应用场景
PATH
指定命令的搜索路径。
为什么我们的可执行程序执行需要带./路径
而系统命令工具就不需要带/路径
直接使用名字就能够执行呢?
首先我们要先明白一点,程序执行是需要代码和数据被加载到内存的。操作系统想要执行程序,需要先找到这个程序在哪里吧。系统命令都放在了系统目录/usr/bin
目录下,而/usr/bin
在环境变量PATH中,再执行系统命令时操作系统会自动在对应搜索路径下搜索,不需要指定路径。而我们的程序默认在自己创建的路径下,程序所在目录不在PATH中,且程序本身也不再系统目录/usr/bin
下,所以操作系统找不到我们的程序,也就没办法执行,只有指定了路径时才能被找到在被执行。
基本概念
系统命令为什么运行时不需要带./+路径
呢?而我们自己写的可执行程序在运行的时候就需要带上./+路径
?
栗子:我们的程序不带./
时运行出错:
要执行一个程序,首先要先找到这个程序。
我们如果想让我们写的程序也像系统指令一样,不需要./
就能直接执行,有哪些方法呢?
让我们写的程序也像系统指令一样可以直接执行
方法1:
把我们的程序拷贝到系统默认目录下,之后再执行我们的程序就不用在带./
了。
但是并不推荐这么做,因为我们所写的程序没有经过系统性的测试,不知道会出现什么问题。
什么是安装?起始就是把文件从一个地方拷贝到另一个地方。
为什么在系统默认路径下的程序就能被直接找到呢?直接使用程序名就能执行呢?
这是因为Linux系统中存在一个环境变量PATH,PATH中记录了一系列的路径,以:
分隔。
系统指令(ls、pwd、touch等)都在/usr/bin
目录下,而/usr/bin
又被PATH记录,这样执行系统指令时,操作系统就通过PATH在/usr/bin
目录下找到指令对应的程序,所以指令不带./
就可以执行了。
echo命令
使用echo $查看、获取环境变量PATH
echo $环境变量名
方法2:
export命令 导入环境变量
export错误示范:
所以,如果环境变量PATH被清空或覆盖了,那么系统指令就都不能直接执行了:
不要担心PATH被清空了系统命令都用不了怎么办,因为当前设置的PATH是内存级的,只需要重新登陆系统PATH就会恢复。
export如果直接设置环境变量PATH为对应路径,会把就PATH覆盖掉,正确做法是把需要添加的新路径追加到旧PATH中:
export PATH=$PATH:对应路径
which指令是如何找到系统其他指令的位置呢?通过上面的例子我们就知道了,which就是通过环境变量PATH来进行查找的。这也解释了为什么当我们的程序所在目录在加入了PATH中后,which就能找到我们的程序所在位置了。
我么在写C/C++的时候我们对定义变量是十分熟悉的,但是你知道吗,我们的命令行,也就是bash也是可以定义变量的:
例子:
但是我们定义的环境变量使用echo命令能找到,但是使用env命令(显示系统所有环境变量)却找不到:
因为我们定义的变量默认是本地变量,属于局部变量。
尝试使用getenv()
函数进行获取:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#define USER "USER"
#define MY_ENV "myval"
int main(){
const char* myval = getenv(MY_ENV);
printf("%s not found\n", MY_ENV);
return -1;
printf("%s=%s\n", MY_ENV, myval);
return 0;
}
意料之中的结果,找不到本地变量myval
把myval导入(export)环境变量:
export myval
此时使用env就可以找到我们定义的myval了:
再次运行程序myproc1,getenv()也能找到我们定义的myval了:
环境变量的全局属性的体现
bash就是一个系统进程,mproc1通过fork的方式以bash子进程的方式运行。环境变量是操作系统为bash进程定义的,并且可以被子进程继承下去(这就是环境变量具有全局属性的原因),这样环境变量就可以使用在不同的场景中,如方便寻找路径、进行身份验证等。
本地变量是什么呢?其实本地变量就是在当前进程(就是bash)定义的变量,所以也只在当前进程有效。
可以类比为C语言中的全局变量可以被所有代码块访问,而局部变量定义在函数体或其他局部域中,只能在局部访问。
栗子:ls我们知道是显示指定目录下的文件和目录,不指定时默认显示当前路径下文件和目录。那么ls进程能够知道用户当前所在路径的原因就是因为,ls进程以bash子进程的方式运行,继承了bash所有的环境变量,而环境变量中的PWD就保存了用户当前所在的路径,ls通过getenv获取PWD就知道了用户所在的路径。
简单实现pwd命令:
#include<stdio.h>
#include<stdlib.h>
#define PWD "PWD"
int main(){
printf("%s\n", getenv(PWD));
return 0;
}
运行图片:
hostname 显示主机名
原理也是通过getenv获取环境变量HOSTNAME,然后显示在屏幕上。
set命令
既显示环境变量,又显示本地定义的shell变量。
unset命令
清除环境变量
看看windows系统的环境变量
我们普通用户在登陆bash时,.bash_profile和.bashrc
就会执行加载环境变量的操作。
什么是环境变量?这种由操作系统提供的,具有全局属性的变量。
环境变量不只有PATH,每一种环境变量都有其特定的功能。
HOME环境变量,记录用户的家目录,所以我们cd ~时的~
就被解释为HOME
HOSTNAME:主机名
LOGNAME:当前登录的用户名
HISTSIZE:记录的历史命令的最大条数
环境变量PWD会随着用户所在目录的变化而变化,这样用户使用pwd命令时,通过PWD环境变量就可以知道用户所在目录了。
例子 :su或su-之后USER前后的变化
当前用户是weihe
,环境变量USER的值是weihe
。
使用su切换为root用户身份(不会重新登陆,依然是原先用户的环境)
su
使用su -
切换为root并重新登陆(登录环境是root的)
su -
使用getenv库函数获取指定环境变量USER
环境变量USER记录着当前使用Linux的用户名
头文件 stdlib.h
char *getenv(const char *name);
一个关于权限的例子:
我们在程序内部获取当前Linux用户并进行判断,只有是root用户才能进行操作,其他用户提示权限不足:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#define USER "USER"
int main(){
const char* who = getenv("USER");
if(strcmp(who, "root") == 0){ // 执行操作之前,先进行身份验证
printf("USER=%s\n",who);
printf("USER=%s\n",who);
printf("USER=%s\n",who);
printf("USER=%s\n",who);
}
else{
printf("权限不足!\n");
}
return 0;
}
图片
su -
sudo
系统命令执行时也会进行一系列的身份验证,其中重要的一环就是获取当前用户身份并进行判断,如果没有权限命令就不会被执行。
环境变量的组织方式(字符指针数组)
C语言main函数的命令行参数
char* argv[] 命令行参数表
#include<stdio.h>
int main(int argc, char* argv[]){
return 0;
}
这里的char* argv[]
是char*类型的指针数组,指向了一个个的字符串。接收的是操作系统或父进程传入的参数。
先来看看char* argv[]
里有什么:
#include<stdio.h>
int main(int argc, char* argv[]){
for(int i = 0; i < argc; i++){
printf("argc[%d]=%s\n", i, argv[i]);
}
return 0;
}
命令行参数的意义:根据不同的命令行参数选项,让同一个程序执行不同的命令。
#include<stdio.h>
#include<string.h>
//ls -a -b -c -d
int main(int argc, char* argv[]){
if(argc != 2){
printf("Usage:\n\t%s\n", "[-a/-b/-c/-ab/-ac/-bc/-abc]");
return 1;
}
if(strcmp(argv[1], "-a") == 0){
printf("功能a!\n");
}
if(strcmp(argv[1], "-b") == 0){
printf("功能b!\n");
}
if(strcmp(argv[1], "-c") == 0){
printf("功能c!\n");
}
if(strcmp(argv[1], "-ab") == 0){
printf("功能ab!\n");
}
if(strcmp(argv[1], "-ac") == 0){
printf("功能ac!\n");
}
if(strcmp(argv[1], "-bc") == 0){
printf("功能bc!\n");
}
return 0;
}
windows下命令行参数
关机栗子:
设置在360秒之后关闭计算机
取消关闭计算机
char* env[] 环境变量表
main函数形参除了argc、argv之外还有一个char* env[]
,这个env结构与char* argv[]
一样,env内部的指针储存着环境变量字符串的起始地址。
每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以'\0'
为结尾的字符串。
#include<stdio.h>
int main(int argc, char* argv[], char* env[]){
for(int i = 0; env[i]; i++){
printf("env[%d]=%s\n", i, env[i]);
}
return 0;
}
environ获取环境变量表
environ
是C语言库函数unistd.h
中定义的二级字符指针,指向了字符指针数组,该字符指针数组的指针依次指向了环境变量对应的字符串。
#include<unistd.h>
extren char** environ;
使用environ打印环境变量:
#include<stdio.h>
#include<unistd.h>
int main(){
extern char** environ;
for(int i = 0; environ[i]; i++){
printf("environ[%d]=%s\n", i, environ[i]);
}
return 0;
}
echo问题(待整理!!!!!!)
程序地址空间
回顾C/C++地址空间
这里的地址空间是内存吗?
答案是:不是内存。
不是内存?那这是什么呢?这是虚拟地址空间
见见现象fork 修改了但没完全修改
两个进程修改同一个全局变量global_val
#include<stdio.h>
#include<unistd.h>
int global_val = 100;
int main(){
pid_t id = fork();
if(id < 0){
printf("fork error\n");
return 1;
}
else if(id == 0){
int cnt = 6;
while(1){
printf("我是子进程! pid: %d, ppid: %d
| global_val: %d, &global_val: %p\n",
getpid(), getppid(), global_val, &global_val);
sleep(1);
if(cnt == 0){
global_val = 200;
printf("我是子进程! global_val已经被我修改了!!!!!\n");
}
cnt--;
}
}
else{
while(1){
printf("我是父进程! pid: %d, ppid: %d
| global_val: %d, &global_val: %p\n",
getpid(), getppid(), global_val, &global_val);
sleep(2);
}
}
return 0;
}
这里子进程把global_val的值修改但是地址没有变,首先这个地址一定不是物理地址,我们在C/C++语言中学习到的地址或者指针也不是物理地址。这个地址叫做虚拟地址(线性地址、逻辑地址)。
感性理解虚拟地址空间
进程会认为自己独占系统资源,彼此之间不知道其他进程的存在,我们知道实际上不是这样的。
这体现在什么地方呢?就是进程地址空间。
32位下操作系统给每个进程都许诺了4GB的内存空间(蓝图),每当进程需要申请空间时都会找操作系统要(申请空间),并且一个进程多数时候要申请空间一般是一点一点的申请,少量的申请空间,不会一下子把4GB空间一下子申请完(即使想申请很大的空间如2GB甚至更大,操作系统也不会给),所以每个进程虽然有着4GB虚拟空间的“大饼”,但是实际上每个进程只是申请了自己需要的少量空间。这样每个进程都被“4GB”内存空间的“大饼”给忽悠着继续运行,操作系统也悠哉的为每个进程分配实际的空间,相安无事。
我么知道了进程地址空间其实就是操作系统给进程画的“饼”。但是内存中同时运行的进程非常多,操作系统需要给每个进程“画饼”(进程地址空间),那么“饼”(进程地址空间)多了之后,操作系统对“饼”(进程地址空间)本身也需要进程统一管理,不然万一画错了饼,进程可就不干了(出问题了)。
那么依据“先描述在组织”的思想,我们把进程地址空间这张“饼”本身给管理起来,就要抽象出公共属性,建立结构体struct。
虚拟地址空间的本质,是内核的一种数据结构mm_struct
虚拟地址空间按照字节为单位进行划分;
32位下,CPU可寻址2^32个地址;
可表示的空间范围是:(*2^32)1Byte = 4GB
每一个字节都有一个唯一的地址;
对地址空间进行编址,从低地址到高地址为0x00000000到0xffffffff;
什么是区域划分?
进程地址空间是以字节为单位的逻辑上连续的空间,在进程地址空间中按功能被划分成了多个不同多区域。对于连续的空间,不同功能区域的划分,其实只要标记出每个区域的起始和结束位置即可。
struct mm_struct{
uint32_t code_start, code_end;//代码区起始和结束
uint32_t data_start, data_end;//数据区起始和结束
uint32_t heap_start, headp_end;//堆区起始和结束
uint32_t stack_start, stack_end;//栈区起始和结束
...//
};
如何进行区域调整?扩大、缩小?
对于一个进程来说,其代码区和数据区起始是确定大小的,一般起始确定就不会再更改。而对于堆区和栈区,涉及到空间的动态变化,不过我们已经定义了每个区域的起始start
和结束end
,那么堆区和栈区的缩小和扩大在虚拟地址空间中只需要更改结束位置end
的值就可以实现。
区域起始地址与区域结束地址之间的所有地址就是该区域的虚拟地址。
堆区heap和栈区stack的区域动态调整(扩大或缩小堆区或栈区),本质就是通过修改各个区域的end或start实现的。
如定义局部变量或函数调用创建函数栈帧扩大栈区、malloc申请堆空间扩大堆区;
而除了作用域局部变量销毁或被调函数返回栈帧销毁缩小栈区,free掉申请的空间缩小堆区。
32位操作系统给每一个进程画的大饼 - 进程地址空间的大小是4GB。
证明:看看Linux源码mm_struct
进程虚拟地址空间
什么是地址空间
进程地址空间表示的只是一段范围,并不储存进程的数据和代码,数据和代码储存在物理内存上。
引入页表:
页表是用于对进程地址空间中的虚拟地址(线性地址)和物理内存中的物理地址进行映射,建立对应关系。每一个进程除了有自己的PCB结构体、进程地址空间结构体,还会有自己的页表对应的结构体,由操作系统进行统一管理。
物理内存被分成了4KB大小为单位的page(页),这样4GB的物理内存就被分成了4GB/4KB即2^20个page(内存页)。
线性地址:在C语言中,我们知道一维数组在内存中是连续存储的;二维数组在内存中也是连续存储的,即二维数组是由一维数组组成的,二维数组的地址也是连续、线性的,我们可以使用既可以使用两层循环遍历二维数组,也可以使用一层循环遍历二维数组。
首先认为:Linux中,虚拟地址和线性地址是同一个概念。
如何理解地址空间
为什么存在进程地址空间?
- 如果让进程直接访问物理内存,万一进程越界非法操作该怎么办呢?这非常不安全。
- 地址空间的存在,可以更方便的进行进程和进程的数据、代码的解耦,保证了进程的独立性。
进程直接访问物理内存容易出问题,确实不行,但是进程地址空间为什么就行呢?
页表的作用不止是把虚拟地址映射到物理地址, 页表还会对进程访问或读取的虚拟地址进行判断,如果进程涉及到非法访问物理内存就会直接进行拦截,进程被操作系统处理;
进程地址空间保护了物理内存,保证了进程(特别是恶意进程)不能随便访问物理内存。
- 让进程以统一的视角来看待其对应的代码和数据等各个区域,方便使用;编译器也以统一的视角对代码进行编译。二者采用相同的规则,编译完就能直接使用。
对开始例子的底层解释:
进一步理解地址空间
- 我们写的可执行程序里面,在没有被加载到内存的时候就有内部逻辑地址了。
- 不只是操作系统会遵守虚拟地址空间的规则,编译器在编译我们的代码时也会遵守。
编译器在编译我们的代码时,就是按照虚拟地址空间的方式对我们的代码和数据进行编址的。
- 像这样由编译器按照虚拟地址空间的方式进行编址就形成了可执行程序内部的地址(一般称为逻辑地址,等价于虚拟地址)。
- 当程序被加载到物理内存中时,程序的代码和数据就天然具有了物理地址(外部),在程序内部有着编译时形成的内部地址(如被调函数地址,肯定是相对于内部来说的,而不是相对于外部物理地址)。
拓展
僵尸进程指的是进程退出后不会完全释放资源,会造成系统资源泄漏;
孤儿进程在父进程退出后,父进程成为init进程,进程退出,孤儿进程的资源将被init进程释放
操作系统通过pcb实现对程序运行调度控制
fork系统调用通过复制父进程创建一个子进程,父子进程数据独有,代码共享(在数据不发生改变的情况下父子进程资源指向同一块物理内存空间(调研写时拷贝技术))
在抢占式多任务处理中,进程被抢占时,哪些运行环境需要被保存下来?
所有cpu寄存器的内容,cpu上正在处理的数据。
页表指针 程序切换时会将页表起始地址加载到寄存器中。
程序计数器 下一步程序要执行的指令地址。
程序是静态的指令集合,保存在程序文件中,
进程是程序的一次运行过程中的描述。
作业是用户需要计算机完成的某项任务,是要求计算机所做工作的集合。
- 一个程序可以同时运行多次,也就有了多个进程。
- 一个作业任务的完成可由多个进程组成,且必须至少由一个进程组成
- 程序是静态的,而进程是动态的。
进程是操作系统对于程序运行过程的描述,而这个描述叫做进程控制块-PCB,它是操作系统操作系统管理以及调度控制程序运行的唯一实体。
因为进程ID只是进程的标识符,是系统能够找到特定进程的标识而已。
-
进程管理器只是对大量PCB进行管理的一个程序而已。
-
进程本质上来说没有名字,它有所调度管理运行的程序的名称,它的标识是进程ID,可以把进程ID当做是它的名字。
-
在系统角度看来,进程是对于程序运行的描述,就是PCB进程控制块。
-
syslogd:系统中的日志服务进程
-
init:init进程是内核启动的第一个用户级进程,用于完成处理孤儿进程以及其他的一些重要任务
-
sshd:远程登录服务进程
-
vhand:内存置换服务进程
守护进程&精灵进程:是同一种特殊的孤儿进程,不但运行在后台,最主要的是脱离了与终端和登录会话的所有联系,默默的运行在后台不想受到任何影响。
结语
本文从冯诺依曼体系结构开始介绍,引入了操作系统的体系结构和操作系统管理的本质。着重介绍了进程概念,通过程序从磁盘加载到内存,操作系统为其建立PCB、进程地址空间、页表对应数据结构来进行描述。特别是进程地址空间,这是理解进程概念的关键,参悟了进程地址空间,也就理解了进程。
标签:状态,操作系统,初识,内存,Linux,进程,CPU,运行 From: https://blog.csdn.net/weixin_64904163/article/details/136829216