进程的概念(上)
目录
1.冯诺依曼体系结构
1.1存储器
2.操作系统OS
2.1底层硬件
2.2驱动程序
2.3管理
2.4系统调用和库函数
3.进程
3.1基本概念与操作
3.1.1PCB
3.1.2task_struct
3.1.2.1内容分类
3.1.3查看进程
3.1.4通过系统调用获取进程标识符
3-1-5通过系统调用创建进程-fork初识
3.2进程状态
3.2.1进程排队问题(粗略理解)
3.2.2理论的进程状态
3.2.3linux中具体的进程状态
R运行状态(running)
S睡眠状态(sleeping)
D磁盘休眠状态(Disk sleep)
T停止状态(stopped/tracing stop)
X死亡状态(dead)
Z僵尸状态Zombies
孤儿进程
3.3进程优先级
3.3.1基本概念
3.3.2查看系统进程
3.3.3PRI && NI
3.3.4查看进程优先级
3.3.5竞争、独立、并发、并行
3.4进程调度与切换
3.4.1切换
3.4.2调度
活跃队列
过期队列
active指针和expired指针
总结
1.冯诺依曼体系结构
稍微介绍下。
输入单元:包括键盘, 鼠标,扫描仪, 写板等
中央处理器(CPU):含有运算器和控制器等
输出单元:显示器,打印机等这里的存储器指的是内存
不考虑缓存情况,这里的CPU能且只能对内存进行读写,不能访问外设(输入或输出设备)(数据层面)
外设(输入或输出设备)要输入或者输出数据,也只能写入内存或者从内存中读取。
所有设备都只能直接和内存打交道。
-------------------------------------------------------------
运算器主要是计算,算术运算和逻辑运算。
控制器主要是协调cpu内部各种信息的流动。
输入设备:话筒、键盘、鼠标、网卡、磁盘、摄像头等
输出设备:声卡、显卡、网卡、显示器、磁盘、显示器、打印机等
注意,有些设备既是输入也是输出。
内存掉电易失。
-------------------------------------------------------------
所有的设备都是需要线,也就是总线来互相连接起来的。
总线大都集中在主板上,很多设备插在主板上,本身也是主板内的总线将不同设备连接起来。为了让数据进行流动,所以才需要将设备进行连接。
-------------------------------------------------------------
上面图中的数据层面的流动也很好理解,本身就是用户通过输入设备传递数据给存储器,存储器跟运算器交换数据,运算器得到的结果,经由存储器再到输出设备,用户从输出设备看到运算结果(以人的角度能理解的数据,比如图片、人物移动等等)。这其中数据的流动就是通过总线将数据的拷贝,传递到不同的设备上。因此数据的流动速度决定了计算机的效率。
-------------------------------------------------------------
1.1存储器
存储器有很多分类,我这里也不是什么存储器大全,主要是粗略了解。
简单的理解下,存储器有很多种类,存储器加存储设备,构成了整个计算机体系里的存储数据的设备。
以离cpu的距离(直接/间接传输距离)划分,cpu内的寄存器,一、二、三级缓存(高速缓存,采用sram),主存(采用dram),本地磁盘,远程存储(服务器、分布式文件系统等)。
本地磁盘保存从远程存储取出数据,主存保存从本地磁盘取出的磁盘块(数据),高速缓存存有主存的内容的副本(一级从二级接受,二级从三级,三级从主存),寄存器从缓存接受数据。
离cpu越近的存储单元,效率越高,造价越贵,单体容量越小。
计算机整体的效率是以内存为主,因为内存相当于中央协调塔,所有设备都要经过内存,内存可以通过预加载、缓存等途径,将速度不协调的设备统合起来(将硬件固定的效率问题转化为如何通过内存的软件来协调不同设备)。也正是有了内存,有了不同的存储设备构建的存储体系,才让计算机的整体价格足够便宜,可以让更多的人使用。
我们知道所谓的程序,其实也是文件,里面写着指令和数据,这些指令和数据,需要cpu执行和运算,但我们知道冯诺依曼体系中,在数据层面,cpu只和内存打交道,所以,如果想要让cpu去执行,那就必须要把程序从输入输出设备的磁盘中读取到内存里,然后才能让cpu从内存里读取到指令和数据去执行相应的任务。这就是为什么程序在执行前必须先加载到内存中。
如果是两个人进行远程聊天,从冯诺依曼的体系角度来看,A通过键盘等输入设备,将内容以数据形式存储在内存,聊天的时候,除了聊天内容以外,还有各种信息,比如发送时间等等,cpu会进行运算执行,将这些内容打包加密,再传回内存,内存再传到网卡,网卡通过网络到了B的网卡。网卡将数据传到内存,内存传给cpu,cpu进行解包解密操作,还给内存,内存再将解密解包后的内容,显示在B的输出设备显示器上。
文件传输也是类似,只是文件传输完成后,文件不是在显示在显示器上,而是存在了磁盘上。
2.操作系统OS
当电脑开机,第一个被加载到内存的程序,就是操作系统。
操作系统是一个进行软硬件资源管理的软件
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。
笼统的理解,操作系统包括:
内核(进程管理,内存管理,文件管理,驱动管理)
其他程序(例如函数库,shell程序等等)
整个计算机的软硬件结构,是层状的。
从用户到硬件,可以分为用户、用户操作接口、系统调用、操作系统、驱动程序、底层硬件。
所属 类型 内容 用户 用户 执行操作,指令、开发、管理。 用户操作接口 shell外壳、lib、部分指令 系统软件 系统调用 系统调用接口 操作系统 内存管理、进程管理、文件管理、驱动管理 驱动程序 网卡、硬盘、鼠标、键盘驱动等等 硬件 底层硬件 网卡、硬盘、鼠标、键盘等
2.1底层硬件
底层的硬件,其实不用多说,前面的冯诺依曼讲得差不多了。底层的硬件在物理上的架构,是冯诺依曼体系,具体上面写过了,不多说了。
2.2驱动程序
硬件被使用的细节,操作系统不需要关心,也没必要关心,因为每个硬件最了解的人,是其厂商,厂商才清楚如何调用,所以由厂商写驱动程序,操作系统只需要把按需调用驱动程序提供的接口\方法即可。形象的理解,就是库函数,我们不需要理解库函数内部怎么调用的,像我们有输出内容到屏幕的需求,所以调用了printf函数,我们不需要知道函数内部怎么实现的,我们只需要知道调用函数的格式即可。
每个硬件都有他匹配的驱动程序。
常见的驱动程序,都是买电脑前就已经内置在电脑里了的,只有一些相对不常见的驱动,需要用户手动下载安装。
当然,为了方便操作系统使用,驱动程序有其规定的接口格式、文件格式,具体不多说。
2.3管理
管理者,做决策。被管理者,做执行。操作系统就是管理者,硬件就是被管理者,驱动程序保证决策落地。
管理者和被管理者,并不需要直接见面,管理的本质不是对人做管理,而是对人的信息(数据)做管理,管理者不需要知道被管理者是谁,他只要知道被管理者有没有执行好决策,能否执行决策,就可以对其进行管理。
管理者核心工作是做决策,根据数据做决策。
但是管理者如何拿到数据呢,就要靠一个中间人,由中间人保证决策落地,收集被管理者数据供管理者做决策。
管理者面对大量的被管理者的时候,数据量必然非常大。因此对数据进行分类,统一格式,以某种数据结构存储在计算机中。管理者的工作,就是对这个数据结构进行增删查改。
因此,所有的管理工作,都可以简单的概括为,先描述,再组织。
那么操作系统对硬件和驱动程序的管理也是同理。先将硬件以结构体等方法来统一格式,然后将这些数据以链表形式存储,操作系统对硬件的管理就是对链表的增删查改,而管理的落地,则是由驱动程序负责。
为什么要有操作系统的管理?
通过对下管理好软硬件资源,从而对上提供一个良好(稳定、高效、安全)的运行环境。没有操作系统的管理,人手动管理的效率很差
编程语言的本质,就是对数据进行管理。先描述,就是一个面向对象的过程,像是c++的类,java的类,c的结构体,都是将一个对象的属性抽象成一个可以被计算机存储的东西。而再组织,就是将这些对象存储起来,管理起来,像是c++的容器,java的一些类。
所以可以看得出来,几乎所有的高级语言,c++、java、python、go等等,都是遵循 先描述,再组织的,面向对象的思想。
因此,将具体问题,进行计算机级别的建模,转换成计算机能够认识的问题的过程,核心就是先描述,再组织,从而将对数据的管理,转换为对特定数据结构的增删查改。
2.4系统调用和库函数
在开发角度,操作系统对外会表现为一个整体,但是会暴露自己的部分接口,供上层开发使用,
这部分由操作系统提供的接口,叫做系统调用接口。而以linux为例,大部分的操作系统都是c语言为主写的,系统调用接口本质上就是函数,用c语言设计的函数。函数或者说系统调用的输入输出,可以理解为,用户传递数据给操作系统和用户在系统调用之后从操作系统得到的反馈。而操作系统提供的,无非就是2种,一个是数据方面的支持,一个是功能方面的支持。
任何人都不能直接访问操作系统内的数据,必须先经过操作系统。当确定了以操作系统为管理者后,就不允许越级绕过操作系统,任何请求一定要先经过操作系统
系统调用在使用上,功能比较基础,对用户的要求相对也比较高,所以,有心的开发者可以对部
分系统调用进行适度封装,从而形成库,有了库,就很有利于更上层用户或者开发者进行二次开
发。先抛开嵌入式。典型的,就是printf函数,printf是要打印到屏幕上的,屏幕是个硬件,printf内部封装了系统调用,由系统调用去执行相应的打印操作,所以才能实现打印到屏幕上的目的。
所以只要涉及到硬件的库函数,内部肯定要封装系统调用,只有封装了系统调用,才能经过操作系统访问硬件
用户可以直接调用系统调用接口,但考虑到需要操作简便,所以会有用户操作接口(shell、lib库等),来简化用户的操作难度。
注意,所谓的语言具有跨平台、可移植,核心关键是,库函数是一致的,区别在于,不同的系统,其系统调用函数是不同的,但是用户不需要关心,不同的系统有相应适配的库,也就是说,环境的差异由库来解决,用户和开发者只需要会用库函数即可
3.进程
3.1基本概念与操作
进程简单的理解就是程序的一个执行实例,正在执行的程序等。
在系统的内核里面,进程是担当分配系统资源(CPU时间,内存)的实体。
我们可以同时启动多个程序,本质就是将多个.exe加载到内存
这些程序都要操作系统管理。
操作系统怎么对上面的程序进行管理?依旧是,先描述,再组织
先描述,由此,进程的概念才出来,就是将程序进行描述,属性封装成一个结构体。
3.1.1PCB
进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合。
课本上称之为PCB(process control block),Linux 操作系统下的 PCB是:task_struct
是task_struct-PCB的一种。
在 Linux中描述进程的结构体叫做task_struct
task_struct是 Linux 内核的一种数据结构类型,它会被装载到RAM(内存)里并且包含着进程的信息。
3.1.2task_struct
3.1.2.1内容分类
标示符:描述本进程的唯一标示符,用来区别其他进程。
状态:任务状态,退出代码,退出信号等。
优先级:相对于其他进程的优先级。(cpu是有限的,同时能够执行的任务也是有限的,所以不同的进程需要有优先级来区分)
程序计数器PC/EIP:程序中即将被执行的下一条指令的地址。本身是一个寄存器。
程序是放在内存,每一条代码或者说指令都有一个地址,cpu是一条条执行的,cpu的工作是取指令、分析指令、执行指令。在分析指令和执行指令的时候,pc会++变成下一条指令的地址,当cpu要执行下一条指令的时候,会直接根据pc的值,到内存指定的地方取指令。
本质上,判断、循坏、函数跳转都是修改pc指针。
pc指向哪一个进程的代码,就表示哪一个进程被调度运行。
内存指针:包括程序代码和进程相关数据的指针,还有和其他进程共享的内存块的指针(方便找到相应的代码和数据等等)。
上下文数据:进程执行时处理器的寄存器中的数据。I/O状态信息:包括显示的I/O请求,分配给进程的I/O设备和被进程使用的文件列表。
记账信息:可能包括处理器时间总和,使用的时钟数总和,时间限制,记账号等。
主要是,用于cpu决策哪个进程先执行,比如某个进程已经使用很久的cpu了,这时候cpu会考虑先执行其他进程。
其他信息
组织内核可以在内核源代码里找到它。所有运行在系统里的进程都以 task_struct 双链表的形式存在内核里。每个程序进入内存后,都会由一个PCB对象将这个程序的属性描述起来,最后纳入链表管理。注意,pcb对象里会有指针,指向下一个pcb对象。
进程=内核数据结构(task_struct)+自己的程序代码和数据
所谓的进程排队,其实就是cpu通过一个运行的队列(队列的关键其实也是链表),运行的队列里面将pcb对象按优先级放入,有了PCB对象就能找到对应程序数据在内存的地址。
所有对进程的控制和操作,都只和进程的PCB有关,和进程的可执行程序无关
3.1.3查看进程
进程的信息可以通过/proc 系统文件夹查看
如:要获取PID为13的进程信息,你需要查看/proc/13 这个文件夹。
进入相应文件夹,可以看到这个exe,一般来说这个exe是一个链接文件,链接向一个可执行程序的绝对路径,我这个是非正常的进程。注意,如果这个可执行程序的文件被删除了,那么后面除了路径之外还会提示文件已经被删除。
cwd,就是current work directory,即当前工作目录,默认就是跟可执行程序在同一个目录下。
很多语言都有文件操作,比如打开文件的语句,经常都有如果文件不存在就在当前工作路径下创建相应文件的功能。之所以能够找到当前路径,就是因该语句的程序在执行的时候是在内存的进程下,当执行到该语句时候,通过该进程的cwd,自然就能知道当前路径是哪个路径了,然后把路径作为前缀,加入语句里的文件名或./文件名,就形成了在当前路径下创建文件。
另外,c语言里就有一个库函数是chdir,可以改变所谓的当前工作目录
大多数进程信息同样可以使用top和ps这些用户级工具来获取
top 命令是 Linux 系统中一个极其重要和强大的实时系统监控工具。它就像是 Linux 系统的“任务管理器”,可以动态地、交互式地展示当前系统的运行状态和正在运行的进程信息。
ps axj 固定用法
ps aux / ps axj 命令
------------------------------------------------------------------------------------
a:显示一个终端所有的进程,包括其他用户的进程。
x:显示没有控制终端的进程,例如后台运行的守护进程。
j:显示进程归属的进程组ID、会话ID、父进程ID,以及与作业控制相关的信息
u:以用户为中心的格式显示进程信息,提供进程的详细信息,如用户、CPU和内存使用情况等
-------------------------------------------------------------------------------
PID就是标识符
可以看到,我左边的终端在执行一个程序,右边,我用另一个终端开始查看进程。
ps axj | head -1 就是把第一行输出,&&是先执行左边,再执行右边。
ps axj | gerp test则是根据我的程序名称进行筛选输出,其中COMMAND就是说明进程的可执行程序是哪个。
可以看到我进程的PID是23286,command是./test
注意,grep本身也是一个指令,指令本身就是一个程序,程序执行必然要进入内存,有相应的进程,而这里之所以被筛选出来,是因为我们筛选的选项里就有相应的关键字,所以会被留下来。如果想去掉,最后再加一条| grep -v grep 反向筛选,去掉包含grep的。
因此,几乎所有的独立指令,都是一个程序,运行起来都要变成进程
进程也是有生命的,当一个可执行程序结束了运行,进程自然会删除。
要注意的是,一个进程的可执行程序在磁盘上被删除了,不会影响该进程的执行,因为可执行程序已经被拷贝了一份进入内存,因此本次的进程不会被影响,但后面就不能重新执行该可执行程序了。
3.1.4通过系统调用获取进程标识符
一个进程,即一个PCB对象,本身也是操作系统内部的数据,因此要获得其数据,操作系统肯定是有提供相应的系统调用接口,而大概率也是有相应的库函数的,如下。
返回值是整数类型
而在linux中,普通进程基本都是由父进程PPID创建的。查看如下:
每一次启动程序,pid几乎都会变化,但是可以发现,父进程即ppid都没变。
可以发现,我们在终端里执行的程序,都是bash的子进程
3-1-5通过系统调用创建进程-fork初识
可以运行 man fork来认识fork。
注意,这个函数是linux环境下的,windows不支持。
--------------------------------------------------------------------------------------
观察结果可以发现,当fork之后,会由当前的进程作为父进程,创建其子进程。
fork之后,2个进程都会执行printf语句,即都会输出fork after。
即 父子进程代码共享
即bash进程创建子进程,子进程又创建了子进程。
--------------------------------------------------------------------------------------
fork有两个返回值,父进程的fork返回值是子进程的pid,子进程的fork返回值是0,fork失败,则fork返回值-1。
为什么要返回子进程pid给父进程呢,因为父进程需要子进程pid,才能精准的控制该子进程进行某些操作,但子进程不需要通过fork来获得父进程id,因为子进程创建成功后,子进程的属性里面就有父进程的id,即可以通过getppid获得。
为什么会返回2个返回值?因为fork函数最初是父进程开始调用的,当执行到一定语句后,子进程会被开创,前面说了,代码会共享,也就是说,从开创子进程之后的语句,是父进程和子进程都会执行的,那么return id这个操作,父进程会执行,子进程也会执行,所以才会有2个返回值(怎么做到返回值不同,参考下面的父子进程不同操作)。
--------------------------------------------------------------------------------------
一般而言,我们都是想让父子进程做不同的事情。
怎么做呢,那就是利用fork有2个返回值的特性,通过if判断之类的,让父子进程做不同的事情。
可以做到父子进程一起死循环。
另外,父子进程,在内存中都是相对独立的进程,在cpu眼里一视同仁。
但子进程的大部分属性,都以父进程为模板,但像是pid和ppid之类的就不一样了。
--------------------------------------------------------------------------------------
kill -9 pid 可以强行结束进程。
同一个变量会为什么可以有不同的值?
另外进程之间是独立,不能互相影响的(比如父进程结束了,也不影响子进程)。详细的可以等之后写进程空间管理的内容。
为了保证进程之间是独立的,操作系统在父或子进程试图对某个共享的数据(比如某个变量)进行写入的时候,会为其开辟空间,父子进程对该数据,会指向不同的内存空间。即写时拷贝。
返回的本质是写入,因此当fork函数返回的时候,会触发写时拷贝,然后形成同个变量,不同进程指向不同内存空间。暂时可以这么理解。
--------------------------------------------------------------------------------------------------------------------------
同时变量对于二进制可执行程序而言是不存在的,当代码形成可执行程序的时候,代码已经被转换成某种寻址方式,比如内存地址等等,另外c/c++上的地址,不是物理地址,而是虚拟地址,需要被转换成物理地址
上述可以创建多个子进程。
其实重点在于,进程的stat有很多表示,有S+(表示正在运行),R+,Z+(进程结束)。
子进程退出时,进程信息的末尾还会有defunct的字样。
3.2进程状态
3.2.1进程排队问题(粗略理解)
进程不是一直在运行的,偶尔会需要等待一些软硬件资源,比如硬件上的输入,像是C语言scanf输入的时候,我们如果不输入内容,程序就会一直在那等。
而现在的操作系统,都会有个时间片的概念,用于实现并发,每个进程都会被分配一个时间片(比如1毫秒),时间片倒计时结束,cpu会暂时保存当前进程的执行进度,然后去执行其他进程,直到下一次轮到这个进度的时间片。因此,进程就算放在了cpu上,也不是一直在运行。具体等后面讲调度的时候会说。
进程排队本身,某种意义上是在等待某种资源。
排队的不是可执行程序,而是PCB,即存储进程信息的结构体或类。
-----------------------------------------------------------------------------
前面,我们说过,linux中的PCB,即task_struct是以双链表存储。但实际实现上,却不是传统的双链表。
我们知道,双链表重要的是next和prev指针。linux中,这两个指针会放在一个结构体(假设叫node)中,每个task_struct中有很多进程的属性再加上一个node对象,因此,task_struct的双链表存储,实际上是内部的node在进行双链表连接。
那么问题来了,既然只是node在进行双链表连接,也就是说,cpu每次按理来说都只会跳到node对象的地址处,cpu怎么知道这个task_struct中的其他属性呢。我们知道,一个结构体中每个成员的地址,通过偏移量就可以计算,同理,当我们知道了task_stuct结构体中node对象的地址,通过固定的偏移量(task_struct的结构是固定的,那么偏移量也必然是固定的),就可以得到该node所在的task_struct对象的首地址,也就可以访问相应的不同成员了。
(task_struct *)(&n - &(task_struct*)0->n)),也就是node对象地址减去,以0地址为首地址的task_struct对象中的n对象地址(这个就是固定的偏移量),得到的就是该node对象所在的task_struct对象首地址。
linux内核是c语言,而c语言是有一个宏,offset,可以快速实现类似的操作。
------------------------------------------------------------------------------------
前面说pcb会放在链表里受操作系统管理,但是我们又说进程排队(放在队列),实际上一个task_struct对象可以存储在多个数据结构中,从前面双链表可以看出,task_struct里面可以放一个用于双链表链接的的结构体node,同理也可以同时放一个用于队列链接的结构体node(要知道队列底层也是可以用链表实现的,node的结构是一样的,也就是说task_struct里会放多个node,用于链表、队列的链接)。因此,task_struct用双链表管理,不影响同时被放进队列进行排队
移除进程本身就是把task_struct中的node从各个链表中移除,因此操作的动作都是删除链表的一个节点,函数方法是通用的。同理其他操作也是类似,因此对进程的管理,关键就是对链表的增删查改。
3.2.2理论的进程状态
在讲具体的状态前,先说下,状态的表示,在task_struct中,就是用一个整型变量来存储。
比如#define running 3 ,也就是说,不同状态就是不同的数字存在变量中。
状态决定了什么?决定了你的后续动作,而linux中可能会有多个进程同时要根据他的状态,准备执行后续的动作,而cpu在短时间内处理的进程是有限的,进程多了就要排队一个个来执行,因此cpu有一个运行队列。
运行状态
当一个进程被放进了cpu的运行队列中,就是运行状态,虽然理论上在队列中排队是就绪状态,但是现在的操作系统大多都是直接把就绪和运行合并了表示,而创建状态,虽然理论上存在,但实际几乎不会以某种形式表现,因为一个进程如果要调度,就是放进运行队列,不调度就放在全局链表里,没有创建状态的表示余地。
------------------------------------------------------------------------------------
阻塞状态
硬件是受操作系统管理的,核心依旧是先描述再组织,一个结构体描述,一个数据结构存储。当进程执行到对某个硬件的操作时,且该硬件的状态并不是就绪或者说运行状态时(操作系统作为管理硬件的,必然清楚硬件的状态),操作系统会把该进程直接丢出cpu的运行队列,放入该硬件的等待(阻塞)队列中(cpu就是一个硬件,那么其他的硬件也会有相应的队列),此时该进程就是阻塞状态。比如键盘,当我们从键盘输入了内容并且回车了之后,键盘的状态会变成就绪状态(输入的内容已经被os放入到了内存空间 缓冲区中),然后操作系统会将键盘的运行队列中第一个进程放入cpu的运行队列中,此时这个进程就从阻塞状态变成了运行状态或者理论上是就绪状态,当运行队列轮到了这个进程,cpu就可以依照这个进程,顺利的进行相应的对硬件的操作。
总结一下,当进程在等待某个软硬件资源的时候,OS会将这个进程挪到相应资源的等待队列中,并且这个进程的状态会从运行状态变成阻塞状态。当一个进程不会被调度,处于等待中,那就是阻塞状态
因此,所谓的状态的变化,核心其实就是os将进程挪到不同的队列中,不同的队列有不同的作用,运行、等待(阻塞)。
------------------------------------------------------------------------------------
挂起状态
这个状态不会很常见,但是出现的时候,大多是出于计算机资源很吃紧的时候。
当资源吃紧,内存空间不够(有os崩溃的风险),但是某个进程预计很长时间都不会被调度(处于阻塞状态),但是本身又占着内存的空间(代码和数据在内存中),这时候为了腾出空间,os会将这个进程对应的代码和数据写入磁盘(其中的swap分区就是专门用于负责这个的)中,当快要调度的时候,再从磁盘读取。其中,将写入磁盘的操作就是唤出,重新写入内存就是唤入。当一个进程被唤出,此时这个进程就是挂起状态。阻塞状态下的挂起,就叫做阻塞挂起(还有其他种类的挂起,这里不表)。
注意,唤出唤入的是代码和数据,pcb本身是不会被抛弃,不然os就不知道这个进程的状态了,更无法管理这个进程了。
注意,os腾空间时,不只是会对进程做手脚,像是文件、网络等资源也会被释放。甚至极端情况,os甚至会把进程本身也干掉
当我们创建一个进程,os会先创建相应的pcb,之后根据需要,选择性的加载部分代码和数据
一般来说,这个swap分区不会太大,因为swap分区越大,os就会更加依赖这个swap分区,唤入唤出的频率就会提高,而swap分区是在磁盘上,而磁盘是外设,访问速度相对于内存是很慢的,唤入唤出的频率越高,说明低速的行为越多,虽然说唤入唤出是采取时间换空间的行为(本来都在内存的话,速度很快,访问磁盘速度就会慢很多,但相应的内存空间也会更多有余地),但也不建议swap分区太大,基本是跟内存一样大或是内存1/2大
3.2.3linux中具体的进程状态
"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):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
-------------------------------------------------------------------------------------------------------------------
R运行状态(running)
当我们的代码是这样的时候,将其运行,可以看到
其中可执行程序是S,为什么不是R呢,不是一直在运行吗,第一个因素就是代码中有sleep,也就是说,程序每一次循坏有一部分时间在sleep等待,自然查出来会是s。第二个因素就是printf本质是向外设打印,比如云服务器,就是要打印到网络设备上再传到我们的终端上,本地的运行,也是要打印到屏幕上,而访问外设(此时进程就是等待中,即阻塞状态),是需要时间的,比cpu慢很多,因此,真正执行printf的时间是非常非常短的。大部分时间都在sleep和访问外设上,自然我们查看的时候,几乎都是S。
可以看到,当我们把sleep注释掉,只剩一个访问外设的因素,自然我们查看的时候,遇到R的几率会上升。
可以看到,当只剩死循环后,我们查看的时候,进程都是R。
注意,其中关于grep的进程,之所以会是R,是因为grep命令执行的那一瞬间,也会有处于R状态的进程。
------------------------------------------------------------------------------
为什么会有+号呢,+号,表示的是该进程是前台进程,所谓的前台进程,就是会占据前端也就是终端bash,运行中,我们不能输入其他指令,只有进程结束或ctrl+c进程,才能输入其他指令。不带+号,表示后台进程,后台进程不会影响我们继续在前端输入指令,如果要结束进程,除了进程自己运行结束,就只能是kill -9强行终止进程。
比如./test 就是将可执行程序以前台进程的形式运行。如果是./test & 就会是后台进程。
S睡眠状态(sleeping)
可中断睡眠,浅度睡眠
简而言之,其实就是阻塞状态的主要表现
S状态的进程,是可以通过ctrl+c结束的。
当进程在等待某种资源(非磁盘),处于阻塞状态时,就会是S,像是等待硬件输入、函数sleep导致的等待都是会导致状态变为S。
D磁盘休眠状态(Disk sleep)
不可中断睡眠,深度睡眠
这个状态下,这个进程不能被杀掉,这个状态,说明这个进程的操作是涉及到了对磁盘的写入的。一般是很难查到的,因为磁盘速度虽然慢,但其实也不是特别慢,因此当查到D状态时,说明这个机器的磁盘已经非常慢了,很可能整个系统即将要挂了。
注意,这个状态也是阻塞状态的表现之一。
T停止状态(stopped/tracing stop)
之前我们用过kill -9,即杀掉进程。
而我们可以看下19,暂停进程,当我们kill -19时,进程的状态就会变成T状态,即暂停状态(stooped)。
18就可以把这个进程继续运行,不再是暂停状态(注意,当一个进程变成暂停状态之后,这个进程就会变成后台进程)
一般什么情况下会出现暂停状态呢,比如当一个进程想要访问某个外设时,这个进程却不被允许访问这个外设,这时候os为了防止进程非法操作,就会将这个进程变成暂停状态。
t也是类似,但t的出现,主要是在调试代码的时候,每一次程序暂停等待gdb下一步的指令时(比如next),这个程序就处于t状态,处于被追踪的状态。
------------------------------------------------
暂停状态,也是阻塞状态的表现之一。
X死亡状态(dead)
就是理论上的终止状态、结束状态。
因为进程结束了,那肯定是要销毁的,因此,这个状态也只会是非常短暂的,因此基本是查不到的。
Z僵尸状态Zombies
僵尸状态(Zombies)是一个比较特殊的状态。
当进程退出并且父进程(使用wait()系统调用,后面讲)没有读取到子进程退出的返回代码时就会产生僵尸进程
僵尸进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。
所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态。
当一个进程运行结束,代码和数据会马上释放,但是pcb却不会马上释放,而是等其他进程或系统来读取这个进程的退出数据,当这个退出数据被读取过后,这个pcb才会被释放。
因此:当一个进程已经执行完毕,但并没有被获取退出的相关数据时,此时这个进程就处于僵尸状态或者Z状态。
可以发现,当父进程在子进程没有结束后没有读取返回状态时,子进程保持Z状态,且末尾出现defunct,表示该进程是无用的,废弃的,死亡的。
创建进程是为了替用户完成某些工作,因此,子进程必须得有结果数据(放在PCB中),来表示工作完成得怎么样,由父进程读取之后,子进程的PCB才能真正释放掉。
当进程已经退出,但进程的状态仍然需要维持住,供上层读取,必须处于Z。
当读取之后,进程才会变成X状态,然后释放掉。
如果父进程一直不读取,就会导致子进程的pcb或者说task_struct一直存在,而pcb本身也是要占据内存空间的,也就是内存泄漏问题。
bash进程会自动读取子进程的退出状态,所以我们一般的程序执行后,我们都不需要担心僵尸进程的问题,甚至因为cpu的速度很快,我们很难手动看到Z状态。
孤儿进程
父进程如果提前退出,那么子进程后退出,进入Z之后,那该如何处理呢?
父进程先退出,子进程就称之为“孤儿进程”
孤儿进程被1号init/systemd进程领养,也就是这个孤儿进程的父进程会变成1号进程。并且这个孤儿进程会变成后台进程。
当孤儿进程执行完毕,会被1号(可以理解为就是os)进程读取,以此里避免当父进程提前退出,子进程变成孤儿,无人认领,导致一直占据内存空间。
3.3进程优先级
3.3.1基本概念
cpu资源分配的先后顺序,就是指进程的优先权(priority)。
优先权高的进程有优先执行权利。配置进程优先权对多任务环境的linux很有用,可以改善系统性能。
还可以把进程运行到指定的CPU上,这样一来,把不重要的进程安排到某个CPU,可以大大改善
系统整体性能。-----------------------------------------
相比权限,优先级的前提是,进程有权限访问某种资源,但因为有多个进程都在访问,所以需要排队,排队的顺序就是优先级。
-------------------------------------------------------
优先级的本质,依旧是资源相对过少。
3.3.2查看系统进程
ps -l
la表示把当前用户所启动的进程以列表形式打印
UID:代表执行者的身份
PID:代表这个进程的代号
PPID:代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号
PRI:代表这个进程可被执行的优先级,其值越小越早被执行
NI: 代表这个进程的nice值
3.3.3PRI && NI
PRI即进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此值越小进程的优先级别越高。
pri也是放在pcb中的一个整型变量,linux的默认优先级是80,在启动前或启动中都可以被修改。优先级的范围是[60,99],40个数字
------------------------------------------------------------------------------------------------------------
那NI呢?就是我们所要说的nice值了,其表示进程可被执行的优先级的修正数值
PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为 :PRI(new)=PRI(old)+nice
pri(old)一直都是80
这样,当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快
被执行所以,调整进程优先级,在Linux下,就是调整进程nice值
nice其取值范围是-20至19,一共40个级别。(超范围的话,实际只取边界值)
nice也是存在pcb中的一个整型变量。
-------------------------------------------------------------------------------
需要强调一点的是,进程的nice值不是进程的优先级,他们不是一个概念,但是进程nice值会影
响到进程的优先级变化。可以理解nice值是进程优先级的修正数据
之所以优先级有范围限制,是为了防止 某些程序将自己的优先级调整的非常高,别人的优先级调整的非常低,导致一些常规的进程很难被cpu运行,从而产生进程饥饿问题。
非常形象的描述就是,食堂排队一直有人插队,老老实实排队的不就惨了么。
任何的分时操作系统,调度上,较为公平的进行调度,避免出现进程饥饿问题。
3.3.4查看进程优先级
用top命令更改已存在进程的nice:
top
进入top后按“r”->输入进程PID->输入nice值
-------------------------------------------------------------------------------
其他调整优先级的命令:
nice,renice
系统函数:
#include <sys/time.h> #include <sys/resource.h> int getpriority(int which, int who); int setpriority(int which, int who, int prio);
3.3.5竞争、独立、并发、并行
竞争性:系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级
独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰(注意,主要是指不影响进程的执行)
并行:多个进程在多个CPU下分别,同时进行运行,这称之为并行
并发:多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称
之为并发。(一个时间片可以运行的指令是很多的,1秒可以运行的指令更是非常多,这就导致了对于我们来说,宏观上cpu是同时执行的,但微观上,每个进程都是一顿一顿的执行)注意,平时说的cpu多核,一般都是cpu里面有多个运算器,可以同时进行多个运算(但也只是把一个进程的很多运算同时进行)。
3.4进程调度与切换
现代操作系统都是基于时间片进行轮转执行的(可以避免诸如死循环的程序,导致cpu一直在执行这个程序)。其中时间片,不同系统设定不同,比如1ms,一个进程会被cpu执行1ms,然后被调度器将其从cpu中拿下来。
3.4.1切换
Cpu有很多寄存器,像是pc寄存器(eip),浮点数寄存器等等,各有各的作用。
进程在运行的过程中,要产生大量的临时数据,放在CPU的寄存器中。
内存把指令输入给cpu,cpu运行指令,运行时产生大量临时数据存储在寄存器中,运行结束结果返回给内存。
cpu的寄存器还会存储像是进程当前运行到哪条指令了等等。
cpu内部的所有临时数据,可以叫做进程的硬件上下文。
CPU上下文切换:其实际含义是任务切换,或者CPU寄存器切换。当多任务内核决定运行另外的任务时,它保存正在运行任务的当前状态,也就是CPU寄存器中的全部内容。这些内容被保存在任务自己的堆栈中,入栈工作完成后就把下一个将要运行的任务的当前状况从该任务的栈中重新装入CPU寄存器,并开始下一个任务的运行,这一过程就是context switch。
当一个进程被调度,如果是首次调度,cpu会按照进程的指令一步步运行,慢慢填充寄存器,如果是非首次调度,则会把进程的上下文覆盖到cpu的寄存器中完成恢复,以便继续运行。
早期的linux内核是把这些临时数据以结构体对象形式放在pcb的成员中,现在会更加复杂一些,是放在堆栈中。在任何时间点,cpu的寄存器里的数据,都是独属于某个进程的。所有进程共享cpu的一套寄存器设备,但数据不共享(独立性的体现之一),即寄存器不等于寄存器的内容。
3.4.2调度
下面以linux2.6内核的调度算法为例。linux的调度会考虑优先级、进程饥饿、效率。、
一个cpu拥有一个runqueue,即运行队列。(如果有多个cpu就要考虑进程个数的负载均衡问题)
如图,蓝色区域和红色区域,分别表示活跃队列和过期队列,但结构都是一样的,是一个结构体,这个结构体是prio_array_t,实际运行的时候,是有一个struct prio_array_t array[2]的数组来管理这2个队列,
活跃队列
时间片还没有结束的所有进程都按照优先级放在该队列
nr_active:总共有多少个运行状态的进程(考虑到过期和活跃,其实应该理解为队列中有多少个进程)
其中queue[140],是一个task_strruct* quenen[140]的指针数组(每个都维护着一个队列)。其中,0-99我们不用(这个是实时优先级,是linux内核为了适配某些实时操作系统而预留的,我们平时更多的是分时操作系统),我们用的是100-139(即普通的优先级,与nice的数值范围对应,40个下标对应40个优先级)0是最高优先级。
进程放入队列时,会按照优先级,放入某一个优先级对应的队列中。这样的设计,让cpu只需要从优先级高的队列开始遍历即可。这个设计跟哈希的结构也有点像。
那么问题来了,cpu遍历队列的时候,要是一个个确认队列是否为空,为空就跳过,不为空就按队列运行,虽然数组大小140是个常数,但是还是有优化空间,所以用bitmap[5]这个int类型的数组来表示,一个int类型是32bit,5个就是160bit,每一个bit表示数组的一个队列,bit的内容表示这个队列是否为空,1就是不为空,0就是空。
这样一来,检测某一个队列是否为空,就只需要用位运算来确认bitmap中对应bit的值是否为1即可(像是确认最低位1的位置,如果bitmap[0]==0,意味着32个队列为空,确认下一个bitmap[1],以此类推,如果遇到为1,说明当前[下标]的32个队列中至少有一个进程,也是当前优先级最高的进程,这时候用位运算确认最低位的1的位置,再加上前面多少个32,即可确认目前当前优先级最高的进程在queue数组的下标)。
过期队列
过期队列上放置的进程,都是时间片耗尽的进程或新插入的进程
当活动队列上的进程都被处理完毕之后,对过期队列的进程进行时间片重新计算
这个设计主要是为了防止进程饥饿的问题,因为如果没有分开活跃和过期,这时候如果一直有高优先级的进程插队进来,队列中原先低优先级的进程就一直不会被运行。
有了这个设计,当cpu对活跃队列开始一个个运行的时候,新产生的进程是不会插入到活跃队列,而是插入到过期进程。
active指针和expired指针
active指针永远指向活动队列,struct prio_array_t * active=&array[0]
expired指针永远指向过期队列 struct prio_array_t * expired=&array[1]
cpu只会运行active指向的队列。
活动队列上的进程会越来越少,过期队列上的进程会越来越多,因为进程时间片到期时一直
都存在的。在活跃队列运行完之后或必要的时候,只要能够交换active指针和expired指针的内容,就相当于有具有了一批新的活动进程! swap(&active,&expired)
总结
在系统当中查找一个最合适调度的进程的时间复杂度是一个常数,不随着进程增多而导致时间成本增加,可以称为进程调度O(1)算法!