进程状态反映进程执行过程的变化。这些状态随着进程的执行和外界条件的变化而转换。
系统操作原理:进程的状态和转换(五态模型)
在普遍的操作系统中,我们所遇到的进程状态有:运行、新建、就绪、挂起、阻塞、停止、挂机、死亡…等等,但是我们并不懂它们(学了等于没学),因为这是操作系统层面的说法,它的理论放到哪个操作系统中都对。所以我们要学习一个具体的操作系统来理解进程状态,而这里我们使用的当然就是Linux!
进程的状态有多种,本质都是为了满足不同的运行场景!
常见的外设有键盘、显示器、网卡、磁盘…等,操作系统也要对这些外设进行管理。那操作系统怎么管理呢?
答案是:先描述,再管理。所以,在操作系统内都包含了每一个硬件的 struct 结构体,这里比如 struct div_keybord… 等,这些结构体都包含了该硬件的所有属性,这些属性可以找到驱动上该硬件的所有匹配的方法,然后操作系统可以对硬件进行各种操作。
根据冯诺依曼体系结构,所有的数据结构和内部属性数据都在内存当中,因为操作系统开机就被加载到了内存中。
假设磁盘中有一个 bin.exe 文件要运行,它先被加载到内存里,操作系统要对该进程进行管理,要管理就要先描述,这里PCB假设是 struct task_struct {};这个进程想要在 CPU 上运行起来,就必须加入到 运行队列(runqueue) 中,这个运行队列是 CPU 为管理进程而产生的,是内核给 CPU 准备的。为什么进程运行要进行排队?因为 CPU 的资源是有限的。
这里假设只有单核的 CPU (一个CPU),不考虑多核的情况,一个 CPU 只有一个运行队列,进程想要运行就必须加入到运行队列中,这里的运行队列假设是 struct runqueue{},这个运行队列包括了 struct task_struct head* 和 其他属性。
让进程入队列,本质是:将该进程的 task_struct 结构体对象放入到运行队列中
注意:进队列排队的是 task_struct 结构体(也就是PCB),并不是让可执行程序去排队,这个 task_struct 结构体包含了该进程的代码和所有属性,运行队列中有一个头指针 struct task_struct head*,head 指针指向第一个进程,第一个进行的属性中又包含了下一个进程的指针,保证可以链接到下一个进程,依次往后就可以找到所有进程。
举个栗子:比如你要找工作了,你是把你的简历投到公司的邮箱里,而不是把你自己投到邮箱里。公司有自己的简历池,假设有一万份简历,HR 对这些投递的简历进行排序,HR 觉得你的简历不错,通过简历池选出你进行面试,HR 再把你的简历扔给面试官。你的简历上有你的电话、姓名、邮箱…等等,通过这个简历里面的方法可以找到你这个人。这个面试官相当于 CPU,HR 就相当于操作系统的调度算法,你的简历就相当于 PCB 结构体 task_struct。面试官拿到的是你的简历,简历上有你的全部数据和属性,通过简历上的属性可以找到你这个人,PCB 结构体 task_struct 也是如此,它有进程的全部数据和属性,通过该进程的属性可以找到该进程所对应的代码和数据等等。
下面给出结构图,帮助理解,结合上面的文字理解:
总所周知,CPU 虽然很笨,但是他很快,很快意味着 CPU 很快轮转一遍进程(进程切换再详谈),运行队列里的进程随时随刻都要准本好,让 CPU 随时调度运行,此时在运行队列里的一个个进程就叫做运行状态(running)
区分一个概念:一个进程正在 CPU 上运行,它一定是运行状态,但是一个进程没有在 CPU 上运行,但他已经在运行队列里面了,这个进程也是运行状态 !
状态是进程的内部属性,进程的全部属性又在 PCB 结构体 task_struct 中,所以状态就是在 PCB 结构体里面,这个状态可以用整数 int 进行表示,比如 int(1:run 2:stop 3:dead …),用整数表示一个具体的状态,整数是几,就意味着状态是什么。
根据冯诺依曼体系结构,CPU 很快,但是外设(显示屏,磁盘,键盘…)相较于 CPU 是很慢的,而且这些外设也是只有少量的。进程或多或少都要访问这些外部设备,比如有十几个进程想要访问磁盘,但是磁盘被 进程A 用着,进程B 和 进程C 又来了,它们也要访问磁盘,还有进程D、E、F… 要访问磁盘,磁盘没有准备好,进程B后面的进程也只能进行等待
总结:不要只以为你的进程只会等待(占用)CPU 资源 ,你的进程也可能随时随地会访问外设资源
假设有十几个进程想要访问磁盘,磁盘该怎么办呢?操作系统对外设进行管理也是先描述再组织,操作系统里面有管理每个外设的结构体,每个外设的结构体都有一个等待队列,这些结构体可以对访问该外设的进程进行管理,假设等待队列为 task_struct wait_queue* ,那么此时这些在等待外设的进程就会加入到 wait_queue ,变成阻塞状态,等待资源!
这个过程也是对该进程的 PCB 结构体对象放到不同的队列中,并不是拿该进程的数据和代码
磁盘忙完了,发现进程A 在等待磁盘,此时磁盘是空闲的,磁盘就告诉操作系统,进程A可以运行了,然后操作系统就走进程A,把进程A的阻塞状态改为运行状态,然后放入 CPU 的运行队列里,进程A 就等待 CPU 运行
结论:所谓的进程不同的状态,本质是进程在不同的队列中,等待某种资源
场景假设:假设大量的进程处于阻塞状态,这些进程都在等待某种资源资源,进程B 是这些进程里的一个进程,也是处于阻塞状态
在这个过程中,进程B 也短时间内不会被调度,也就是不会被 CPU执行,该进程的 代码和数据 短期内不会被使用。
假设此时的内存空间不够了,又有新的程序加载到内存里,操作系统最大的特点就是为我们节省内存空间。这时操作系统发现进程B 短时间内不会被调度,进程B 的代码和数据短时间内也不会被执行,内存空间却一直被你进程B 占用,操作系统说:这不是占着茅坑不拉屎吗?
这时操作系统就做了一件事,把 进程B 的代码和数据暂时保存到磁盘中,为其他进程腾出内存空间,腾出的空间可以被其他进程使用
我们把 一个进程的代码和数据暂时换出到磁盘的这个过程叫做该进程被挂起,该状态就是挂起状态
注意:该进程B只是数据和代码被换出到磁盘,进程B 内存中的内核数据结构还存在,也就是 task_struct 依旧存在
阻塞状态和挂起状态有什么区别?
答:阻塞不一定挂起,挂起一定阻塞
所以小结一下,操作系统中的进程状态的概念是这样子的:
其他状态在下面结合linux谈!
为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程可以有几个状态(在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 */
};
Linux 的运行状态由一个指针数组构成,task_state_array[0] 就代表运行状态,其他也是如此。
Linux 状态总览图:
而在 Linux 中查看进程的状态用的指令是 ps ajx 或者 ps aux
♐️ 并且我们可以发现,Linux 中并没有所谓的就绪状态、挂起状态等等说法,这是因为 OS 其实主要是为了提供一个总体概念的说法,而具体到某个 OS 上面操作的时候,不同的 OS 的进程状态的设定是不一样的,但是都是基于总体概念的,而 Linux 就有 Linux 独特的进程状态说法!
定义:并不意味着进程一定在运行中,它表明进程要么是在运行中要么在运行队列里
运行状态上面的 遍操作系统层面的进程状态(宏观)已经谈过了,自己往上翻阅
接下来我们看一下一个具体运行状态
测试代码:
#include
int main()
{
int a = 0;
while(1)
{
a += 2;
}
return 0;
}
运行后我们用 ps ajx 来查看一下进程状态,这里的 STAT 代表的就是进程状态,这里的 R+ 就是运行状态,至于 + 号是什么意思,下面会解释!
定义:意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠(interruptible sleep))。
下面在 Linux 测试一下该状态:
#include
int main()
{
int a = 0;
while(1)
{
a += 2;
printf("当前值为:%d\n", a);
}
return 0;
}
运行并查看进程,当前进程状态处于 S,叫做睡眠状态,睡眠状态也是阻塞状态的一种。因为 printf 要访问显示器,显示器是外设的一种,外设有个特点就是相较于 CPU 比较慢,慢就要等待显示器就绪,等待就要花较长的时间。我们这个程序可能只有万分之一的时间在运行,其它时间都在休眠,站在用户的角度它是 R,但是对于操作系统来说它不一定是 R,它有可能在队列中等待调度。
并不是每次测试的时候打印状态都能打出 S,只不过这个概率很高,因为这个状态是瞬间的,有可能那个瞬间是在执行代码而不是在等 IO就绪 ,所以有时候测试得到的状态可能是 R 。
定义:有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束。
上面的 S 状态也叫 浅度睡眠,进程可以被杀掉,这里的 D 状态叫做深度睡眠,表示该进程不会被杀掉,即便是操作系统也不行,只有该进程自动醒来才可以恢复或者给机器断电
假设场景:进程A需要向磁盘写入10万条用户数据,这些数据对用户很重要。进程A 访问磁盘,等待磁盘写入数据,进程A 等待磁盘返回一个结果,数据是否写入成功,此时进程A 处于休眠状态S;此时突然内存空间不足了,挂起也无法解决内存空间不足的问题,操作系统会自主杀掉一些进程(特别是内存资源不用的,比如进程A),操作系统就把进程A 给杀掉了,造成了磁盘写入数据失败,磁盘给进程A 返回结果,发现进程A 没有应答,磁盘只能把这些数据丢弃,然后磁盘继续为其他进程提供服务。结果,这重要的 10万条数据皆丢失了。
为了防止这种情况的发生,Linux 给进程设置了深度睡眠 (D) 状态,处于深度睡眠状态的进程既不能被用户杀掉,也不能被操作系统杀掉,只能通过断电,或者等待进程自己醒来
深度睡眠状态一般很难见到,一般在企业中做高并发或高IO的时候会遇到,这里就不演示了
注:一旦机器大量进程处于 D状态,说明机器已经处于崩溃的边缘了
定义:可以通过发送 SIGSTOP (signal stop)信号给进程来停止(T)进程。这个被暂停的进程可以通过发送 SIGCONT (signal continue)信号让进程继续运行。
而这两个信号是在 kill 指令中的,我们可以用下面的指令来查看 kill 的指令选项:
kill -l
今天介绍的两个信号为 18、19, 18信号是继续,19信号是暂停
测试代码:
#include
int main()
{
int a = 0;
while(1)
{
a += 2;
printf("当前值为:%d\n", a);
}
return 0;
}
进程运行了,给进程发送 19号信号(暂停)
kill -19 进程的PID
再给进程发送 18号信号(继续),进程又继续运行起来,这里的 +不见了,后面解释,这时候进程就无法用 [Ctrl]+c 杀掉了,只能用 9号信号杀掉进程
kill -18 进程的PID
可以看出 T 状态也是阻塞状态的一种,但是有没有挂起完全不知道,这是由操作系统自己决定的!
详细的测试自主尝试(用循环配合进程状态即可看到效果)~~
追踪暂停状态t 是也是一种暂停状态,不过它是一种特殊暂停状态,表示该进程正在被追踪。比如在使用 gdb 调试打断点的时候,程序暂停下来的状态就是 t状态,这里就不细谈了!
这个状态只是一个返回状态,你不会在任务列表里看到这个状态
死亡状态只是一个返回状态,当一个进程的退出信息被读取后,该进程所申请的资源就会立即被释放,该进程也一瞬间就不存在了,所以你几乎不会在任务列表当中看到死亡状态
我们创建一个进程的目的是为了让其帮我们完成某种任务,而既然是完成任务,进程在结束前就应该返回任务执行的结果,供父进程或者操作系统读取;所以进程退出的时候,不能立即释放该进程的资源,该进程要保存一段时间,让父进程或操作系统读取该进程的执行结果(保存一段时间是对于CPU 而言)
僵尸状态 Z 就是进程退出时,该进程的资源不能立即被释放,该进程要保留一段时间,等待父进程或操作系统读取该进程结果的过程就叫僵尸状态。
比如,有一个人在跑步中倒下了,没有了呼吸,这个人已经离开这个世界了(进程退出),警察来到现场对首先这个人退出的原因进行调查(父进程或操作系统读取该进程的执行结果),而不是立即进行对这个人进行火葬(资源清理)
处于僵尸状态的进程,我们就称之为僵尸进程
僵死状态(Zombies)是一个比较特殊的状态。当进程退出并且父进程(使用wait()系统调用,后面讲)没有读取到子进程退出的返回代码时就会产生僵死(尸)进程。僵死进程会以终止状态保持在进程表中,并且会一直在等待父进程读取退出状态代码。
下面用例子测试僵尸进程
测试代码:
#include
#include
#include
int main()
{
int id = fork();
if(id == 0)
{
printf("child, pid=%d, ppid=%d\n", getpid(), getppid());
sleep(5);
exit(1);
}
else
{
while(1)
{
printf("parent, pid=%d, ppid=%d\n", getpid(), getppid());
sleep(2);
}
}
return 0;
}
运行代码后,可以通过以下监控脚本查看:
while :; do ps axj | head -1 && ps axj | grep myproc | grep -v grep;echo "################";sleep 1;done
当子进程退出后,子进程的状态就变成了僵尸状态 !
这里只讲了僵尸状态是一个问题,没有谈怎么解决,后面进程控制谈解决。
前面子进程退出,父进程还在运行,但父进程没有读取子进程状态,子进程进入Z状态,该进程就为僵尸进程,如果反过来。父进程先退出,子进程还在执行,这是什么进程?
定义: 父进程先退出,子进程就称之为“孤儿进程”,孤儿进程会被1号 init进程(有些版本高的叫做systemd)领养,由 init进程回收,1号进程就是操作系统。
下面用例子测试孤儿进程(其实就是将exit()放到了父进程中)
测试代码:
#include
#include
#include
int main()
{
int id = fork();
if(id == 0)
{
while(1)
{
printf("child, pid=%d, ppid=%d\n", getpid(), getppid());
sleep(5);
}
}
else
{
printf("parent, pid=%d, ppid=%d\n", getpid(), getppid());
sleep(2);
exit(1);
}
return 0;
}
运行代码后,同样可以通过监控脚本查看过程:
可以看到 5 秒前有 2 个进程,5 秒后父进程死亡了(这里没有被僵尸的原因是父进程也有父进程 25369 -> bash),只有 1 个子进程。这里我们称没有父进程的子进程为孤儿进程也就是 1127,此时孤儿进程会被 1号进程领养,它是 systemd(操作系统),此时操作系统就可以直接对 1127 回收资源。 且进程状态会由前台转换为后台,后台进程可以使用 kill -9 来结束进程。
这种现象是一定存在的,如果不对子进程进行领养,对应的僵尸进程便没有人能回收了。如果是前台进程创建子进程,如果子进程变孤儿了,子进程会自动变成后台进程
操作系统启动之前是有 0号进程的,只不过完全启动成功后,0号进程就被1号进程取代了,具体的取代方案,后面学习 进程替换时再谈。可以看到 pid 排名靠前的进程都是由 root 来启动的。注意在 Centos7.6 下,它的 1号进程 叫做 systemd,而 Centos6.5 下,它的 1号进程 叫做 initd。
面试题 :什么样的进程杀不死 ❓
D状态进程 和 Z状态进程。因为一个是在深度休眠,操作系统都得叫大哥,一个是已经死了。
在 linux 或者 unix 系统中,用 ps –l 命令则会类似输出以下几个内容:
我们很容易注意到其中的几个重要信息,有下:
- UID(user id) : 代表执行者的身份
- PID (process id): 代表这个进程的代号
- PPID (parent process id):代表这个进程是由哪个进程发展衍生而来的,亦即父进程的代号
- TTY:可以理解为终端设备
- CMD:是当前进程的命令。
- PRI(priority) :代表这个进程可被执行的优先级,其值越小越早被执行
- NI (nice):代表这个进程的nice值
下面我们就要对 PRI 和 NI 进行详细讲解:
使用 top命令 配合 ‘r’ 可以调整已存在的进程的 nice 值:
现在我们按下 r 键,并输入对应进程的 PID 即可进行 nice 值的修改!
注意: 一般来说需要 sudo 一下才能修改 nice 值!
那我们如何去理解这个进程切换的过程呢,下面我们来详细讨论一下:
首先我们先要知道,CPU 中是有大量的寄存器的,比如说常见的BP、SP等等,如下图:
而寄存器一直都是在做三件事情:①取指令 ②分析指令 ③执行指令
这个时候如果有一个进程需要我们加载到内存的话,那么就会有寄存器指向该进程的结构体PCB,如下图:
比如当我们在执行该进程的某条指令的时候,会有一个寄存器叫做 pc/eip 指向 当前正在执行指令的下一条指令的地址!
♻️ 要注意的是,当我们的进程在运行的时候,一定会产生非常多的临时数据(比如返回值等等),这些数据都只属于当前线程!
换句话说,CPU内部虽然只有一套寄存器硬件,但是寄存器里面保存的数据,只属于当前进程,而不属于其他进程!
还有一个点:寄存器硬件 != 寄存器内的数据
虽然进程在运行的时候占用 CPU,但是该进程并不是一直要占用 CPU 直到进程结束的!比如说,我们用 while(1) 写一个死循环,要是按照前面的说法,进程要是一直占用 CPU 直到进程结束才释放,那么结果就会导致一旦我们写了死循环,我们就无法操作其他进程了,其它进程也就卡死了。
但是真实的情况是就算我们写了死循环,一般也是不会卡死的,我们还是可以操作其他的进程的,比如在 VS 写了死循环后,我们还是照样可以上 qq,打游戏等等。这是 因为进程在运行的时候,都有各自的时间片!
其实非常好理解,也就是每个进程轮流占用 CPU 的时间。比如说当前有 100 个进程正在运行队列中,假设进程的时间片是 10ms,也就是每个进程会分别被轮流执行 10ms,那么一秒之内每个进程都会被执行一次。
*️⃣ 而我们要关注的重点不只是进程的执行时间,还有进程每次被调度执行的时候,有可能这个进程还没有跑完指令,它的时间片就到了,那么当下次又轮到该进程被调度执行的时候,那些当时没跑完的指令和数据是我们怎么办呢?
我们先举个例子,比如说张三大一的时候挂了一门学科,但是他因为身体好想去义务征兵,于是在没有告知老师和学校的情况下,和同学吃顿散伙饭就走了,等到当兵回来发现,自己已经挂了二十多门课了。这种情况的发生就是因为张三走的时候没有告知老师和学校,他留在学校的一堆数据还没有得到处理,进而导致了这种后果。所以为了避免这种后果,张三在离开学校的时候需要向学校告知,让学校保存档案数据!
和上面的例子对比一下,其实张三就是进程,而学校就是CPU,兵队就是运行队列或其他队列,当进程在CPU执行的时间片到了之后,进程会重新回到运行队列中排队,相当于张三征兵;而进程离开CPU的时候,按我们所知的,为了避免留在CPU的数据被误处理,每次进程离开CPU的时候都要将数据保存和带走(至于数据存放到哪去我们不关心,可以看作数据暂时放回了PCB中,但实际上是操作系统在管的这些数据),这样子才能避免数据被误处理,这个过程叫做 数据的上下文保护!
而当张三回到学校的时候,也就是重新轮到该进程被CPU调度的时候,那么张三就需要先去通知学校,让学校安排复学手续,相当于进程回到CPU调度的时候要将原来的数据带回来恢复,继续往下执行,这个过程叫做 数据的上下文恢复!
总的来说,也就是 当进程在切换的时候,要进行进程的上下文保护;当进程在恢复运行的时候,要进行上下文的恢复!
在任何时刻,CPU里面的寄存器中的数据,看起来是在寄存器上,但是其实寄存器中的数据,只属于当前运行的进程!所以可以看出寄存器被所有进程共享,寄存器内的数据,是每个进程各自私有的-----上下文数据!
♻️ 总结: 当一个进程需要切换到另一个进程时,操作系统会执行以下几个步骤:
进程切换是操作系统内部自动完成的,用户不需要关心这个过程。不过,进程切换会消耗一定的时间,因此,操作系统会根据实际情况来决定是否执行进程切换,以便尽量降低进程切换对性能的影响。