文章目录
一. OS进程状态
1.1 一般分类
在操作系统中,统一的一般会存在这几种状态:创建,就绪,阻塞,执行,终止。
😇简单讲讲,进程在刚开始处于创建状态,随后进入就绪状态,等待系统调用,调用后进入执行状态。在这里会进入一个岔口,如果是scanf等需要用户做出行为推进的进程,进程会进入阻塞状态等待用户输入;如果进程所用的时间片用完了,进程会回到就绪状态;如果进程结束(进程的任务结束了),就会进入终止状态。
👇OS进程中有三种状态值得一说:
1.2 运行状态
对于分时操作系统来说,OS需要一个调度器来保证CPU的资源被合理的调用,所以OS需要在内存维护一个运行队列,他将进程的task_struct结构体连接起来,此时,处于这个队列的进程就处于运行状态,这说明运行状态并不指的是正常运行的进程,而是处于运行队列中!处于运行队列中,说明该进程可以随时被CPU调度!!
1.3 阻塞状态
OS管理硬件的方式也是先描述再组织,先描述出硬件统一的数据结构,再以链表的形式将他们管理起来起来,在硬件的结构体中,还会维护一个阻塞队列,当硬件没有准备好数据的时候,该进程只能在阻塞队列中等待!!
——》所谓的阻塞状态,和运行状态一样,本质都是处于不同的队列中!!
——》当用户执行程序到scanf时候,如果用户一直不输出,那么进程就会一直处于阻塞状态!!
1.4 挂起状态
当OS内存资源严重不足时,OS会将阻塞队列中一些阻塞进程的数据与代码,从内存中搬出到磁盘中来节省空间,处于这个状态的进程就处于挂起状态。
-
导致OS内存资源严重不足的一种情况是存在大量处在阻塞队列的进程,所以我们要办法将一些资源置换到磁盘中,但是为了不影响阻塞队列的管理,所以大多数情况下并不是直接将task_struct结构体置换过去,而是将该进程的数据和代码先置换过去,而当执行到该进程的时候,再通过某种方式将其数据和代码置换回来。
-
磁盘中会有一个专门的分区swap来存储处于挂起状态的进程的代码与数据。
-
挂起状态是一种特殊的阻塞状态,他依然在阻塞队列中,只不过他的代码和数据都在磁盘中。
-
处于挂起状态的进程是不会告诉用户的!
-
代码和数据在磁盘中,这意味着每次需要调度的时候,就需要频繁的换入与换出,我们从冯诺依曼体系中可以知道,外设和内存的交互是要走IO线的,比起在内存中交互是很慢的!!因此,挂起状态这样解决内存空间不足的方法,是一个空间换时间的方法!!
1.5 并行与并发
调度器将进程放到CPU上去运行,并不代表必须要将进程全部运行完才会被放下来!!这是因为,在分时操作系统中,调度器要保证CPU资源被合理使用,不能让一个进程占用CPU太长时间。
——>那么什么时候应该把这个进程放下来,就要取决于时间片。当一个进程在超过时间片还没有结束的时候,就要把他放下去然后重新放在新的运行队列的位置。
CPU运行速度是很快的,所以这个过程我们感受不到, 而大量地把进程从CPU上拿上来在放下去的这个过程,称之为进程切换!多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,这个过程称之为 并发执行 ;多个进程在多个CPU下,分别同时进行运行,这称之为 并行执行!
——>并行与并发的区别在于,一个是单CPU,一个是多CPU。
二. Linux进程状态
上面所述的,是一般操作系统的进程中,统一的几种状态,在Linux操作系统中,当然对进程的状态有单独的设计,下面的状态在kernel源代码里定义:
/*
* 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 */
};
所有进程查看指令:
ps aux / ps axj
2.1 分类
2.1.1 R状态
R运行状态(running): 并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列
里。
这里写一个while循环能直观的看到这个状态:
2.1.2 S状态
S睡眠状态(sleeping): 意味着进程在等待事件完成。也是一种阻塞等待状态。这里的睡眠有时候也叫做可中断睡眠状态。
我们再上面的代码稍作修改,在while循环里执行printf,就能观察到这个现象:
接下来会引申出几个问题
❓Q1:为什么上一段代码是R状态,而在循环中加了printf就是S状态呢??
👉 因为printf需要一直和显示器建立联系,CPU计算的速度很快,相比之下,数据从走IO线再到显示屏打印数据的速度是很慢的,所以进程有很大概率一直处在等待状态,因为需要等显示器准备好(CPU太快了 所以显示器缓不过来)
❓Q2:键盘会因为用户不输入而卡着,那为什么显示器也会卡着呢??
👉 因为cpu的速度比外设快太多了,所以大多数的进程都在等待,另一方面我们当前的机器是云服务器,用xshell进行链接,从硬件角度上,不仅会走IO线,还要走网卡,速度是很慢的。
我们的bash进程同理,如果我们一直不输入指令的话,他也会卡在那里。
2.1.3 D状态
D磁盘休眠状态(Disk sleep)有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的
进程通常会等待IO的结束。
与S状态不同,S状态(浅度睡眠)是可以被唤醒的,而D状态(深度睡眠)是不可被唤醒的,唤醒的意思就是不能中途杀掉这个进程。
——》处于D状态的进程负责的任务都是磁盘级的数据运输。在进行磁盘级的大内存数据搬运时,该进程会等待数据搬运的IO结束,并且记录运行的结果(数据搬运失败,磁盘空间不足或者搬运成功),如果将这样的进程杀掉,如果数据搬运失败,数据就有丢失的风险。OS可能会在内存空间不足时,会杀掉部分进程,而D状态的进程不会被优先杀掉。
2.1.4 T状态与t状态
T停止状态(stopped): 可以通过发送 SIGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可
以通过发送 SIGCONT 信号让进程继续运行。
指令:kill -l
处于T状态的进程,可能是进程在运行中被用户强制终止,也可能是进程做了非法而不致命的操作,被OS终止了。
另外,处于gdb(调试)状态的进程,是t状态进程。
2.1.6 X状态
X死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
2.2 僵尸状态(Z)
2.2.1 概念
僵死状态(Zombies)是一个比较特殊的状态。当进程退出并且父进程没有读取到子进程退出的返回代码时就会产生僵死(尸)进程,僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。所以,只要子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态。
——》打个比方,如果一个人如果突然死亡,他的身体会先通过法医检测死亡原因,再将其交给家属,并告知死亡原因。回到进程的角度,死亡的人是子进程,他的身体通过法医检测的状态就是僵尸状态,将其身体交回给家属就是向父进程返回子进程信息!子进程信息返回后才会死亡!!
——》由此可见,一个进程在退出的时候并不是立即把所有的资源全部释放,而是要把当前进程的退出信息维持一段时间!!——>因为父进程需要关心子进程!!
2.2.2 查看状态
为了观察到这个现象,我们需要让子进程中途退出,用到exit函数:
——》这里需要说明,像此类退出程序的函数是没有返回值的,因为执行了这个函数,该进程就终止了,该进程本身就取不到返回值,设置返回值也就没有意义。
- 💬情况1,父进程存在,子进程中断。
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
pid_t id = fork();
if (id == 0)
{
int cnt = 5;
while (cnt)
{
printf("child,pid:%d,ppid:%d,cnt:%d\n", getpid(), getppid(), cnt);
--cnt;
sleep(1);
}
exit(0);
}
else
{
while (1)
{
printf("parent,pid:%d,ppid:%d\n", getpid(), getppid());
sleep(1);
}
}
return 0;
}
子进程退出,只要父进程不退出,没有处理子进程的返回信息,子进程就会一直处于Z状态,而存储子进程状态的PCB也就一直不会释放,就会一直占用内存!!
疑问1:为什么要有僵尸状态?
💡子进程需要处于僵尸状态,维护自己的task_struct,方便未来父进程读取退出状态。
疑问2:如果创建了很多的僵尸状态的子进程,不处理这些数据,会一直消耗内存(甚至内存泄漏吗)?
💡是的!!如果Z状态一直不退出,维护返回信息的PCB就一直不会释放,也就会一直消耗内存!!
💬情况2,父进程中断,子进程存在。
#include<stdio.h>
#include<unistd.h>
#include<stdlib.h>
int main()
{
pid_t id = fork();
if (id != 0)
{
int cnt = 5;
while (cnt)
{
printf("parent,pid:%d,ppid:%d,cnt:%d\n", getpid(), getppid(), cnt);
--cnt;
sleep(1);
}
exit(0);
}
else
{
while (1)
{
printf("children,pid:%d,ppid:%d\n", getpid(), getppid());
sleep(1);
}
}
return 0;
}
当父进程中断,子进程存在时,子进程会被系统进程“收留”,也就是其父进程会改为系统进程,这样的子进程也被称为孤儿进程!
疑问😕1:为什么会被系统进程收留?
💡父进程被终止,子进程不能没有进程管理,因为被管理,子进程的相关资源才能被回收,此时就会将子进程交给系统进程领养。
疑问😕2:在上面的实验中,我们可以观察到一个现象,子进程仍在运行,但是Ctrl C却终止不了它了,这是为什么?
💡这是因为,子进程被系统进程领养后,ppid被改为了1,它与bash进程(命令行进程)就不是直接的亲属关系了,我们的Ctrl C命令实际上也是对bash进程下达命令,因为bash管不了子进程了,Ctrl C也就终止不了它了。
疑问😕3:子进程是bash进程的孙子进程,为什么父进程消亡后不由bash进程回收而是交由系统进程回收?
💡因为bash做不到,因为孙子进程不是他去创建的! 他没有这个权限,而系统进程可以做到,因为要将孤儿进程托孤给系统进程。当然不同的操作系统具体的实现方法可能也不同!!
三. 进程的优先级
3.1 概念
1. 😕什么是进程优先级??
——》cpu资源分配的先后顺序,就是指进程的优先权(priority)
2. 😕权限 VS 优先级,它们的区别?
——》权限是我“能不能做”,而优先级是我“能不能先做”,它们对待的不是一种问题,优先级越高的进程,越优先被系统调用。
3. 😕分时操作系统不是保证进程公平调用吗,为什么要优先级?
——》因为绝大多数的时候,CPU资源是少数,而进程有多数,狼多肉少,进程与进程之间相互竞争,优先权高的进程有优先执行权利。配置进程优先权对多任务环境的linux很有用,可以改善系统性能。
3.2 查看系统进程
在linux或者unix系统中,用ps –l命令可以查看系统进程:
从上面的显示信息中,我们很容易注意到其中的几个重要信息,有下:
- UID : 代表执行者的身份——》记录进程是谁启动的。
- PID : 代表这个进程的代号
- PPID :代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号
- PRI :代表这个进程可被执行的优先级,其值越小越早被执行
- NI :代表这个进程的nice值
——》为什么会有UID?在Linux下,一切都是文件,是文件就有自己的文件属性,有自己的拥有者,所属组对应的权限。进程也是一种文件的体现!!我们所有的操作,都是进程执行的,进程也会记录自己的拥有者,所属组,以及它们所对应的权限!
3.2.1 PRI and NI
PRI也还是比较好理解的,即进程的优先级,或者通俗点说就是程序被CPU执行的先后顺序,此值越小
进程的优先级别越高
那NI呢?就是我们所要说的nice值了,其表示进程可被执行的优先级的修正数值。
PRI值越小越快被执行,那么加入nice值后,将会使得PRI变为:PRI(new)=PRI(old)+nice。
这样,当nice值为负值的时候,那么该程序将会优先级值将变小,即其优先级会变高,则其越快被执行,所以,调整进程优先级,在Linux下,就是调整进程nice值。
PRI初始值为80,nice值的取值范围为[-20,,19],这样的话,权限一共有40个等级。
3.2.2 top指令可以改变nice值
进入top后按“r”–>输入进程PID–>输入nice值
需要强调一点的是,进程的nice值不是进程的优先级,他们不是一个概念,但是进程nice值会影响到进
程的优先级变化。
可以理解nice值是进程优先级的修正修正数据。
四. 进程切换
关于进程切换,我们需要提到并发,并发是基于进程切换基于时间片轮转的调度算法,在单CPU中,我们先要有几点认识:
-
每个进程都有自己的时间片,时间片到了,进程就要被切换。
——》对于单CPU是必要的,因为如果让一个进程一直霸CPU,那么其他程序就跑不了,甚至操作系统都跑不了,不就死机了吗??所以在运行一个进程之后,到了时间片就会把它拿下来。 -
Linux是基于时间片进程调度轮转的,其实就是并发的。
-
一个进程的时间片到了,不一定就跑完了,可以在其他地方被重新调度切换
——》重新调度切换意味着,OS知道这个进程原先运行到哪里了(代码运行到那一段),轮到这个进程后再继续往下进行。
——》OS是如何做到这一点的??
讲一个例子:张三在大学读了两年成了大三,然后去参军,但是张三的学还没读完,所以学校将它的学籍信息保存好放入档案中,当张三参军,向学校申请继续读书,学校就可以拿出档案,知道张三是读完两年书的,然后把他放在新年级的大三就行了。在这个过程中,我们知道,学校保存张三学籍不是他的目的,只是将来恢复张三学籍进度才是目的。
——》回到进程角度,一个进程被重新切换回来的时候,OS是怎么知道原本他运行到哪里了??在进程切换中OS做了两件事情,1.保存个进程的上下文数据,2.对切换到的进程恢复上下文数据!!!
进程切换的核心,就是对进程上下文的保存与回复!!
——》进程如何进行上下文的保存与回复?
你的进程在运行的时候,会有很多的临时数据,在CPU的寄存器中保存。
CPU内部的寄存器的数据,是进程执行时的瞬时状态信息数据,而这些数据,就是进程的上下文数据!!CPU内有很多个的寄存器,只有一套寄存器(注意寄存器!= 寄存器里面的数据)
——》进程的上下文数据不会再留存在CPU中,会放到进程对应的task_struct,进程被切换回来的时候,接着上次没做完的事情,继续运行!
五 . Linux真实调度算法
我们知道了进程切换的本质,就是保存进程的上下文数据,在进行切换,那么由优先级影响的调度又是怎么一回事呢?
上图是Linux2.6内核中进程队列的数据结构。一个CPU拥有一个runqueue,如果有多个CPU就要考虑进程个数的负载均衡问题。
5.1 活跃队列
——》runquene中有一个数组叫做 struct quene array[0],array[0]是活跃进程,array[1]是过期进程。active指针永远指向活动队列,expired指针永远指向过期队列
1️⃣在时间片还没有结束的所有进程都按照优先级放在quene[140]。
——》😕进程是task_struct是如何通过优先级放在quene[140]???
首先,活跃队列中有这样的规定:
普通优先级:100~139(我们都是普通的优先级,想想nice值的取值范围,可与之对应!)
实时优先级:0~99(不关心)
2️⃣进程在准备运行时,会被挂起在活跃队列中下标[100,139]!!!相同优先级的进程会被挂到同一个下标中!!整个活跃队列的数据结构,其实是一个哈希桶!!!
3️⃣queue[140]: 一个元素就是一个进程队列,相同优先级的进程按照FIFO规则进行排队调度,所以,数组下标就是优先级!
——》nice的取值返回为什么是[-20,19],一共40个元素,也是与之对应的!!
——》😕从该结构中,选择一个最合适的进程,过程是怎么的呢?
- 从0下表开始遍历queue[140]
- 找到第一个非空队列,该队列必定为优先级最高的队列
- 拿到选中队列的第一个进程,开始运行,调度完成!
- 遍历queue[140]时间复杂度是常数!但还是太低效了!所以Linux不是用遍历数组找到非空队列的!是通过bitmap[5]这个成员提高效率(下面细说)
- nr_active: 用于记录总共有多少个运行状态的进程。
- bitmap[5]: 一共140个优先级,一共140个进程队列,为了提高查找非空队列的效率,bitmap[5]充当queue[140]的位图,用5*32(140)个比特位表示队列是否为空,当该位置为1时,表示该进程有进程,这样,便可以大大提高查找效率!
——》nr_activebitmap[5]queue[140]就是活跃进程的三个成员!
5.2 过期队列
1️⃣在过期队列和活动队列结构一模一样
——》🤔为什么要存在过期队列呢??这需要聊聊进程调度的整体逻辑。
过期队列上放置的进程,都是时间片耗尽的进程,当进程在活跃队列的运行后,时间片耗尽并且进程没有结束,该进程会从活跃队列卸下,然后被挂进过期队列。
当活跃进程中的nr_active为0时,代表着一次运行周期结束,活跃进程中已经没有运行进程了(都跑到过期队列),随后做一次swap(&active,&expired),交换过期进程和活跃进程,让过期进程成为新的活跃进程,再进入下一轮的运行周期。
active队列永远都是一个存量进程竞争的情况,即:active队列中进程只会越来越少,不会增多,进程会退出,也会因为时间片而到挂到过期队列。
——》这里会诞生一个问题:如果cpu正在运行活跃队列时,有一个新的进程插入,那这个进程是要直接“插队”,直接挂到活跃队列吗🤔?
—》 如果不断的有进程插入,这些进程的优先级又很高,优先级低的进程会不会一直都调不到😧(进程饥饿问题)??
是的!所以新插入的进程在一个运行周期中不会被放入活跃队列,而是会挂进过期队列中!!等到下一个运行周期来到时,才会运行这个进程!!
整段逻辑
:
总结:在系统当中查找一个最合适调度的进程的时间复杂度是一个常数,不随着进程增多而导致时间成本增加,我们称之为进程调度O(1)算法!
5.3 补充知识
在之前的OS概念阐述中,我们知道OS管理进程的方式是描述对应的task_struct,后以全局链表的形式管理,进程与进程还是线性的关系,
但是在上面的调度算法中,管理进程的结构又是哈希桶,这是怎么做到的?是怎么让进程即在全局链表中,又在其他的数据结构中的??
——》进程与进程链接的关系,并不是该节点的next成员指向下一个节点:
而是在task_struct中,设计了一个结构体,这个结构体只有链接字段,没有属性字段!!
在此基础之上,就可以设计多个链接字段的结构体:
这样就可以做到,一个进程,既可以在全局链表中,又可以在任何一个其他数据结构中,只要加节点字段即可!
——》但是又出现一个新的疑问:通过这个链接字段,我们怎么访问到task_struct的其他变量数据?🤔
首先知道一个事实:在C语言中,结构体内的变量的相关数据是连续的,变量与变量的相对位置存在偏移量。
那么我们就运用这一事实,通过偏移量找到对应变量的地址就可以了!!!
——》怎么做的??
1、将0强转成task_struct结构体的类型,其实就是假设在0位置都有一个task_struct结构体大小的内存,然后找到他的node节点并取他的地址,由于低地址是0,所以找到node节点的地址就其实就相当于知道了node在task_struct的偏移量 ——> &(task_struct*)0—>node
2、当前找到的位置然后用当前指向的位置(比如start) 减去这个偏移量,就可以找到该结构体的头部。——>start-
3、最后将这个头部的地址强转成task_struct* 就可以拿到整个PCB结构体了 ,就能访问里面的其他数据
总结: ( task_struct * )(start-&(task_struct * )0—>node)——>other
本文就到这里,感谢你看到这里❤️❤️! 我知道一些人看文章喜欢静静看,不评论🤔,但是他会点赞😍,这样的人,帅气低调有内涵😎,美丽大方很优雅😊,明人不说暗话,要你手上的一个点赞😘!
希望你能从我的文章学到一点点的东西❤️❤️