目录
进程概念
什么是进程?————一个程序运行起来加载到内存就是进程。只不过进程有多种不同状态,可能并不在执行。
进程与程序的区别在于进程具有动态属性。
根据冯诺依曼体系结构,一个程序要想被执行,首先要从磁盘加载到内存,再被CPU读取调度。但是整个过程就这么简单吗?显然不是,因为每时每刻都有很多程序需要加载到内存,并且内存中也已经有很多进程了,所以操作系统要管理这些进程。
怎么管理?先描述,再组织。将进程的代码、属性用结构体\类描述起来,再将其作为节点放到对应数据结构中。
先说描述,这里就要提到一个概念PCB(Process Control Block) 进程控制块。其实就是一个结构体,我叫它task_struct,里面存程序的所有属性、代码属性的地址以及下一个节点。
struct task_struct
{
//程序所有属性
//程序代码属性的地址
struct task_struct* next;
};
struct task_struct* p1 = new ...
p1->data = ...
p1->addr = ...
用这个进程控制块标识对应进程的信息存到链表中。每个进程控制块连接一个进程和下一个节点控制块:
当一个进程死亡时,操作系统根据进程控制块信息,将对应进程及控制块从链表删除,有新的程序要加载进内存也是一样,都是操作系统遍历链表,根据进程控制块筛选节点进行增删查,或者选择优先级。
struct task_struct称为内核结构体,task_struct对象称为内核对象,
进程 = 内核数据结构(task_strcut) + 进程对应的磁盘代码。
Linux下的进程
查看进程
首先写个小程序,此时mytest并不是进程,对应上图在磁盘中的程序,./mytest 运行起来加载到内存后才是进程。
ps ajx 指令可以看到系统当前所有进程:
ps ajx | grep 'mytest' : 通过管道过滤mytest进程
ps ajx | head -1:会将输出结果的标题打印出来。
PPID:父进程ID PID:子进程ID PGID:祖进程ID SID:会话ID
TTY:终端 STAT:状态 UID:用户ID COMMAND:当前进程
ps ajx | head -1 && ps ajx | grep 'mytest' : 先打印标题,再显示对应进程:
杀死进程
要想杀掉该进程,kill -9 29333(PID)
这样就杀掉了该进程。
见一下系统调用接口
打开man手册,查看一下getpid这个系统调用接口的用法。
在我们上面写的程序中加入getpid:
另一种查看进程的方式(了解)
在Linux下有一个内存级的目录proc,ls /proc可查看根目录的进程属性信息:
可以看到有很多数字目录,这些目录对应的就是进程PID,进程也可以被看做文件,Linux下一切皆文件!
ls /proc/17225 -d 显示当前运行进程文件
此时结束右边运行程序,该进程文件也被系统回收:
再做一个实验,运行刚刚写的程序加载到内存。
进入该进程目录,找到exe文件,这就是磁盘可执行程序mytest对应的进程路径。
如果我们此时删除可执行程序mytest,会不会将进程退出?
可以看到,进程仍在跑,但是进程文件被删除了,这说明当可执行程序被加载到内存后就和它本身没关系了,这是普遍现象但也有特例,后面再提。
进程的常见调用(2个)
fork命令是创建进程的:
查看进程信息,我们发现这里第一个创建的进程29661其实就是bash(shell命令行解释器)
一般来说,第一个进程的父进程就是bash。
现在我们知道了fork可以创建进程,那么怎么使用呢?像上面那样使用肯定不能满足。
打开man手册查询一下:
来看返回值,子进程ID作为父进程的返回值,0作为子进程的返回值;如果创建失败,-1作为父进程的返回值,没有子进程。
根据手册我们尝试来写创建父子进程的代码:
2190是父进程,2191是子进程,父进程返回值是子进程PID,子进程返回是0.
我们发现一个不可思议点:同一段代码,在后续没有改变的情况下,变量val(ID)居然会有不同值! 在C语言的学习过程中,函数返回值都只有一个,但是这里居然出现了两个。这就是系统与语言的差别,多线程的影子在此体现。
父子进程同时运行,fork函数执行前只有一个父进程,执行后,父进程+子进程。
既然fork是用返回值来判断父子进程的,并且执行后父子进程一起跑,那么:
由此得出,fork函数后续代码,被父子进程共享,当然要根据 if判断返回值。
进程状态(抽象理论)
之前说了进程不一定都是在运行,也存在不同状态,如就绪、阻塞、挂起、死亡...
这里说一下,不同操作系统书籍上对状态的说法描述有所不同,或者叫法不同。
那首先要明白为什么会存在这么些状态。一般我们的电脑只有个位数的CPU,总之肯定比进程数来的少,那么就不可能一个CPU专门管一个进程,通常是单核一个CPU操作所有进程数据。那么一定就有正在执行的进程,其他管不过来的就处于其他状态。
运行(R)
操作系统有一个运行队列runqueue,在里面的进程状态被标识为运行(R),但是不一定真正在运行,只是状态为R。
之前在讲进程控制块PCB时说了,程序被加载到内存成为进程然后被CPU读取,操作系统调度进程时是根据PCB来的,不是程序代码。所以这里,进程状态会被保存到PCB中,也就是结构体对象task_struct->int(1:run 2:stop 3:hup 4:dead) 所以让进程入运行队列,实际上就是将PCB的task_struct结构体对象放入队列。
阻塞
当一个进程想要使用外设资源,但是其前面已经有其他进程占用外设时,它就会进入等待队列,操作系统看它暂时使用不了外设资源,就不会让它留在runqueue,当然操作系统很忙速度很快不会陪它一起等外设空闲,就把它塞到等待队列,当前面进程完事后再把它拉入runqueue。
所以进程不止会占用CPU资源,也会随时随地需要动用外设资源。
所谓的进程不同状态,就是进程在不同的队列中。
挂起
当进程标识阻塞状态后,可能会很长时间都在等待,不仅要等硬件资源,还要再进runqueue排队,这时它加载到内存的数据就会一直卡在那,对于内存来说是个负担,操作系统要不断给内存腾空间,万一内存不够了就会无法响应卡住。所以这时操作系统会将阻塞进程的代码置换到磁盘中,等资源准备好了再把代码重新加载到内存运行。这部分节省的空间给其他进程使用,阻塞进程的PCB没有被换出去,因为体积小并且还需靠PCB重新加载对应代码。当资源准备好,其他进程也会给该阻塞进程腾空间让其进入runqueue,操作系统是一视同仁的。所以挂起实际上是对进程数据的换入换出。
所以该场景下阻塞不一定挂起,挂起一定阻塞。注意是该场景下,还有其他情况,比如阻塞挂起状态,进程既阻塞又挂起,还有就绪挂起等各种情况。阻塞就是单纯在等待自己的资源,挂起会和其他状态各种组合,要注意。
Linux下的进程状态(具体)
Linux下的状态有以下这些,运行,浅度睡眠,深度睡眠,暂停,t停止,僵尸,死亡。
与上面提到的状态说法不同,具体操作系统下有自己的状态规则,比如S和T状态都是阻塞。
先写个代码见见状态:
此时a.out 处于R运行状态。此时程序是计算密集型,查看进程状态为R。
改一下代码:
此时进程变成了S睡眠状态,也就是阻塞。为什么呢,就是多加了一句printf?————这里就印证了CPU与硬件之间速度的差距。CPU可能一瞬间就跑完了这段代码,但是要打印到显示器 ,显示器当然刷新要慢得多,CPU不会一直等待,该进程就进入睡眠状态等到资源加载完毕CPU再执行打印,其实大部分时间都在等IO,这种程序就是IO密集型,查看状态基本为S。
然后这个+是什么意思呢?———— 带+的是前台进程,不带的是后台进程。
之前我们学过一个指令kill ,当时只说了kill -9杀死进程。
说一下kill -19 暂停信号和kill -18继续信号。
对刚刚的进程,kill -19暂停,查看进程:
这时进程状态变为了T,暂停。暂停也是阻塞,但是不是挂起呢要看操作系统的决定,这就是为什么挂起状态不好说的原因,完全取决于操作系统,而且它不会把挂起状态暴露给用户,也就是说你不知道进程是不是挂起的。
此时再kill -18继续进程:
然后进程会再度运行起来,此时S+变成了S,也就是前台进程变成了后台进程。前台进程可以Crtl+C退出,但是后台进程不可以,需要kill -9杀死进程退出。
深度睡眠
刚刚说的S是浅度睡眠,D状态是深度睡眠,这个不常见到,只有公司里高IO的情况下容易见到。
举个例,进程A有个任务。向磁盘写入1000条重要数据,因为数据量大,磁盘慢慢去执行了,进程A就挂着在等待。此时操作系统内存吃紧,于是检查内存的闲杂人员,将大量暂时不用的进程挂起,但还是不够,再继续下去操作系统将崩溃,于是它杀死了很多挂起进程包括A。等磁盘写完1000条数据回来发现进程A死亡,于是数据就被丢弃了。为了防止重要数据被该情况丢弃,给部分特定进程挂上免死金牌,告诉操作系统不要杀死他,这种状态就是深度睡眠,理论上只有该进程自动醒来或者断电能杀死它,否则无法杀死。
当一个机器挂满大量D状态的进程时,机器离崩溃也不远了。
t (tracing stop) 暂停(进程正在被追踪)
当我们编译完一个程序,让它跑起来进入调试,此时会显示暂停状态,并且进程显示被追踪。
可以看到此时进程处于t 状态,此时可以查看程序代码上下文。
Z状态(僵尸进程)
一个进程执行完任务后,需要回报任务信息,虽然是否关心该任务信息不一定,但是需要回报。随后进程正常退出,按理应该进入死亡状态。这时如果没有人来回收它(它的父进程没回收,操作系统也没回收),那么它就会进入Z状态。
我们来写个程序看看僵尸进程是什么样的。
创建进程,运行一段时间让子进程退出,让父进程不要去回收它。
写个监控脚本方便查看:
while :; do ps ajx | head -1 && ps ajx | grep myproc | grep -v grep; sleep 1; done
可以看到,子进程状态由S+变成了Z+,因为父进程没有回收它。 defunct意思是死者,死亡的。
如果父进程一直不回收僵尸子进程,那么它就会一直占用系统的内存。僵尸进程的磁盘代码可能被释放了,但是PCB数据会保留下来一直占着空间,这会造成内存泄漏,所以僵尸进程是一个要解决的问题。
当进程标记为X(dead)状态时,所有进程资源包括PCB都释放了/
孤儿进程
上面的僵尸进程是父子进程运行时子进程先退出的情况,这里的孤儿进程则是父亲先退出的情况。
改一下代码,让父子进程一同运行下去:
此时杀掉父进程:
我们发现父进程是没了,子进程状态从S+变成了S。并且此时的子进程的PPID变成了1
查一下系统进程,发现PID为1的进程就是bash,也就是说失去父进程的子进程,它被操作系统领养了。
问题:为什么操作系统要领养孤儿进程?————如果没人回收,该进程会变成僵尸进程,长时间占用内存,所以由操作系统直接接手。
进程优先级
首先明确一点,优先级和权限不是一个概念。权限是系统是否让你执行,优先级是在允许执行的前提下排队。
为什么要有进程优先级?————CPU个数有限,匹配不了大量等待运行的进程,大量的人要抢占少量的资源,所以必须要有优先级,必须要排队。
Linux下的优先级
Linux下优先级是怎么设置的呢?其实也不是什么高大上的东西。就是PCB里的一项属性,用整数来标识优先级,Linux下使用PRI和NI两个整数。
ps -la查看进行优先级;
初始PRI是80,NI不一定,范围在[-20,19]。
最终优先级 = PRI(老的优先级)+NI 。(优先级[60,99])
sudo top可以更改进程优先级,通过调整NI值做到,注意超出NI范围会去到NI边界值,不会超出。
sudo top ---> r ---> 输入进程PID--->输入要更改的NI值,就可以改变优先级了。PRI始终是80,不会因为上一次改变而变化。
进程切换
进程的4个特性:竞争性、独立性、并行、并发。
1、竞争性。 上面说了,计算机中CPU数量一定是少于进程的,不可能一个CPU运行一个进程,所以大量待运行的进程要抢占少量的CPU资源,就存在竞争。
2、独立性。 多进程运行期间不会互相干扰。一个进程的退出不会影响其他进程,比如QQ进程无响应了,不会影响浏览器的运行。子进程退出,父进程依然会跑下去。
3、并行和并发。 多个进程在多个CPU下同时运行,这叫并行;多个进程在单个CPU下采用进程切换的方式运行,使得多个进程都能推进,这叫并发。
比如一台电脑上有2个CPU,那么就能有2个进程同时在运行(注意不是在runqueue中,而是真正在运行)。只有一个CPU那么同一时间只能有一个进程在运行!为什么我的电脑只有一个CPU却好像也同时运行着许多进程呢?————因为采用了进程切换的方式。CPU是纳秒级别的芯片,它的执行速度非常快,一个进程不会让它从头执行到尾再换下去,CPU要兼顾其他进程,所以可能一个进程CPU给几毫秒的时间运行,时间一到就换下去,让其他进程上来,没执行完继续排队等待下一次,否则要是必须执行完一个进程再切换那么死循环就出不来了。CPU实在太快了,瞬间能跑大量代码,所以在我们看来CPU好像同时在执行多个进程。
进程切换的执行
首先要知道CPU上有很多寄存器,可见的、不可见的,这些寄存器帮助存储临时数据、完成计算...
CPU调度进程,存储进程的数据也是通过寄存器。
CPU上有些关键寄存器,其中一个叫pc/eip,pc指针。它专门存储当前进程执行代码的下一条指令地址。
我们知道CPU不会主动执行什么命令,只会被动的接受发送的指令,然后根据内部指令集分析指令,最后执行。当进程在运行的时候会产生非常多的临时数据,而这份数据属于当前进程。
虽然CPU中有非常多的寄存器,但是寄存器硬件只有一套!寄存器数据是属于当前进程的,不是寄存器属于当前进程!一定要分请两个概念,寄存器硬件 != 寄存器数据。
上面说了进程都有自己的时间片,一到时间就会从CPU上剥离下来等待再运行。假如一个进程没执行完,它的数据会怎样呢?————一定会被操作系统保存下来,否则下一次上来运行的时候就不知道执行到哪了。
在任何时刻我们看到寄存器里存储的数据,都是只属于当前进程的,寄存器硬件被所有进程共享,但是寄存器数据只属于当前进程。所以进程切换要保存数据,也就是上下文保护,这个上下文就是指寄存器数据。当前进程下去了,进行上下文保护,回来继续运行了,进行上下文恢复。
我们知道寄存器是很小的,一个寄存器可能只有几字节,如果我写的代码开辟了很多变量数组,它要存储进程数据存的下吗?————不用担心,寄存器对进程代码是一部分一部分获取的,对于已经执行过的代码和尚未执行的代码也不会加载到CPU,所以不需要有容量考虑。