Linux进程——进程状态
文章目录
- 进程的状态
- 宏观理解
- 进程的状态(通用)
- 对状态的解释
- 运行状态
- 阻塞状态
- 挂起状态
- 状态变化的表现
- 操作系统内部队列和链表的解释
- Linux下的进程状态
- Linux下的进程状态划分
- 进程状态概念的解释和演示
- 进程状态的+号(后台进程)
- 孤儿进程
- 内核数据结构的申请的补充
进程的状态
本篇文章将重点对进程的一些状态进行深入学习
操作系统是计算机的哲学。说的东西都是对的,但是我们直接看是看不懂的。因为对于操作系统这个学科而言,只是把所有操作系统中共性的一些东西给提取出来,针对于这些共性进行讲解。但是我们要知道的是:市面上会有很多的操作系统,它们基本都遵循着操作系统的一些共性,但是针对于不同的系统,在某些方面的实现是不太一样的。
所以本篇文章中,我们需要先从宏观的角度上学习一些操作系统进程状态的概念,然后再深入到Linux系统下的一些具体实现来讲解。
宏观理解
进程的状态(通用)
我们先来看看在操作系统学科内(书本里)的一些关于操作系统进程状态的术语:
我们会发现,对于状态的术语还是蛮多的。
只不过对于当前的我们,肯定是很难理解这些概念。但是我们只需要了解一些重要的点:
1.CPU管理的进程是处在运行状态的进程。
2.对于就绪状态,其实可以把它和运行状态合在一块理解。
当前,就先记住这两个结论即可。
对状态的解释
我们在上文讲到过一个结论:CPU管理的是正在运行的进程。其实这里需要再详细一点介绍:
对于一台机器,一个CPU,只管理一个进程调度的队列
所谓管理进程,就是管理内核数据结构(PCB)和对应代码数据,在系统层面上,先描述再组织,管理进程,就是管理数据结构。
之前我们说过,在操作系统内部(Linux),PCB是通过双链表的方式组织起来的。但是这里我们又说CPU管理的是调度队列,而且调度队列内又是对应的PCB。这是怎么做到的呢?
这一点我们在后面会讲解,当前只需要记住一个结论:
当前认识的PCB是可以存在多个数据结构之间的,不会像以往自行实现的数据结构一样,只能出现在一种数据结构内。
运行状态
我们先来说运行状态,运行状态其实是最简单的。
一个CPU,管理着一个调度的队列。管理的就是正在运行或者等待运行的进程队列。
如上面看过的这张图所示,上面的队列按照一定的优先级先后排序,收到CPU管控调度。
Tips:这里的优先级也是进程管理中的一个重要部分,先不做解释
回归到进程状态术语中,运行状态和就绪状态之所以能被归类到同一块去,就是因为这两个状态都在CPU所管控的调度队列内。优先级高的放在队列前面先调度,其余的等到调度。形成FIFO(First In First Out) 一样的思想。
阻塞状态
我们现在再来看看何为阻塞状态。
我们当前学习的c/c++语言中,到目前为止,使用过的阻塞状态有且只有输入流的scanf和cin。当我们使用这两个函数的时候,我们会发现,如果我们不进行键盘的输入和按下回车确定,代码运行(进程)会一直卡在输入界面。这个其实就是进程的阻塞状态,即进程停滞在某个位置不动,需要等待。
具体到操作系统层面的表现就是:
在操作系统内部,其实不仅仅是运行和就绪状态的task_struct会组成一个队列,受CPU的管控和调度。其实,阻塞的进程的task_struct,也会组成一个阻塞队列:
其实,阻塞队列就是一系列需要等到用户响应的进程,如键盘、鼠标点击等。
阻塞状态最大的特点就是当前进程被卡住阻塞了,该进程从运行队列出来,进入阻塞队列。
挂起状态
挂起状态的是针对于内存空间不足的时候的一种操作。
之前讲过,所有的进程运行的时候,都是先加载到内存当中的。这样导致的结果就是,因为操作系统要管理进程(本质就是管理进程对应的信息/代码数据/软硬件等)。
长时间下来,会发现内存空间其实不太够用。
对于就绪状态而言,因为当前进程其实并不是真的在运行中,而是等到CPU的调度。那么处在就绪状态的进程,它的代码和数据其实放在内存当中就有些占空间了;对于阻塞状态也是一样的,阻塞状态当前正在等待用户响应,也是没有正在运行代码和数据的。
操作系统内对此解决方案是:计算机外设磁盘中,会有一个swap交换分区,操作系统会将处于阻塞状态的进程对应的代码和数据,先行唤入到swap交换分区,然后表明数据信息来自于哪个进程。这个就叫做阻塞挂起状态。
操作系统也有可能会把处于就绪状态的进程的代码和数据也唤入到swap交换分区,和上面的情况类似,这个状态叫做就绪挂起状态。
其实也就是说,所谓的挂起,就是在系统内存空间不足的情况下,将暂且不会调度的进程的一些数据唤入到计算机外设磁盘的swap交换分区中,这个叫做挂起状态。
不需要担心把数据换回来的操作(唤出)。因为操作系统是一个管理者软硬件的软件,它的工作就是负责统筹管理计算机的软硬件资源,它一定是第一时间知道进程的状态变化的。比如一旦用户从键盘输入了,那么操作系统必然是先知道的,此时操作系统就会把数据从交换分区唤出回给对应的进程。
状态变化的表现
我们还是从宏观上来理解一下何为状态的变化。但实际状态的变化要远远的复杂的许多。
比如当前运行队列中正在调度着一个进程,该进程的代码中使用了scanf等输入流的函数,此时我们就会发现系统会卡在输入界面上。
从进程的角度来理解就是,此时进程从运行状态变成阻塞状态了,那么是如何表现的呢?
其实就是当操作系统发现某个运行的进程阻塞后,就会将其从运行队列中出队,入队阻塞队列。此时阻塞的队列需要等到设备或者资源就绪了,操作系统才会将其从阻塞队列出队,重新入队运行队列。
所以我们可以很清晰的发觉,所谓的状态的变化,表现之一就是将进程的PCB和对应的代码数据在不同的队列中流动,也就是在数据结构层面上的增删查改。
操作系统内部队列和链表的解释
前文提到一句,操作系统内部的数据结构和以往接触的不太一样。我们以往认知内,一个数据结构的特性是唯一的,链表就是链表,队列就是队列。可是对于操作系统的内部其实并不是这样的,内核数据结构不仅处于队列中,也处于全局链表中。这是如何实现的呢?
这里也不卖关子,直接进入主题:
我们学习过数据结构,其实理解起来也不会很难。首先,确实是存在一个全局链表,将标识不同进程的PCB和对应的数据代码给组织起来。但是其结构与我们所认知的不太一样:
以往的双链表,不仅有指向前后节点的指针,还会有对应的数据。前后指针指向的是前驱节点/后继节点的所有内容(指针域和数据域)。而在操作系统内部,双链表是只起一个链接作用的,也就是链表节点不存在数据域:
在操作系统内部,链表的结构如下所示
struct list_head{ struct list_head* prev, next; };
结构如图所示
也就是说,链表节点的两个指针被封装在了一个结构体内,并没有数据域。指针前后指向的仅仅是下一个list_head结构体。
然后,对于task_struct而言,其结构如下所示:
struct task_struct{int pid;int ppid;...struct list_head links;//把链表节点封装成一个结构体变量再放入task_struct结构体内...
};
链表节点不再是单独的指针指向下一个数据 + 指针的节点,而是仅仅起到链接作用,指向下一个task_strcut内的list_head节点。队列仍然是使用链表实现的,只不过在连接方式上变得复杂了一点。但是这确实实现了链表 + 队列的组合。
但现在有一个问题,以前使用双链表,指针指向的是整一个节点,所以是可以很轻松的通过结构体的解引用方式来进行访问内部数据的。但是在操作系统内二段双链表,仅仅只是连接下一个链表的节点,该如何访问到PCB内的其他数据呢?
其实前人早已铺好路了。我们需要知道两条基本定理:
1.结构体内第一个变量的地址就是结构体变量的起始地址
2.结构体内的变量地址是逐渐增大的
我们目前是只能知道链表节点(prev和next指针组成的结构体)的其实地址的。那如果能够算出该变量与起始地址的偏移量,那不就可以很轻松的得到整个PCB数据结构的起始地址吗?然后再把该地址给一个struct task_strcut*
的指针变量去操作就可以了。
现在的问题就是,如何计算该偏移量呢?
可以这样子做:
1.把地址0强制转化为struct task_strcut*
类型:(struct task_struct*)0
此时就得到了一个起始地址为0的(struct task_struct*)
2.解引用得到links(链表节点):(struct task_strcut*)0 -> links
得到起始地址为0时的struct task_struct
内的链表节点
3.对得到的links取地址:&((struct task_struct*)0 -> links)
得到起始地址为0时的struct task_struct
内的链表节点的地址
经过上面的三步操作,就成功地算出links
相对于task_strcut
的偏移量了。因为把地址0转为(struct task_struct*)
,所以(struct task_struct*)0
就是一个指向task_struct
的指针,起始地址为0。那么只需要解引用出links再取地址,就得到了一个比0大的links的地址。偏移量就是links地址-0,偏移量即links地址本身。
最后,需要算出task_strcut
的起始地址,只需要用链表节点的地址减去偏移量即可。链表的地址很容易得到的,因为链表节点互相是连接的。
所以最后,task_struct
的起始地址 = links地址 - &((struct task_strcut*)0 -> links)
在c语言中,计算结构体变量偏移量是可以通过一个宏offset来计算的。所以上述方法是可行的,只需要能够理解结构体地址的计算即可。
也就是说,所谓的队列和链表共存,起始就是链表节点独立化封装后,重新作为一个新的变量放入PCB中,起到连接作用,队列起始就是靠链表的指针连接的。
其实,PCB中不仅仅可以存在一个links的链表节点的,可以是多个:
我们可以理解为:一个队列对应一套链表。
PCB在不同队列之间的迁移,本质上就是内核中链表节点的断开与重新连接。
其实并不是真的存在多个队列,而是所有在内存的PCB,根据不同的状态,根据状态连接对应的链表节点,这就是链表和队列共存的特殊性。
如下图,从运行队列到阻塞队列所示:
这种方式还是有较大的好处的。我们不仅可以实现链表 + 队列的结构,还可以实现链表 + 其他数据结构,只要是节点状的数据结构都可以。
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 */
};
进程被划分为上述的七个大类,每个进程其实是对应着一个整数的。也就是说,进程在切换队列的时候,不仅仅是做了增删查改的动作,还有切换进程对应整数的动作。
进程状态概念的解释和演示
在这个部分,我们将重点对每个进程状态进行解释和演示,以此来加深我们对进程状态理解。为了方便展示,将开启两个终端。一个用来测试,一个用来实时监测进程状态。
1.运行状态
即R (Running)状态,我们观察发现,Linux下并没有就绪状态,就绪状态其实和运行状态是被合并成一个状态了,统称为运行状态。
我们使用下面这个代码进行测试:
#include<stdio.h>
#include<unistd.h>
int main(){int i = 0, sum = 0;while(1){sum += i;++i;}return 0;
}
由于该进程没有任何打印等回显操作,所以不会有任何的结果返回到显示器,但是我们只需要知道该进程正在运行即可。
使用while脚本指令进行实时观察:
while :; do ps ajx | head -1 && ps ajx | grep RUN_TEST| grep -v grep; sleep 1;echo "######################################"; done
我们可以发现监测窗口中每个一秒钟会查询一次当前正在运行RUN_TEST的进程,我们可以发现,在STAT状态栏下,出现了一个R+的标识符。这个就代表这是运行状态。具体这里的加号我们先不讲,我们知道这是运行状态就可以了。
2.暂停状态
暂停状态分为两种,一种是T状态,一种是t状态。下面我们做出解释:
现在我们有一个可执行程序叫做SUSPEND,我们感觉代码逻辑有一些错乱,于是我们使用gdb对其调试,同样的,我们使用脚本指令对进程进行1s为间隔的检测:
刚进入调试的时候,发现并没有出现和暂停相关的状态。
断点打在了第七行,会发现右侧检测出现了t状态:
我们再看会t状态的定义:tracing stop,即追踪性暂停。这就说明,t状态是表明当前进程正在被debug调试。
我们再来看看T状态是什么意思:
先把进程运行起来,我们检测到现在确实是有一个叫SUSPEND的可执行程序在运行,然后我们按下ctrl + z,就可以把当前进程给暂停了,我们会发现检测窗口中出现了T状态。这代表着进程被暂停了。
在本章节,我们的重点任务是能够认识这是暂停状态即可。至于怎么回复恢复T状态的进程和删除T状态的进程,这些我们放在以后来讲。
3.休眠状态
休眠状态其实可以理解为当前进程阻塞住了。无法继续运行。但是休眠状态分为浅睡眠和深睡眠状态,具体的我们下面一起来看一下。
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main(){while(1){printf("我是一个进程,我的pid是%d\n", getpid());sleep(1);}return 0;
}
我们来看看该代码的结果:
我们会发现,右侧检测窗口没有显示R状态,取而代之的是S状态,即浅休眠状态,这是为什么呢?代码不是正在运行吗?
其实这个是和IO流的输出函数printf有关。因为printf是将信息从缓冲区打印在显示器上。大部分时间下,系统其实都是在等待着缓冲区的内容碰到缓冲区刷新操作后,才会继续运行该代码(打印)。而阵阵运行的时间太短了,我们这里又刚好设置每秒检测一次,所以很难检测到R状态。我们可以先简单地认为,当前该进程被阻塞住了,等待缓冲区的刷新才会继续运行。
但是这个是浅睡眠状态,是什么意思呢?
简单来说就是,浅睡眠状态进程正在等待事件完成,是可以被中断的,比如使用ctrl + c或者kill -9 pid来杀掉进程。
那反过来,D状态即为深睡眠,也叫磁盘休眠,这是不能被打断的。这个状态其实见的特别少,一般来说是存在于一些比较极端的场景下。
这里不做演示,因为演示会有可能干崩系统,在这只稍微地进行提及即可,举个例子:
假设现在内存中有一个进程,需要向磁盘内写入100MB的数据
我们说过,D状态下进程无法被中断。假设现在认为可以被打断,会出现什么问题呢?
一般来说,D状态常见于I/O操作,尤其是同步读写慢速设备时。所以写入磁盘时候,我们可以认为进程时D状态。但是磁盘空间可能有限,所以不一定能够读写成功。
但是计算机的内存空间也是有限的,对于操作系统而言,在内存特别吃紧的情况下,是可以采取杀进程的方式来释放空间的。假设现在就是个极端情况,进程被杀掉了,但是数据又没能成功写入磁盘当中,那这就麻烦了,这100MB的数据就丢失了。这是很严重的。所以在此情况下,进程的D状态是不应该被中断的,这就是进程的深度睡眠状态。
4.僵尸状态
僵尸状态,即z zombie状态,这个状态是什么意思呢?
我们知道,在Linux系统下,所有子进程都是由父进程带出来的,就像一个进程树一样。父进程是需要管理子进程的。但是有些时候,子进程会比父进程先行退出。父进程是需要管理子进程,收集一些必要信息,释放子进程的。但是如果父进程没有这么做,那么子进程就无法被释放掉,就会一直停留在内存当中。这就很容易导致”内存泄漏“。
下面我们来做个实验:
实验代码:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>int main(){pid_t id = fork();if(id < 0){perror("fork");return 1;}else if(id > 0){ //parentprintf("parent[%d] is sleeping...\n", getpid());sleep(30);}else{printf("child[%d] is begin Z...\n", getpid());sleep(5);}return 0;
}
对于改代码,我们可以发现,子进程会比父进程先退出,也就是说,父进程没有回收子进程的一些信息前,子进程是仍以Z状态存在于内存中的。
上图为实验结果,我们会发现,29358这个子进程确实是以Z+状态存在于内存当中。
直到父进程退出处理了子进程后,我们才会发现子进程不存在了,即结束僵尸状态。
这就好比警察处理户外发现的尸体的时候,第一件事是先需要封锁现场,叫法医来检测尸体,推测死亡原因。这不就是类似于子进程需要等到父进程处理子进程的返回信息一样吗?然后检查完后才能拖走尸体,就和父进程销子进程是一样的。
这里会有造成内存泄露的风险,这是因为僵尸进程必须等待其父进程处理。但是父进程不处理,子进程只能以僵尸状态存在于内存当中。当然,如果进程退出了就不怕,这些东西都会被销毁。比如我们自己写的代码,没跑一会儿就结束了,这个倒是不用很担心。
但是很多的软件都是一直跑的,比如有些app。这种就很容易造成很多的僵尸进程在内存中,导致内存吃紧。这也就是为什么有时候电脑长时间开机会很卡的原因。
进程状态的+号(后台进程)
本部分,我们将简单讲一下,为什么状态标识会由+号。
先给出结论:
带有加号的状态的进程是前台进程,我们可以直接中止的。反之后台进程不可以
可中止的情况:
我们可以使用指令./可执行程序 + &,把程序导入后台:
这里运行的是测试暂停状态的代码。经过实验发现,前台并没有办法中止该进程。此时我们检测一下状态:
我们会发现,状态为S,确实是没有+号了。所以+号就是标识进程为前台进程。
我们可以使用指令kill来杀掉进程,这里就不进行演示了。
孤儿进程
这个进程和僵尸进程是相对的。孤儿进程,顾名思义,父进程会先比子进程先退出。
但是在Linux下,除了系统本身,其余进程是不能没有父进程的,这该怎么办呢?
结论:如果一个进程的父进程先退出了,那么该进程会自动分配给1号进程,即systemd进程,我们可以理解为操作系统。
对于所有的进程,只要它们的父进程先退出,那么它们就会被分配给1号进程作为子进程。而且这是非常有必要的。
因为我们说了,进程是需要被父进程处理和销毁的,否则会变成僵尸进程。所以需要分配一个新的进程给这些进程作父进程,那么操作系统直接代管就非常合适了。
我们来测试一下孤儿进程:
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>int main(){pid_t id = fork();if(id < 0){perror("fork");return 1;}else if(id == 0){while(1){printf("子进程,pid为%d, ppid为%d\n",getpid(), getppid());sleep(1);}}else{printf("父进程,pid为%d\n", getpid());sleep(5);}return 0;
}
我们让子进程一直运行,父进程运行5秒:
刚开始,前台的程序6486和6487都是S+状态,是父子进程关系。
但是因为父进程先退出,所以后序子进程6487需要被1号进程接管:
如图所示。
而且一旦被1号进程接管,就会默认变成后台程序,所以前台是没办法中止的,只能使用kill指令发送信号(信号后面章节再说)。或者粗暴的关掉终端也是可以。
内核数据结构的申请的补充
最后我们稍微提及一下内核数据结构的申请和释放。其实我们仔细想想也能知道,内核数据结构的开辟和释放其实是比较耗费效率的。
所以在操作系统内部,对于PCB的释放,其实很多时候并不是直接把整个对象释放掉了。而是将其放在一个unuse的区域内存放。当又有新的PCB申请的时候,只需要把unuse内的PCB拿过去一个,稍微修改一下里面的信息就可以了。因为系统内部对于PCB的申请肯定是高频大量的,这样子可以节省很多时间。这个了解一下即可。