【Linux系列】并发世界的基石:透彻理解 Linux 进程 — 进程状态
👓️博主简介:
文章目录
- 前言
- 一、Linux内核源代码怎么说
- 二、进程状态查看
- 三、Z(zomble) - 僵尸进程
- 四、僵尸进程危害
- 五、孤儿进程
- 总结
前言
我们在了解了进程的概念之后,我们再来了解了解进程的各种状态,我们在使用各种程序的时候,有的程序可能已经退出了,有的程序可能还在用,有的程序甚至可能会闪退。以上的程序的状态是一样的吗,它们分别是什么状态呢,我们一起来看看吧。
一、Linux内核源代码怎么说
- 为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程可以有几个状态(在Linux内核里,进程有时候也叫做任务)。
下面的状态在kernel源代码里定义:
/*
*The task state array is a strange "bitmap"of
*reasons to steep. 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):可以通过发送 SLGSTOP 信号给进程来停止(T)进程。这个被暂停的进程可以通过发送 SIGCONT信号让进程继续运行。
-
X 死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。
我们一个正常的人在现实生活中总是存在着不同的状态,就比如说我们在上课时状态就是上课中,我们在睡觉时状态就是休息中,我们的进程也是如此,不同的状态决定了当前正在做什么事情,系统该如何看待进程,以及在系统层面,系统该如何处理这个进程。
本质:进程状态就是一个整数。
进程状态本质就是 task_struct 里的一个整型变量,通过 define 的宏进行替换。以后我们的进程是什么状态,就将进程task_struct 里改成什么数字就可以了。
运行 && 阻塞 && 挂起
进程拥有非常多的状态,比如创建状态、就绪状态、阻塞状态、挂起状态等。只有运行状态才是真正意义上持有 CPU 的状态。状态间可以进行转化。
我们计算机中的 CPU 非常少量,但是我们的进程运行却可以有很多。我们所说的一个进程可以在 CPU 上运行本质上是我们的每一个 CPU 都要在系统内部维护一个调度队列的东西。我们的 CPU 要去选择一个进程去运行,本质不是去选择这个进程的代码和数据去运行,而是去选择一个进程特定的 PCB 来运行(task_struct里有指向对应代码数据的内存指针,可以直接或间接的找到对应的代码和数据)。一个 CPU 一个调度队列,这个队列在 Linux 内核上叫做 runqueue,类型是 struct struct*。但是在系统中我们的进程不是连接在一个全局的双链表当中吗?其实我们的 task_struct 既可以属于一个全局的双链表,又可以把相关进程放在一个全局队列中,也就是说一个数据结构节点既可以属于 A 数据结构,又可以属于 B 数据结构。具体如何做到的下文的理解内核链表会讲解。在这里的调度队列和我们之前的全局列表是两种结构。我们接着来看这个 runqueue,它有 queue,所以说我们可以把先它当作一个由链表组成的队列,也就是先进先出,从头出从尾进,也就是说队列前面的优先级高。
在这个背景下我们来看看什么是运行、阻塞和挂起。
运行: 一个进程、在 CPU 上跑的时候就就是运行状态。但是在当代计算机中,我们的进程只要在这个调度队列中,我们就称为它在运行状态。我们的进程处在运行状态时要么正在被 CPU 执行、要么就是准备好了,随时被 CPU 调度执行。就是上图的运行加就绪。
阻塞: 我们在目前为止唯一遇到的阻塞情形只有两种,在 C 语言中调 scanf 和在 C++ 中调 cin。我们的代码在运行时也是一个进程,在从上往下运行到 scanf 或 cin 时,我们的代码就停下来了。它本质上是停下来等用户输入。在用户输入后,我们的scanf、cin 拿到数据后我们的程序就会继续向后运行了。其实当我们 scanf 时,不是等待用户输入,而是等待键盘文件就绪,等待键盘上有按键被按下。当我们用户没有按下键盘时,我们称键盘文件不就绪。不就绪我们的 scnaf、cin 就无法读取数据,就必须得等。所以阻塞本质上是等待某种设备或资源就绪。在等期间一直不就绪,我们的进程不会被调度,就会卡住。具体来说,我们的操作系统要对软硬件资源进行管理就得先描述、再组织。由于 OS 要对硬件进行管理,所以就得创建相应的数据结构。它的数据结构中可能就包含 id、vender(厂商)、status(状态)等。我们操作系统在打开时就得给这些硬件创建这些节点。再用指针将它们连接起来。所以 OS 对硬件的管理就变成了对这个数据结构所形成的列表进行增删查改。属性不一样我们可以用指针指向它特定的数据空间、但是顶层都是同一数据结构。
把这两个图合并到一起就成了我们 OS,我们的 OS 除了运行队列,还有设备队列。
我们的设备本质上也是一个数据结构,当我们的设备未就绪时在等待中,我们就可以在设备的数据结构中增加一个等待队列。
所以我们的每一个设备都有一个对应的等待队列。当我们的 OS 发现我们的一个进程要读取时,比如要读键盘,就会去检查我们的键盘的状态,发现我们的设备没有输入按键,status 处在不活跃的状态下,我们的进程无法去执了,我们的 OS 就会把该进程从调度队列中取下来,放到相应的键盘的等待队列上去,此时的进程永远不会被调度,就处于阻塞状态。把 PCB 链入不同的队列当中的行为,就是阻塞。当我们的键盘按下时,我们的 OS 作为软硬件资源的管理者会第一时间知道我们的键盘就绪了,OS 就会把我们的键盘硬件状态标记为活跃的,同时再来查看我们的等待队列,发现我们等待队列不为空,就会把我们阻塞的进程状态设置为运行状态,再将我们的阻塞进程重新链回我们的runqueue 队列中。这个进程就会继续运行 scanf,然后把我们的数据从外设上读到自己的数据当中。
进程状态的变化,表现之一就是要在不同的队列中进行流动,本质就是数据结构的增删查改!
挂起: 属于一种极端的情况。假设我们的计算机内存资源全部不足了,我们的 OS 会把我们正在阻塞中的各种进程的代码和数据换出到我们的磁盘中的一个 swap 交换分区里,只保留它们的 PCB,以此来缓解内存不足的问题。我们成这些进程处在阻塞挂起状态。所以说挂起是指我们的内存在资源严重不足的情况下我们的 OS 通过不同的算法把我们的进程的数据和代码释放的行为。当我们对应的硬件就绪了,那 OS 立刻就会感知到,并把我们存在磁盘的数据换入内存,重新和 PCB 进行指针映射。如果这样操作后资源还是不够,我们的 OS 可能还会把我们在调度队列末端的数据挂起来缓解资源空间不足。
理解Linux内核链表:
我们在之前学习的时候,我们的链表定义方式是在结构体中定义两个指针。
struct node
{int data;struct node* next;struct node* prev;
};
但是 Linux 内核中的链表并不是这么设计的,在 Linux 内核的链表中,首先包含了一个 struct list_head 的节点,在这个节点中存储的才是我们的 next 和 prev 指针。当我们创建了一个数据结构后。
struct XXX
{int x;int y;int z;list_head links;...
};
我们的我们 links 的链表节点是不包含任何数据的,而是把我们的 next、prev 指针单独封装一个类型,我们把这个类型作为一个新的目标的数据结构的成员来链接。
我们遍历之前的链表数据十分的容易,因为我们的 next 直接是指向我们下一个链表的头节点,但是我们的 Linux 内核数据该如何访问呢?
首先我们在 C 语言的学习中,我们的结构体的地址和我们的第一个数据的地址是相同的,同时数据由上到下依次增大。我们的links 指针只能告诉我们 list_head 的地址,我们得通过一系列的运算才能求出。首先,我们可以给 0 地址进行 (struct node*)0 强转,这样做相当于就是说我们在 0 这个位置有一个 struct node,我们再去求出 links 的相对于 0 位置地址 &((struct node*)0->links),通过这个操作,我们就能得出 links 相对于 0 的偏移量,也就是 links 的偏移量。这样我们想要访问链表的头节点只需要减去这个偏移量即可,就达到了访问的效果。
我们以后就可以在这个结构体中放大量的 links 来指向不同的链表,所以说 Linux 中很多数据结构其实是网状的。
二、进程状态查看
R 状态:
我们可以通过一个简单的代码来查看进程状态
#include<stdio.h>int main()
{while(1){printf("hello world\n");}return 0;
}
我们可以通过之前学的查看 PID 的指令来查看进程状态,但是由于进程状态变来变去,所以这里用一个循环的指令来查看
while :; do ps ajx | head -1; ps ajx | grep myprocess; sleep 1; done。
在运行时我们发现它一直是 S 状态,不应该是运行状态吗?其实是因为我们的进程运行我们的 printf 函数的时间是极短的,大量的时间都是去在硬件队列和调度队列间切换的阻塞,所以这里显示的是 S(如果运气好的话可能能看到 R)。
如果想要看到 R 状态,那我们就不要去 printf,这样 OS 就不用去等设备了,就可以看到。
这个加号的意思是我们的这个进程是在前台启动的,就是命令行上启动的,如果是放到后台上就没有加号了(之后会细讲)。
S 状态:
我们刚刚讲的进程中的阻塞状态的其中之一就是我们的 S(sleeping)状态。
#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>int main()
{printf("我是一个进程, pid:%d\n", getpid());int x;scanf("%d", &x);return 0;
}
可以看出它当前的状态就是 S。
D 状态:
我们的S状态称为休眠状态,是一种可中断休眠,也称浅度睡眠。如果一个进程处于S状态,我们可以直接把这个进程杀掉,会响应我们杀掉它的动作。而我们的D状态也是休眠状态,只不过是不可中断睡眠,也就是深度睡眠。
当我们的进程如果想对磁盘操作时,势必要等待我们的磁盘的响应以及我们磁盘的反馈,就比如操作成功啊失败啊等,要我们的进程交给OS。但是在内存空间十分不足时,我们的OS如果发现我们有进程处在S状态,可能就顺手把它杀掉了(我们有的时候服务器挂掉、软件闪退等就是进程被杀掉了),这样就会导致我们的数据丢失,所以为了避免这种情况,我们就会有D状态。D状态也是阻塞的一种,Linux系统中的阻塞状态就是由S和D状态构成。我们OS无权终止D状态,所以想要结束D状态只能等这个进程自己醒来,或者重启。当我们的进程出现高I/O时就容易出现D状态。
T/t 状态:
t 状态叫做追踪状态,t 状态的出现是在我们日常调试时,在断点处暂停时就会出现。
我们可以看到当我们在 gdb 中打了断点后运行我们的进程,就会出现我们的 t 状态,所以为什么我们的程序在断点处会停下来,因为我们的进程被暂停了。
我们再次运行我们之前的代码。在不停循环时我们按 ctrl + z,这个时候我们的循环就会停下来。
我们可以看到 T 状态的出现。也就是说如果我们的进程不是通过 gdb 暂停的,而是用户通过键盘操作将其暂停的就是我们的 T 状态。暂停和阻塞不同,阻塞状态是我们的进程在等待某种资源,而我们的暂停则是某种条件不具备,或者进程做了非法操作,OS 就会把你的进程暂停了。它属于 Linux 特有的状态。我们的 T 状态是用来做止损的,就比如我们想将某一文件写入某个地方,但是实际上确是不支持这样做,我们的 OS 检测到后就怀疑我们进程出错后进行 T 状态的暂停。然后就交给我们的用户,让我们来决定要不要继续运行。
X 状态:
X 就是我们的死亡状态,也就是一个进程死掉了,对应我们 Linux 的就是结束状态。X 状态看不到。进程一瞬间就退出了。
三、Z(zomble) - 僵尸进程
当我们看到一个人倒在地上没有了呼吸,一半都会打 110 和 119,当我们警察先行来到现场,封锁现场后对死者提取信息,然后再送到医院去处理后事。用到进程上最后我们的进程就死亡了,也就是 X 状态,但是在我们进程死亡之前,在警察来到之前以及提取信息的过程中,我们就称它是 Z 状态,也就是僵尸状态。僵尸状态主要就是用来去获取进程退出的信息的,它是怎么退出的、为什么退出等等。
在我们 Linux 系统中,所有的进程都一定是某一进程的子进程,我们创建子进程的目的是为了让我们的子进程去给我们办事情的,正是因为如此,那么我们子进程相关的信息,我们的父进程就应该得知,子进程退出了并不是立刻就把子进程的 PCB 和代码和数据释放掉,而是把代码和数据释放掉,但是 PCB 信息不能释放,需要我们的父进程通过我们的 PCB 获取子进程的结果。所以 Z 状态就是在子进程退出后到父进程得到子进程信息前的状态。
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>int main()
{pid_t id = fork();if(id == 0){//childint count = 5;while(count){printf("我是子进程, 我正在运行: %d\n", count);sleep(1);count--;}}else{//parentwhile(1){printf("我是父进程, 我正在运行...\n");sleep(1);}}return 0;
}
正常情况下我们子进程退出了,我们父进程得去接收子进程的僵尸状态的,但是现在由于父进程在运行而无法接收,所以子进程就会维持僵尸状态。
四、僵尸进程危害
如果父进程一直不管、不回收、不获取子进程的提出信息,那么 Z 就会一直存在!但是我们的子进程提出信息是在 PCB 上的,它占有一定的内存空间,如果我们一直不管,那就会出现内存泄漏问题。所以说除了 new 和 melloc 等,我们的僵尸进程也会导致内存泄漏。所以我们父进程除了要获取子进程的退出信息,还得解决内存泄漏的问题。
内存泄漏:
-
当进程退出了,我们的内存泄漏的问题就不会再存在了。我们创建出来的空间在退出时系统就自动回收了。
-
死循环的进程具有内存泄漏问题是比较麻烦的,我们把这个内存叫做常驻内存。
五、孤儿进程
当父进程因为某些原因提前退出了,而此时我们的子进程还在运行,此时我们的子进程就是我们的孤儿进程。
但是我们通过僵尸进程可知,我们子进程的退出信息是要我们父进程回收的,此时我们的父进程挂掉了又该如何呢?
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>int main()
{pid_t id = fork();if(id == 0){//childwhile(1){printf("我是一个子进程, pid: %d, ppid: %d\n", getpid(), getppid());sleep(1);}}else{//parentint cnt = 5;while(cnt){printf("我是一个父进程, pid: %d,ppid: %d\n", getpid(), getppid());cnt--;sleep(1)}}return 0;
}
运行后我们发现
当我们父进程退出后,我们的子进程的 ppid 变成了 1,并且进程无法退出了。所以说,在父子进程关系中,如果父进程先退出,我们的子进程要被一号进程(init/systemd)所领养,这个被领养的进程(子进程),叫做孤儿进程。无论有多少子进程变成了孤儿进程,我们的 1 号进程都会将其领养,我们的系统通常就被认为是我们的一号进程。
我们的进程如果变成了孤儿进程被系统领养了,就会自动变成后台进程,后台进程无法用 ctrl + c 杀死,只能 kill -9 进程的 pid 来杀死它。
总结
Linux 中的进程主要就是这么几种状态,对于我们来说,了解一种进程的状态也是分析我们程序运行的成效的一种方式,我们也得好好了解掌握,我们下一章再见吧。
🎇坚持到这里已经很厉害啦,辛苦啦🎇 ʕ • ᴥ • ʔ づ♡ど