【Linux系统】------ 进程状态
- [1 什么是进程状态](#1 什么是进程状态)
- [2 操作系统的 运行&&阻塞&&挂起 状态](#2 操作系统的 运行&&阻塞&&挂起 状态)
-
- [2.1 运行状态 (running)](#2.1 运行状态 (running))
- [2.2 阻塞状态](#2.2 阻塞状态)
- [2.3 挂起状态](#2.3 挂起状态)
- [3 一个节点如何处于多个数据结构](#3 一个节点如何处于多个数据结构)
- [4 Linux 中的进程状态](#4 Linux 中的进程状态)
-
- [4.1 总览](#4.1 总览)
- [4.2 R 和 S](#4.2 R 和 S)
- [4.3 T 和 t](#4.3 T 和 t)
- [4.4 D 状态](#4.4 D 状态)
- [4.5 僵尸状态(Z)](#4.5 僵尸状态(Z))
-
- [4.5.1 什么是僵尸状态](#4.5.1 什么是僵尸状态)
- [4.5.2 见一见僵尸状态](#4.5.2 见一见僵尸状态)
- [4.5.3 内存泄漏](#4.5.3 内存泄漏)
- [4.6 死亡状态(X)](#4.6 死亡状态(X))
-
- [4.6.1 内核结构申请](#4.6.1 内核结构申请)
- [4.7 为什么 Linux 没有挂起状态](#4.7 为什么 Linux 没有挂起状态)
- [5 孤儿进程](#5 孤儿进程)
1 什么是进程状态
进程的状态决定了进程当前在做什么事,以及系统如何看待处理该进程。
进程状态本质就是 PCB 结构体中的一个整型变量 。在操作系统内,我们可以定义宏来表示进程的各种状态。
如下是 Linux 中定义的各种状态
c
/*
*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 */
};
总结:进程状态就是 task_struct 中的一个整数
2 操作系统的 运行&&阻塞&&挂起 状态
2.1 运行状态 (running)
操作系统中往往同时存在多个进程,可是 CPU 只有一个或两个。
每个 CPU 都要在系统内部维护一个调度队列
CPU 选择一个进程去运行,本质不是选择这个进程的代码数据,而是选择某个进程的PCB;再通过该 PCB 去找到对应的代码和数据
一个 CPU 一个调度队列 ,CPU通过一个 runqueue
指针(类型:task_struct*
)指向运行队列
之前我们说过所有的 PCB 都会被链入一个全局的双链表中,那这个双链表是不是调度队列呢?
不是,他们是两种不同的结构。
在我们之前的学习中,一个结点只会属于一种数据结构(如:链表,二叉树);但 Linux 内核对 PCB 的维护采用的是:让一个PCB可以属于多个不同的数据结构中。如何做到的,我们之后会介绍
注:Linux 中 CPU 实际的调度方式并按不是上图队列先进先出的调度方式(但也有小部分操作系统是这种调度方式),为了方便介绍我们暂时认为 Linux 中 CPU 的调度就是先进先出,Linux 中的实际调度方式后续文章会详细介绍。
什么是进程的运行状态 (running)?
只要进程在调度队列中,该进程的状态就是运行状态。并不是一个进程持有CPU正在被调度,它才叫运行状态
2.2 阻塞状态
先不说阻塞状态的概念,我们先来将阻塞状态的现象:
我们写 C/C++,调用了 scanf / cin 函数。当程序跑起来(进程运行起来),执行到 scanf / cin 时就停下来,停下来等待用户输入,这是之前的说法。其实进程停下来,并不是等待用户输入,而是等待键盘硬件就需,即等待键盘有按键按下。当键盘没有被按下,我们称为键盘文件未就绪,键盘未就绪,scanf / cin 就无法读取数据,进程就不会被调度
所以阻塞的意义:等待某种设备或者资源就绪
操作系统是一款管理软硬件资源的软件 ,如何对硬件进行管理呢?与管理进程一样:先描述再组织 !每个硬件都会有一个对应的结构体对象来描述它,为方便描述我们假设这个结构体类型为 struct device
(实际并不完全是)。
这个结构体类型中,除了有描述各个设备的属性成员外,还有一个成员:struct task_struct *
进程运行时,对应的设备未就绪,对应进程就要阻塞等待 。
例如调度的当前进程调用了 scanf,操作系统检测键盘状态发现键盘未就绪,就会将该进程的 PCB 移出调度队列,链入到键盘设备等待队列当中。即键盘的 struct device 中的 struct task_struct * 指针就会指向该移出的 PCB。
把这个进程移出运行队列,转而到了设备的等待队列,该进程就永远不会被调度,此时这个进程就处于阻塞状态
从运行转为阻塞本质:把进程 PCB 链入到不同的队列结构当中
当键盘被用户按下,属于硬件就绪。操作系统作为硬件的管理者,硬件状态发生变化是第一时间知道的。这时操作系统再去检查该设备的等待队列,发现指针不为空,就将该等待队列中的进程状态设为运行状态,再将该进程重新链回该队列。
重新链会运行队列的进程在末尾,一开始并没有被 CPU 调度。当轮到调度它时, CPU 就会继续运行 scanf,并且将数据从对应的底设备层键盘读取出来。
从阻塞回到运行状态,本质就是找到等待队列中的 PCB,再将 PCB 链回运行队列
当然,可以多个进程都在等键盘就绪,即键盘的阻塞队列有多个 PCB。其他设备如:磁盘、网卡、显示器等等都是同理
结论:进程状态的变化,表现之一就是要在不同的队列中进行流动,本质都是数据结构的增删查改
2.3 挂起状态
挂起是操作系统里面比较极端的情况。
进程 == 内核数据结构 + 自己的代码和数据 ,他们都是要占用内存空间 的。
当计算机中内存资源吃紧时,怎么办呢?
内存中有一些数据并不会被立即访问,但还占用着内存,就比如处于阻塞状态的进程。此时操作系统就会将这些处于阻塞状态的进程代码和数据换出到磁盘的 swap 分区 ,只在内存中保留对应的 PCB。此时我们将这些进程的状态称为阻塞挂起状态。
当键盘就绪,系统会将该进程在磁盘 swap 分区 的代码和数据换入到内存,形成完整进程再把进程放入到运行队列中。
如果内存资源严重不足时,将阻塞状态的代码和数据都挂起到外设上内存还是不够呢?
此时操作系统就会对运行队列中末端的进程动手。我们将这些进程状态称为处于运行挂起状态
挂起:将进程的代码和数据挂到外设(磁盘)上
3 一个节点如何处于多个数据结构
就拿双链表来说,以前我们定义双链表的结点,是这样定义的:
c
struct Node
{
int data;
struct Node *next;
struct Node *prev;
}
这样就能把各个节点连接起来
但 Linux 中的双链表并不是这么设计。
Linux 内核中,定义了一个 list_head 的结构体,有且仅有包含两个指向自己类型的指针 next 和 prev。
c
struct list_head
{
struct list_head *next, *prev;
};
之后设计的结构体中,不再单独包含指向自己类型的 next 和 prev,而是包含各种数据和包含struct list_head类型
c
struct XXX
{
int x;
int y;
struct list_head links;
char c;
int * p;
};
那么 struct XXX 的各个结点之间是怎么链接的呢
它的 next 指针并不指向下一个 struct XXX 结点的开始,因为 next 的指针类型是struct list_head,因此它的 next 指针指向的是下一个 struct XXX 结点中的 struct list_head 类型成员。prev 也是同理
我们以前的链表结点,他的 next 都会指向整个 Node。但 Linux 内核中的 next 只会指向目标结点内部的某个成员对象(struct list_head links)。
现在有个问题:next 指向的是下个结点中成员对象(struct list_head links)的地址,如何通过 links 的地址找到所在结点的地址呢?
结构体的地址和结构体第一个成员的地址在数值上是相等的。结构体中成员的地址满足对齐原则依次递增的。
(struct XXX*)0
我们认为在 0 地址处有一个结构体 struct XXX ---> &((struct XXX*)0 -> links)
访问 0 地址处 struct XXX 结构体的 links 成员,再对其取地址。此时得到 links 成员相对于该结构体开始的偏移量 ---> next - &((struct XXX*)0 -> links)
next 中保存着下一个 struct XXX 结点的 links 地址,减去相对偏移量就是下一个结点的开始地址 ---> (struct XXX*)next - &((struct XXX*)0 -> links)
最后再进行强转就能访问下一结点的所有成员
既然我们可以定义一个 struct list_head 对象,那么就可以定义多个 struct list_head 对象。如果有多个对象,我们可以用对象 1 表示调度队列、对象 2 表示双链表、对象 3 表示二叉树......
这样就可以理解前面所说的一个 task_struck 既在调度队列中,又在全局的双链表中了。
这也说明我们内核中的数据结构并不是单一的数据结构,而是网状 的结构。
4 Linux 中的进程状态
4.1 总览
Linux 中定义的状态如下:
cpp
/*
*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):意味着进程在等待时间完成(这里的睡眠有时候也叫做可中断睡眠)
- D 磁盘休眠状态(Disk sleep):有时候也叫不可中断睡眠状态,在这个状态的进程通常会等待 IO 的结束
- T 停止状态(stopped):可以通过发送 SIGSTOP 信号给进程来停止进程。这个被暂停的进程可以通过发送 SIGCONT 信号让进程继续运行。
- X 死亡状态(dead):这个状态只是一个返回状态,你不会再认为列表里看到这个状态
4.2 R 和 S
在 Linux 内核中,运行状态我们称为 R(running);其中一个阻塞状态我们称为 S(sleeping)
前面我们说过,进程状态在PCB中是一个整数。在 Linux 内核中 R 为 0,S 为 1
我们写下如下代码进行测试
c
#include <stdio.h>
int main()
{
while(1)
{
printf("Hello Linux!\n")
}
return 0;
}
输入如下指令每隔一秒查看进程属性
while :; do ps axj | head -1 && ps axj | grep code | grep -v grep; sleep 1;done
为什么我们的程序一直在 S 状态呢?进行不是一直在跑吗?不应该是 R 状态吗?
因为我们的代码里一直有 IO 操作!
我们的进程,几乎绝大多数时间都在等待显示器就绪,绝大多数时间处于阻塞状态;而代码的执行速度是非常快的,一瞬间就执行完 printf 了,因此几乎看不到 R 状态
想要看到 R 状态,我们不要让它 IO 就好
c
#include <stdio.h>
int main()
{
while(1)
{
//printf("Hello Linux!\n")
}
return 0;
}
S+ 和 R+ 中的 "+"是什么意思呢?
表示的是当前进程是前台运行的。
我们可以在进程运行时加上 &
表示让进程在后台运行:
前台进程与后台进程区别
- 前台进程:运行时无法使用 bash 外壳的指令,并且可以被 Ctrl+C 强制终止掉
- 后台进程:运行时可输入指令,不能被 Ctrl+C 掉,只能使用 kill 指令来杀掉进程.
4.3 T 和 t
t (tracing stop)
: 当前的进程因为被追踪而暂停了。
什么是 t 状态呢?不知道,我们来实验一下
我们以 debug 模式编译 code.c
c
gcc code.c -o code -g
打开 gdb
现在仅仅是打开 gdb,code 进程还没运行起来
在 gdb 中输入 b 行号
指令,再输入指令 r
让程序运行至断点处停下来
此时我们发现,code 进程的状态就是 t
所以为什么我们打断点程序会停下来?因为进程被暂停了
T(stopped) :
让进程暂停,等待被进一步唤醒。暂停后自动会变成后台。
还是这个代码:
c
#include <stdio.h>
int main()
{
while(1)
{
printf("Hello Linux!\n")
}
return 0;
}
将进程运行起来,再按Ctrl + Z
T 和 t 都是暂停状态,那暂停状态又是什么呢?
暂停 (T/t) 和阻塞 (s) 不太一样。阻塞是进程在等待某种资源,而进程暂停往往是因为进程不具备某种条件或者进程做了非法操作,操作系统给你暂停了。暂停是 Linux 特有的一种状态。
暂停状态是用来做止损的,操作系统认为该进程不对劲,但是问题又不是那么严重,操作系统就会把进程暂停。暂停有什么用呢?该进程是谁启动的?是用户,我们自己启动的进程被暂停了,我们用户肯定要去看一看。
所以暂停是操作系统怀疑这个进程有问题,将进程暂停,交给用户,由用户决定要不要继续运行
4.4 D 状态
当一个进程处于 S 状态,我们称为睡眠状态(其实就是阻塞状态),S 状态又称为可中断休眠 / 浅睡眠。
什么叫做可中断休眠呢?意思是如果一个进程处于 S 状态,我可以直接把它杀掉,这个进程会响应我们杀掉它的动作。
我们称 D(disk sleep) 状态为深度睡眠 / 不可中断休眠。
D 状态的应用场景是什么呢?我们举个例子来理解:
A 进程要将 1GB 的数据写入磁盘(此时磁盘空间不到1G)。磁盘数据有可能写入失败(如内存不够等),但不管成功还是失败,磁盘都会将 成功/失败 的结果返回给A进程,再由 A 进程告诉用户。此时 A 进程什么都做不了,只能磁盘返回结果,即 A 进程处于 S 状态。
当操作系统的内存资源严重不足,挂起都无法解决,操作系统极端情况下会选择杀掉进程节省空间。此时就是这样,操作系统看 A 进程处于 S 状态,为腾出空间直接把 A 进程杀掉。
此时,磁盘写了 500M 发现磁盘空间满了,想返回结果发现A进程已经被杀掉了。磁盘一想,还有那么多进程需要我,就不管这 1GB 数据,直接将其丢弃。至此我们在系统层面上就丢失了这 1GB 数据,而且用户不知道!因为每人告诉用户。如果这 1GB 数据是银行的转账记录呢?问题不就大了。
为了避免这种情况,凡是涉及到磁盘等关键数据存储设备进行高 IO 访问时,进程状态设为 D 状态:不可被杀深度睡眠,不可中断睡眠。主要是为了防止该进程丢失从而导致数据丢失的问题
归根结底,D 状态也是阻塞状态的一种
我们一般不会遇到这种情况,从事系统管理、运维、存储等工作可能会遇到。
杀死 D 状态的方法:
- 进程自己醒来。
- 重启,重启不行则断电。
4.5 僵尸状态(Z)
4.5.1 什么是僵尸状态
一天,你走在马路边发现一人倒在地上,走进一看发现已经他已经挂了,你吓的赶紧报警。警察过来了第一件事是做什么?封锁现场!因为警察要判断死者的死因,待法医和警察在死者和周边环境上采集足够多的信息后,才会将死者抬走。
在这人挂掉到被警察抬走,他一直躺着,没人敢动他。我们这里将这种状态称为僵尸状态(Z)。
为什么这个人要处于僵尸状态,就是为了获得这个人的退出信息,以便确定死因 。
当警察获取了足够的信息,将其抬走,这人才正在死亡,即死亡状态(X)
在 Linux 中,所有的进程一定是某个父进程的子进程 。父进程创建子进程是为了让其完成某个任务的,既然是让子进程办事,那么子进程事做的怎么样,父进程得要知道。
因此一个进程退出时,代码和数据会被释放掉,但是其 PCB 将会保留。父进程需要在子进程的 PCB 中获取子进程的退出信息,知道事情完成的怎么样。
在子进程退出之后,父进程拿走子进程退出信息之前,这个状态称为僵尸状态(Z)。
4.5.2 见一见僵尸状态
如何模拟验证僵尸状态呢?
要模拟 Z 状态, 我们得有一对父子进程,并让子进程退出而父进程什么都不做(现在我们也不知道要父进程如何解决子进程的 Z 状态)。只要子进程的退出信息没有被回收,子进程的 PCB 就一直被保留着。
测试代码如下:
c
#include <stdio.h>
#include <unistd.h>
int main()
{
pid_t id = fork();
if(id == 0){
//child
int count = 5;
while(count--){
printf("我是一个子进程,我正在运行:%d\n", getpid());
sleep(1);
}
}
else{
//parent
while(1){
printf("我是你爹,我正在运行\n");
sleep(1);
}
}
return 0;
}
4.5.3 内存泄漏
如果父进程不去回收子进程,那么子进程的僵尸状态就需要一直维护!
也就是说子进程的PCB会一直占用内存,这就造成了内存泄漏。
不仅仅只有new / malloc 才会造成内存泄漏,僵尸进程也是会造成内存泄漏的。
未来,父进程出来获取子进程的退出信息,还要将子进程的 PCB 释放,解决内存泄漏的问题。
我们来聊一下内存泄漏
一个进程退出,曾经用 new / malloc 申请的资源,但没释放的内存会被释放吗?即一个进程退出了,内存泄漏问题还在不在?
不存在!
进程退出,曾经用 new / malloc 申请的资源会被系统自动回收,内存泄漏的问题就解决了。(僵尸进程不算,僵尸进程是系统级的,而 new / malloc 是语言级的)
所以什么样的进程害怕内存泄漏问题?
启动后不退出(死循环)的进程 ,我们将这种进程称为常驻进程
4.6 死亡状态(X)
还剩一种状态就是死亡状态(X)
我们一般无法看到死亡状态。一旦父进程回收了子进程的退出信息,对应的 Z 就直接转换为 X,只要是 X 状态,操作系统就瞬间将这个进程释放了。
4.6.1 内核结构申请
一个进程启动,操作系统要为其创建一个 task_struct,需要申请一个 task_struct 的内存空间,并将其初始化。
操作系统中,每时每刻都有许多进程启动和退出。
如果进程退出,系统将其 task_struct free 掉,再来一个进程再重新 malloc task_struct,是不是太麻烦了?
所以在系统中会维护一张废弃 task_struct 的列表 。
进程进入死亡状态,操作系统就会回收这张废弃的 task_struct,将其链入列表中
当有新的操作系统启动时,操作系统就直接在列表中取出一张 task_struct,初始化其成员,就完成了复用
4.7 为什么 Linux 没有挂起状态
挂起状态的本质是系统内存资源不足,操作系统为节省内存将进程的代码和数据换出到 磁盘的 swap分区 上,需要运行再换入。
在用户层面,我并不需要知道系统资源的换入与换出工作,我们只需要知道我们的程序有没有运行
因此操作系统将挂起操作完全影藏起来,并没有在 Linux 中体现出来
5 孤儿进程
父进程如果提前退出,而子进程后退出,进入 Z 后,该如何处理呢?
我们用代码进行实验
c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
int main()
{
if(id == 0){
//child
while(1){
printf("我是一个子进程,pid:%d,ppid:%d\n", getpid(), getppid());
sleep(1);
}
}
else{
int count = 5;
while(count--){
printf("我是你爹,我正在运行\n");
sleep(1);
}
}
return 0;
}
结论:父子进程中,如果父进程先退出,子进程不能成为没有父进程的进程 ,子进程要被一号进程领养 ,这个被领养的进程我们称为孤儿进程。
所以 1号 进程是谁?
使用 top
指令:
再用 ps 指令查 "systemd"
在 Linux 中,当我们登录 Linux 就有人帮我们创建对应的 bash,谁帮我们创建的?我们说是系统 ,那系统是谁?我们认为系统就是 1号 进程,或者理解为 1号 进程是系统的一部分。
所以父进程如果提前退了,子进程就要被系统领养。
为什么要领养?
如果不领养会发生什么呢?这就意味着该子进程没有父进程,意味着该子进程退出时没有人回收其退出信息,就会造成内存泄漏并且无法解决 。
领养之后子进程就有一个新的父进程,就可以对子进程进行回收
注:一个进程一旦变成孤儿进程,被系统领养,就会变成后台进程 ,无法用 ctrl + c
杀死进程,需要用 kill -9 pid
杀死
好啦,本期关于 进程概念 的知识就介绍到这里啦,希望本期博客能对你有所帮助。同时,如果有错误的地方请多多指正,让我们在 Linux 的学习路上一起进步!