【Linux】进程概念(三):深入剖析操作系统学科的进程状态理论体系与 Linux 系统下的浅度睡眠、深度睡眠、停止、僵尸、死亡等具体进程状态
文章目录
- 前言
- 一、创建多进程示例代码
- 二、操作系统学科的进程状态
- 理解什么是进程状态
- 操作系统状态理论图
- 运行状态
- 新建状态
- 阻塞状态
- 挂起状态
- 三、linux进程状态
- 浅度睡眠状态(S)
- 深度睡眠状态(D)
- 停止状态T和t
- 僵尸状态(Z)和死亡状态(X)
- linux系统没有对应的状态
- 孤儿进程
前言
一、创建多进程示例代码
上一节我们只创建了1个进程,接下来小编会一次性创建10个子进程,来引出这一节内容的讲解,在此之前小编先抛出一个结论:当我们创建出子进程之后,父子两个进程谁先运行不确定,由OS根据调度原则来确定。
#include <unistd.h>
#include <stdio.h>int main()
{int i = 0;for(i; i < 10; i++){pid_t id = fork();if(id == 0){//子进程while(1){printf("我是子进程 pid: %d ppid: %d\n", getpid(), getppid());sleep(1);}}else{//父进程printf("子进程创建成功,它的pid: %d\n", id);}sleep(1);}//十个子进程创建成功while(1){printf("我是父进程 pid: %d ppid: %d\n", getpid(), getppid());sleep(1);}return 0;
}
下面是代码流程图:
运行结果如下:
左图的监视窗口,指令:while :; do ps ajx | head -1 && ps ajx | grep myfork; sleep 1; done
二、操作系统学科的进程状态
理解什么是进程状态
首先我们要明确什么是进程状态,状态,决定了接下来进程要做的工作,所以进程状态并不等于接下来进程要做的工作。
理解了这一点之后小编想说进程状态理起来其实很简单,本质就是task_struct内部定义的一个数字(整型变量),只不过这些数字是由各自代表的状态宏定义出来的。所以改变进程状态本质就是修改task_struct内表示进程状态的数字,后面系统会根据状态数字来决定让进程做什么工作。
操作系统状态理论图
下面小编展示一张学校教材中的操作系统状态理论图,所有操作系统在实现进程状态变化的时候,都要符合图中的理论
:
所以上图只是一种指导思想,没有落地方案,不会去讲具体某种状态是如何设计的。所以操作系统这门学科本质就是一种指导思想,我们需要掌握具体实现才能解决现实世界的问题,小编接下来就会尽可能的介绍操作系统的部分实现,使读者能理解现实世界的操作系统。
运行状态
根据冯诺依曼体系结构,计算机被分为5大结构,其中外设所对应的输入、输出设备、CPU所对应的运算器、控制器本质是计算机的两大资源。而进程的各种调度、切换本质就是在竞争这两大资源。所以我们写的代码可以依此分为两类:
1、竞争CPU资源——计算密集型:算法、数据结构代码
2、竞争外设资源——IO密集型:访问各种外设的代码
因为进程要竞争CPU资源,所以操作系统会给每一个CPU设置一个调度队列。如今一款操作系统内部大概率会有多个CPU,也就表示会有多个调度队列,操作系统要管理这些调度队列也得遵循先描述再组织,所以操作系统内还会有一个名为runqueue的结构体,runqueue内会包含->(队列的所有属性、整型变量num表示有多少个队列、task_struct的结构体指针),其中task_struct的结构体指针是为了保存进程的task_struct数据(方便把task_struct链入unqueue中),让进程在调度队列里排队。如果进程要在调度队列里排队,那么进程的状态必须是运行状态( r )。 所以进程运行状态的定义就是: 进程的PCB必须处在CPU的调度队列中,也就是说该进程随时准备被CPU调度执行。
上面有关进程状态和CPU调度队列的说法只是蜻蜓点水,要理解透彻进程状态还需要知道在linux操作系统中,task_struct是如何在全局用双链表管理起来的。这里linux内核对于task_struct的链表管理有点反直觉,它并不是像我们之前介绍双向链表那样把每一个task_struct看成一个整体再头尾相连,而是让结点数据与链表本身解耦,先创建一个双链表结点,每个结点里没有存储数据值,只存储了next和prev指针,然后把双链表结点内嵌进task_struct中,示意图如下:
这种方式实现双链表的价值是可以让结构体结点类型和链表本身解耦,因为list_node可以内嵌进任意结构体变量中。更重要的是,如果task_struct中不止一个list_node变量,就可以让task_struct既属于全局管理所有进程的双向链表,同时也属于其它数据结构,这里的其他数据结构就完全可能是CPU的runqueue,所以就可以实现让一个进程的task_struct不断链脱离全局双向链表的情况下,把它链入runqueue里。
新建状态
有了上面的认识小编再输出关于进程新建状态(就绪状态)的定义:
进程一开始在全局的双链表,没有被链入CPU的调度队列时就是处在新建状态。小编这里再补充一个问题,当内核需要遍历由所有进程组成的双向循环链表时,必须通过 list_head 的地址反向推导 task_struct。在实际开发中我们可以使用标准库提供的 offsetof 宏来解决,offsetof用于计算结构体中成员相对于结构体起始地址的偏移量(以字节为单位)。但是我们有必要了解一下offsetof的底层实现原理。
设计offsetof主要需要解决两个问题: 如果我们知道结构体变量内部任意一个成员的地址和结构体类型:
1、如何获取该结构体变量的起始地址?
2、如何访问该结构体变量内部任意一个成员?
实际上只要解决了第一个问题,第二个问题也解决了,因为结构体的内存布局是固定的——每个成员相对于起始地址的偏移量(距离)是确定的(由结构体类型定义和内存对齐规则决定)。有了起始地址这个 “基准点”,结合成员的偏移量,就能准确定位到任意成员的内存地址,进而访问该成员。我们看下面这个示例代码:
struct Student {int id; // 偏移量0char name[6];// 偏移量4(int占4字节)float score; // 偏移量10(char[6]占6字节,加4得10,满足float的4字节对齐)
};int main() {struct Student s;struct Student *p = &s; // p指向结构体起始地址(基准点)// 访问成员时,编译器自动计算地址:p->id = 1001; // 实际地址:p(起始地址) + 0 → 写入idp->score = 90.5;// 实际地址:p(起始地址) + 10 → 写入scorereturn 0;
}
所以现在的主要矛盾就是解决第一个问题,其实很简单,依据就是通过计算偏移量然后在原结构体中减去偏移量解决:
struct test strart = &c - &((struct test)0->c)
阻塞状态
下面小编来介绍阻塞状态,在介绍阻塞状态之前小编需要铺垫一些前菜,我们知道操作系统是计算机软硬件资源的管理者,前面介绍的对于进程的管理是管理软件资源,那么对于硬件资源的管理也需要遵循先描述再组织,所以linux内核一定有描述硬件的结构体变量并且被链表链接管理起来,示例结构如下:
其中最重要的是硬件结构体内部的等待队列,这也是进程阻塞状态关键的一环。
前菜铺垫完后现在我们要知道进程什么时候会发生阻塞,以最简单的输入输出代码为例:
int main()
{int a;scanf("$d", a);printf("%d\n", a);return 0;
}
当程序运行到scanf函数时会停住,等待键盘的输入,这时的程序进程就处在阻塞状态,原本链在CPU调度队列中的该进程的task_struct就会断链,该进程不再参与CPU的调度竞争CPU资源,而是链进键盘的硬件描述结构体的等待队列中,等待竞争外设资源,并把进程的运行状态设置为阻塞状态。当我们在键盘中输入数据后,操作系统知道键盘中有数据了就会把链在硬件等待队列的task_struct重新链回CPU的调度队列,并把进程的阻塞状态设置为运行状态。
所以进程在运行状态和阻塞状态之间变换的本质是:
1、更改进程task_struct的状态属性。
2、把进程task_struct链入不同的队列中。
挂起状态
下面我们再聊一聊进程挂起状态,有了前面关于运行和阻塞状态的认识后挂起状态很容易理解。我们知道进程在内存的代码和数据是从磁盘加载进来的,进程的task_struct是内核主动在内存中创建并初始化的,但是当内存中有大量进程内存容量严重不足时,这时操作系统就会把暂时不会被CPU调度的进程也即是在阻塞状态的进程进行处理,会把在阻塞状态的进程在内存中的代码和数据交换(swap out)进磁盘的swap分区,这时腾出来的内存空间就可以被马上要被调度的进程使用,从而缓解内存容量压力。虽然这样会让程序整体运行效率变慢,但是总比内存空间被占满系统直接崩溃要好一些。
这种PCB在内存中,但是代码和数据被换出内存的进程处于挂起状态。当内存严重不足时,操作系统会自动换出一部分进程的代码和数据。其中挂起状态又分为两种,一种是前面介绍的只把处于阻塞状态的进程挂起,叫做阻塞挂起状态,还有一种是当把所有阻塞进程都挂起都不足以缓解内存空间不足时就需要把在CPU调度队列中排在后面的进程也挂起,这叫做就绪挂起状态。
三、linux进程状态
前面我们认识了操作系统学科对于进程状态的理论规定。下面我们来具体介绍具体操作系统对于该理论的实践落实,我们以linux进程状态为例,下面是在kernel源代码里对于进程状态的定义:
(补充: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 */
};
- R 运⾏状态(running): 并不意味着进程⼀定在运⾏中,它表明进程要么是在运⾏中要么在运⾏队列⾥。
- S 睡眠状态(sleeping): 意味着进程在等待事件完成(这⾥的睡眠有时候也叫做可中断睡眠(interruptible sleep))。
- D 磁盘休眠状态(Disk sleep)有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束。
- T 停⽌状态(stopped): 可以通过发送 SIGSTOP 信号给进程来停⽌(T)进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运⾏。
- X 死亡状态(dead):这个状态只是⼀个返回状态,你不会在任务列表⾥看到这个状态。
浅度睡眠状态(S)
我们会发现linux进程状态和操作系统的理论有一定出入,下面小编先带带大家来认识一下睡眠状态。 我们先以一段代码为例:
int main()
{while(1){printf("hello world\n");}return 0;
}
运行结果如下:
我们看到这段死循环的进程的状态是S,那这个S对应操作系统理论进程的哪一个呢?我们先分析这段代码,我们知道死循环打印会高频访问显示器这个硬件设备,但是硬件设备速度远小于CPU,系统在这段进程期间一直会将进程的task_struct从CPU的调度队列转移到显示器硬件的等待队列和从显示器硬件的等待队列转移到CPU的调度队列,所以大部分时间硬件设备都是处于不就绪的状态,我们查询时大概率该进程的task_struct都处于显示器硬件的等待队列,但是有可能某次查询时task_struct在CPU的调度队列。那么这个S状态就是阻塞状态咯?是的,S状态在linux系统中被称为浅度睡眠状态,也就是操作系统理论的阻塞状态。
当我们把printf(“hello world\n”);这段代码注释掉后的运行结果如下:
因为这里没有打印,就没有访问硬件一直执行单纯死循环,一直是纯CPU计算,所以进程就一直处于R状态,R状态在linux系统中被称为运⾏状态,和操作系统理论的运行状态一致。
深度睡眠状态(D)
前面我们介绍了浅度睡眠状态(S),在linux操作系统中还存在深度睡眠状态(D),当进程在进行IO操作,特别是向磁盘中写数据时会将进程设置为D状态,因为进程在进行IO操作时不能让操作系统将该进程杀掉,因为该进程需要等待接受磁盘是否将数据成功写入磁盘的反馈。将进程设为D状态后进程不会对任何事件进行响应,包括操作系统要杀掉该进程。要想解除深度睡眠状态除非进程自己醒来,或者断掉电源。
我们把浅度睡眠状态(可中断睡眠状态)和深度睡眠状态(不可中断睡眠状态)统称为阻塞状态。可以大致总结为进程等键盘/显示器就会处于浅度睡眠状态,进程等磁盘就会处于深度睡眠状态。
停止状态T和t
接下来我们介绍停止状态(T), 我们先介绍如何使进程进入停止状态,后面再说明什么场景下进程会进入停止状态。
用户按Ctrl+Z,或通过命令kill -19+进程PID,都能让进程进入该状态。恢复方式是进程可通过接收SIGCONT信号(或命令kill -18+进程PID)恢复运行。
上面的示例如果有细心的读者会发现在进程进入暂停状态之前的运行状态是R+,而从暂停状态恢复后,进程的运行状态就变成R了。这是因为进程一开始的运行状态是处于前台运行状态(类似于手机运行的应用程序处在前台),这时该进程的 task_struct 内的R状态标志后面有一个+号,前台运行状态的进程是可以用ctrl+c终止掉的,当把前台运行状态的进程暂停后,进程就自动变成后台进程了,如果把它再恢复成运行状态,这时该进程就是后台运行状态,处于该状态的进程是无法被ctrl+c终止的,这时只能通过指令:kill -9 (进程pid) 杀死进程。
linux的进程暂停状态除了T(stopped)外还有一个t(tracing stop)状态,用gdb调试进程时,设置断点后进程在断点处停止,就会进入t状态:
当我们用gdb把程序调试起来之后用ps指令查看会看到生成了一个gdb进程,这时程序并没有运行。当我们把程序打上断点,然后r运行到断点时我们再通过ps指令查看会生成一个运行该程序的进程,我们看该进程的ppid会发现它是gdb进程的子进程,原来的gdb进程创建的子进程让程序运行起来的,这时因为gdb断点而停下来的进程的状态就是t状态。
什么时候进程会处于暂停状态呢?
当进程执行了非法但不致命的操作(如访问无效但不导致崩溃的资源),操作系统会暂停进程,并将情况汇报给用户,由用户决定后续是恢复还是终止。进程自身的异常(非法但不致命操作)或主动暂停行为,会让进程进入T状态;t状态由外部调试 / 跟踪触发,和进程自身行为无关。
辩析阻塞状态与暂停状态:
阻塞状态是操作系统让进程等待硬件资源准备就绪时让进程所处的状态,暂停状态(T)是因为进程执行了非法操作,但操作系统不想杀掉这个进程就会让它处于暂停状态。
僵尸状态(Z)和死亡状态(X)
操作系统只有一个结束状态,而linux系统有两个结束状态:僵尸状态(Z)和死亡状态(X)。
在往下介绍之前,小编先铺垫一些概念,我们首先要明白进程被创建出来是为了完成任务的,任务完成后进程就应该被释放,进程释放是进程创建的反过程,需要把内存中该进程的task_struct和进程代码和数据释放掉,但是这里有个问题,进程结束的时候我们需要知道任务完成的怎么样,这通常由进程的返回值来表征(如main函数返回0就表示程序正常退出),所以在进程结束时,不能立即释放进程的所有资源,要保留一部分向外界传递进程的完成情况。
所以当进程结束的时候,要先处于一种僵尸状态,因为进程已经实际死亡不会被调度了,所以进程的代码和数据会被释放掉,但是task_struct会被保留,进程的退出信息会自动写到task_struct内的退出码变量(exit_code)中,方便父进程读取退出码,而只有当父进程读取完进程退出码后该进程才会由僵尸状态变为死亡状态,才会把进程的task_struct释放掉。
下面我们用代码来验证上面的概念,我们先fork一个子进程,子进程跑5次后就会退出,子进程退出时父进程不做任何操作,那么理论上子进程就会处于僵尸状态。exit(1)不是终止程序的而是用来终止进程的,使用它需要包含头文件<stdlib.h>。代码如下:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>int main()
{pid_t id = fork();if(id == 0){//子进程int cnt = 5;while(cnt--){printf("我是子进程 pid:%d\n", getpid());sleep(1);}printf("子进程退出了\n");exit(1);}//父进程while(1){printf("我是父进程,pid: %d\n",getpid());sleep(1);}return 0;
}
运行及监视脚本监视结果如下:
那么对于一个僵尸进程,如果父进程不对它做处理,它就会一直存在,它的task_struct会一直占用内核内存空间,就会发生广义的内存泄漏(内存泄漏一般指在堆区动态申请的资源未被释放)。这里发生的内存泄漏需要通过父进程读取僵尸进程信息来解决,具体过程后面会细聊。
进程一但处于死亡状态X它的task_struct会被操作系统立即回收,就再也无法查看到该进程的信息了,所以死亡状态X是一个瞬时状态,我们无法通过代码查看,这里也不再演示了。
linux系统没有对应的状态
linux系统中没有实现对应操作系统新建状态的状态,我们知道进程 = 内核数据结构 +
代码和数据,因为要遵循先描述再组织,所以创建一个进程会先创建进程的PCB,然后再把代码和数据加载进内存,在操作系统学科中对于新建状态的定义是当进程的PCB创建出来但代码和数据还没完全加载进内存时的状态称为新建状态,但是linux不需要新建状态,因为linux由虚拟地址空间,后面会细讲。
孤儿进程
⾄此,值得关注的linux进程状态全部讲解完成,下⾯来认识另⼀种进程:孤儿进程。
我们知道当子进程比父进程先退出时子进程就是僵尸进程,但是如果父进程先退出这时的子进程就变成孤儿进程,这时孤儿进程没了父进程,但是孤儿进程未来也会退出所以还需要父进程处理,否则孤儿进程就会保持僵尸进程从而发生内存泄漏,所以孤儿进程会被某个进程领养,一般会由1号进程领养这个孤儿进程,1号进程是什么?我们用top指令查看1号进程:
它名为systemd,在老一些的操作系统中它叫做initd,可以把它简单理解成操作系统的一部分。
来看下面这段代码示例:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>int main()
{pid_t id = fork();if(id == 0){//子进程while(1){printf("我是子进程 pid:%d\n", getpid());sleep(1);}printf("子进程退出了\n");exit(1);}//父进程int cnt = 5;while(cnt--){printf("我是父进程,pid: %d\n",getpid());sleep(1);}return 0;
}
当父进程运行5次后会退出,子进程会成为孤儿进程被1号进程接管。运行结果如下:
补充:当一个进程变成孤儿进程后它会默认变成后台进程,所以用ctrl+c无法终止这个孤儿进程,需要用 kill -9 杀死该它。
这里还有最后一个问题,父进程的父进程也就是孤儿进程的爷爷进程是什么?为什么父进程退出后没有成僵尸进程而是直接消失了?我们先看它到底是什么:
原来它就是bash,当父进程退出后就被bash自动回收了,当然就看不到它的僵尸进程。
以上就是小编分享的全部内容了,如果觉得不错还请留下免费的关注和收藏
如果有建议欢迎通过评论区或私信留言,感谢您的大力支持。
一键三连好运连连哦~~