Linux系统编程 -- 进程(二)

上一期我们重点谈论了一些进程相关的概念,本期我们将根据这些相关概念来理解进程状态。

进程状态

什么叫做状态呢?现在大家正在看这篇文章,这叫做学习中;其他部分人正在睡觉,这叫做休息中;还有一部分人在操场上打篮球、踢足球,这叫做运动中。一个活着的人都有他目前所对应的状态,状态标明的是这个人当前正在干什么事。对于进程来讲,进程的状态用来决定这个进程当前要被调度、要被运行、正在休眠还是正在进行等待,所以它的状态决定了进程在系统层面如何被处理。进程状态本质就是task_struct结构体里的一个整型变量,在C语言中我们使用宏定义各种状态的变量值,然后在结构体的进程状态中直接使用这些宏变量即可,将来进程是什么状态,我们只需要把宏变量改成对应的数字就可以了。**总之,进程状态就是一个整数!**这样说对我们已经有了C/C++编程经验的同学来说,还是很好理解的。
1、课本上的说法-名词提炼一个进程会有很多状态,参考下图。

2、运行&&阻塞&&挂起

归根结底在系统中总会有存在多个进程的时候,但CPU是少量的,所以我们经常说一个系统在CPU上去运行调度进程时,每个CPU都会在内部维护一个叫做调度队列的东西,用来管理运行状态下的进程。

每个矩形框都是一个PCB(task_struct),它们都有唯一对应的pid,每个PCB中都存有指向它代码数据的指针。第一点,运行时CPU会选择一个进程去运行(不是选代码数据,而是选进程对应的PCB去运行),我们调度一个进程在CPU上跑,其实是选择了进程的task_struct去CPU上跑(通过task_struct我们可以找到对应的代码数据,所以我们找到PCB就可以了)。第二点,每个CPU都会维护一个调度队列,在linux中叫做runqueue,在这个队列中,我们可以找到所有运行状态的进程。我们之前说过,在系统中所有的PCB会被连接到一个全局的双链表中,但这个双链表不是队列,我们以前在写数据结构的时候,不管是链表还是二叉树或其他,你new出来的数据节点只属于一种数据结构,但在linux内核中采用的做法是让一个数据节点既可以属于一个全局的双链表,又可以把相关进程放在一个全局队列里,也就是说这个数据节点(task_struct)同时满足两种数据结构,在这里我们先要了解到CPU为了管理这些PCB专门设计了一个队列,而这个队列遵守双链表的特征,我们后面会讲他是怎么做到的。
运行状态:进程在调度队列中,进程的状态都是running!
阻塞状态:等待某种设备或者资源就绪,例如在C/C++中调用scanf或cin时,进程停下来等待键盘文件就绪(用户输入),此时属于阻塞状态!键盘、显示器、网卡、磁盘、摄像头、话筒等都可能处于此状态。
补充:OS要管理系统中的各种硬件,采用的方式同样是先描述,在组织!

对于不同的设备文件,OS创建了对应的device结构体,结构体中包含了目标设备的所有属性(id、供应商、状态、数据、下一个设备的地址、设备类型等),OS对设备文件的管理方式和进程一致,采用具有队列特征的双链表结构管理PCB,每个PCB中存有指向设备的代码数据。

因此,在OS中不仅有运行队列,还有设备队列。设备是一个结构体,我们未来的设备都会对应一个device对象,我们在读磁盘读网卡的时候,如果设备没有就绪,说明进程进行阻塞等待了,在OS中如何理解这个阻塞等待呢?其实在device结构体中还存在一个等待队列(wait queue),当正在处于运行状态的进程执行到某设备输入状态时,当前进程的PCB会被移出运行队列被连接到相应设备的等待队列中,并将状态修改为阻塞状态,这时只要设备输入没有完成,该进程就永远不会被调度了,也就是说该进程处于阻塞状态!所以从运行状态转换到阻塞状态的本质是PCB被转移到了不同的队列当中。当设备完成输入后,OS作为硬件的管理者,硬件状态发生变化OS会第一时间收到设备的消息,并对该设备阻塞队列进行检查,若指针不为空则会将队列中的PCB的状态进行修改,改为运行状态后重新连接到运行队列中。进程状态的变化,表现之一就是进程的PCB要在不同的队列中进行流动,本质都是对数据结构的增删查改。

挂起状态:挂起是操作系统里一个比较极端的情况,我们知道进程=PCB+代码数据,这是比较占用内存的。假设当队列中存在非常多的进程,设备的阻塞队列中也有相当多的进程,此时我的内存资源已经严重不足了,作为OS,它不能因为内存不足就开始摆烂不干活了,它会开始寻找阻塞队列中的进程,因为这些进程的PCB没有在运行队列中,所以不会被执行,OS会把这些阻塞队列中的进程的代码数据唤出到磁盘的swap交换分区(用于在内存不足时存储暂时不会被运行的进程数据)中,此时这些进程的状态我们称为阻塞挂起!当被唤出的进程状态被更改为运行状态时,OS会将该进程的代码数据唤入到内存中(重新加载到内存,重新构建指针映射),重新构建完整进程后,OS就会将该进程放入到运行队列里。

同理,如果内存中只剩运行队列但内存依然不足时,OS会将队列中优先级较低的进程唤出到磁盘的swap分区中,此时这些进程的状态我们称为运行挂起!当运行队列即将执行该进程时,OS会把该进程的数据唤入到内存中。

总之,挂起是当系统的内存资源比较吃紧时,OS要做一些内存页面置换的算法,它要把不会被调度的进程或者相关的内存块交换到对应的磁盘上,这时在内存中只有PCB但没有代码数据的进程就处于挂起状态。

补充:理解内核链表的话题

我们在最初学习数据结构时学到的双链表应该是下面的样子,结构体中包含了数据和前驱后继指针。根据我们对双链表的理解,双链表最终的结构就是右侧所示。

cpp 复制代码
struct list_head
{
    struct list_head *next;
    struct list_head *prev;
}

//PCB
struct list_head tasks;

但在Linux内核里,它的双链表并没有这么设计,他们首先设计了一个list_head的结构体,只包含了该节点的前驱后继指针,有了这样一个节点后,将来我想要创建任何种类的结构体时,都可以使用这个独立的指针结构体来连接所有节点。

在PCB中,每个进程之间就是用这种结构连接的,这些PCB中的指针并不需要指向下一个PCB的首地址,而是指向下一个指针节点,这样就会形成一个类似队列的结构。

在最初的双链表结构中,我们想要遍历所有节点中的数据是非常方便的,但PCB这种结构的双链表中并不能做到,所以我们需要像一种方法把它还原到第一种遍历方式,也就是说找到每个PCB的"头",才能使用XXX.x来访问数据,我们还知道一个结构体struct task_struct a的第一个数据如果是int x,那么&a == &a.x,因此我们可以使用&((struct task_struct*)0->links)得到了links相对于节点首地址的偏移量,这样我们就可以通过list->next - &((struct task_struct*)0->links)找到第二个PCB的首地址了,未来我们就可以使用(struct task_struct*)(list/next - &((struct task_struct*)0->links))->成员变量 来访问任意PCB的任意属性了。

有了上面的认识,我们未来无论一个PCB里有多少个指针节点,我们都可以用这种类似队列的方式将所有PCB串联起来,让PCB既属于运行队列,还属于全局链表,还可以把它放在二叉树或者你想要的任何节点类型的数据结构里,也就是说每个进程的PCB在内存中只存在一份就够了,无论这个进程是在运行队列还是阻塞队列,改变状态时我们只需要修改PCB内部的指针指向就可以了。未来我们讲到一个对象既属于这又属于那时你就不会害怕或者糊涂了,因为我们已经有了对于一个对象隶属于多个数据结构的认识。

Linux内核中的进程状态

理论上我们已经对上述三个概念做到理解了,但还是不够具体,为了弄明白正在运行的进程是什么意思,我们需要知道进程的不同状态。一个进程有多个状态(在Linux内核里,进程有时候也叫做任务)。

下面的状态在kernel源代码里定义:

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):意味着进程在等待事件完成(这里的睡眠有时候也叫做可中断睡眠(interruptible sleep))。D磁盘休眠状态(Disk sleep):有时候也叫不可中断睡眠状态(uninterruptible sleep),在这个状态的进程通常会等待IO的结束。T停止状态(stopped):可以通过发送SIGSTOP信号给进程来停止(T)进程。这个被暂停的进程可以通过发送SIGCONT信号让进程继续运行。X死亡状态(dead):这个状态只是一个返回状态,你不会在任务列表里看到这个状态。

R运行状态(running):

我们使用xshell来直观地查看,首先我们写一个死循环程序并将其运行起来,然后我们再打开同xshell一台机器的新窗口,输入while : ; do ps ajx | head -1; ps ajx | grep myprocess; sleep 1; done 查看有关该程序的进程,并有1s时间的无限循环。

我们就会看到进程的状态是S睡眠状态(sleeping),原因是我们的代码中有printf,可以进程有1纳秒的时间在运行代码,而其他时间都是在执行打印操作,所以进程捕捉到的大概率都是S状态。如果你想要查看它的R状态,只要将代码里的I/O操作注释掉即可。

有人可能会注意到这个状态后面跟着一个加号,它表示的是进程在前台运行,也就是我们可以看到进程的执行并且可以直接操作该进程,我们也可以使用 ./myprocess & 来让进程在后台运行,我们就可以看到进程状态后面的加号就没了。此时我们想要终止进程只能使用kill -9 pid来杀掉进程。

S睡眠状态 (sleeping):

根据我们之前的说法,如果一个进程在运行中执行了I/O操作,在下面的程序中,我们从标准输入中去读,进程就会被键盘阻塞输入,当前进程的PCB就会被连入键盘设备的阻塞队列里,因此在输入完成前它就不会再被调度,当我完成输入时就会把进程重新唤醒,PCB被连回运行队列里继续运行。

我们可以看到该进程的状态是S,所以在Linux中,S睡眠状态就是理论中的阻塞状态。我们讲过在PCB中,进程状态就是一个整数,在Linux当中,S被定义为整数1。

T/t停止状态(stopped):

停止状态是在调试中出现的状态,所以我们需要用到gdb。

在启动gdb之后,程序还没有跑起来,所以进程中看不到myprocess的进程id,只有gdb的进程id。

我们打上断点后,将程序运行起来,我们就可以看到有一个该路径下的myprocess程序出于t状态了。此时程序停止在了断点处,所以我们称t为追踪状态,因此出现t状态的原因是被debug,进程被暂停了!

下面我们将输入去掉,重新回到循环打印。

重新编译运行起来后,按ctrl+z,程序会被暂停,此时的进程状态为T,也就是说用户通过键盘操作让程序暂停,那么这个进程就是T状态。

暂停怎么和理论上的状态对应呢?暂停不同于阻塞,阻塞是停止等待某种资源,而进程暂停往往是因为某种条件不具备或者进程做了非法操作,OS就会把你的进程暂停,它属于Linux特有的一种状态,可能其他系统也有,但是在操作系统学科中没有提到这种状态。t大家可能容易理解,用于调试时的暂停,T在什么情况下会用到呢?我们在显示器上的打印操作其实是往特定的显示器文件里进行打印的(pts/0),未来我们遇到的一些情况,它不允许你往显示器上打印了,如果你依然往显示器上打,那么OS会误认为你的进程发生错误了,OS就会把你的进程暂停,这个情况目的是OS为了不必要的麻烦来做的止损操作。为什么不直接终止进程呢,因为OS认为情况没有这么严重,而且进程是用户启动的,一般暂停后用户也需要查看原因或信息,所以暂停是OS怀疑进程有问题,然后把决定权交给用户。

总结,Linux 里的进程暂停(T 状态)和阻塞不是一回事 ------ 阻塞是进程等着要某个资源(比如等文件读取完),而暂停是进程干了不该干的(比如不让往显示器打印还硬打)或缺了关键运行条件,系统才主动叫停的,这状态课本里没重点讲,但实际常用。系统不直接关掉进程,是觉得问题不算严重,而且进程是你启动的,暂停后你能查清楚原因,再决定是修复后让它继续跑,还是直接关掉。

D磁盘休眠状态(Disk sleep):

S状态在Linux中被称为可中断休眠或浅睡眠,而D状态就是不可中断休眠或者深睡眠。什么叫可中断休眠呢?如果一个进程处于阻塞状态,我们发出了一个终止该进程的命令,这个进程会响应这个命令,就称为可中断休眠。同理,不可中断休眠就是处于阻塞状态的进程不会响应这个终止命令。

举例:在OS内有一个进程,这个进程要把100MB的数据写入到磁盘中,进程就会对着磁盘喊:"这是100MB的数据,我现在要把这写数据存储到你内部的特定区域内,你帮我存一下吧",磁盘说:"可以,你把数据给我,我现在给你存,你先不要走,在这等我存完了再走,我写入有可能空间不足写入失败,但我能保证不管写入成功还是失败我都能告诉你结果,然后你再把结果告诉给用户"。进程说没问题,磁盘就开始工作了,此时进程要把自己设置为S状态在这里等待。与此同时,OS路过发现进程在这不敢活干坐着,他就问进程:"你在这干啥呢!",进程说:"我在等磁盘把文件存进去呢。",操作系统就对进程说:"你看不到我一直在跑吗,我已经忙成这个样子了,空间已经严重不足了,你还占着空间不干活",说完后OS直接把这个进程杀掉了(有时候你手机的应用会闪退就是这个原因),此时这个进程也没办法,官大一级压死人,所以只能被杀掉了。过了一会,磁盘发现写入到90MB时磁盘空间满了,失败了,磁盘就对着进程之前等待的地方喊:"我写入失败了,没空间了,怎么办,你还在吗",但是并没有进程回应。所以磁盘只能把数据丢弃掉了,所以我们在系统层面上丢失了这100MB的数据,此时用户还不知道。假设这些数据是某银行一天的转账记录,那这就会造成非常大的损失,银行行长就把这三个货叫到了办公室,说:"你们三个自己商量一下,这个事故谁来背锅。",磁盘说:"这事不赖我啊,我就是个跑腿办事的,我跟进程说了得等我,可是最后我写入失败了,进程不在啊,我有什么办法,我处在系统最底层,我听话的很,又不是我的问题",进程说:"这事也不赖我啊,我是受害者,我被杀掉了,我在等待队列里等的好好的,OS来了直接把我干掉了,我打不过他能怎么办",OS说:"行长你知道我是衷心与你的,你赋予了我很高的权利,如果系统资源严重不足的时候,我有权杀掉部分进程,今天我只是杀掉一个进程,如果我挂掉的话损失的就不止这一个进程了",行长一听三个说的都有道理,难倒不是他们的问题是我的问题吗?所以行长说:"数据丢了就丢了吧,你们都别难受了,回去干活吧,从今天开始给进程多加一个状态,如果是D的状态,OS你就不能杀掉它。"自此,凡是进程遇到高I/O的操作时,都会处于D状态,这样就不会被误杀掉导致数据丢失了。处于D状态的进程,重启也杀不掉,只有断电才能杀掉它。
其实大多数工程师对这个也不是很熟,一般都是做存储做I/O才会常用这个状态。其实磁盘也是挺快的,除非你的磁盘时间太久老化掉了,否则很难出现上面的情况。如果你用的磁盘太老,存数据时,可能会出现寻址问题等等,导致磁盘不响应,一旦出现一个D状态的进程,那么这个电脑就离挂掉不远了。

X / Z死亡状态(dead):

X状态对应操作系统学科的结束状态,这个状态只是一个返回状态,你不会在任务列表里看到这个状态。Z(zombie)状态又叫僵尸状态,这个状态存在的意义是为了获取已经结束的进程的退出信息。我们知道在linux中所有刚创建的进程一定存在它的父进程,我们今天创建了一个子进程,目的是为了让子进程完成某件事情的!那么这个事情完成的怎么样,结果相关的信息父进程得知道,当子进程处于Z状态后,子进程会把退出的信息暂时维持到PCB中,父进程就会找到子进程的PCB中的退出信息。Z状态的子进程会保留什么信息我们后面会讲。

下面我们来模拟一下Z状态,我们使用fork函数创建子进程,然后使用分支语句让父进程一直循环,子进程循环五次后结束。

我们就会看到子进程结束后,状态会从S变成Z,defunct就是失效的。如果父进程一直不管,不回收,不回去子进程的提出信息,Z状态的子进程会一直存在,pid会一直存在,这属于内存泄漏问题!所以不是只有new或malloc才会引起内存泄漏的,僵尸进程就是内存泄漏!未来我们会学到使用waitpid来等待回收僵尸进程,所以父进程未来在获取子进程退出信息的时候还会处理释放掉僵尸进程的PCB,解决掉内存泄漏问题。

补充:如果进程退出了,内存泄漏问题还在不在?

答案是不在了。进程一旦退了,你new和malloc的空间会被系统自动回收的。僵尸进程属于系统层面的内存泄漏问题,这种情况并不属于进程完全结束。所以什么样的进程具有内存泄漏问题时比较麻烦呢?是常驻内存的进程,这种进程一旦启动,就会长久待在内存中。

知识点:关于内核结构申请的

在内存中会非常频繁地创建和销毁进程,这样会非常浪费时间。我们知道创建进程时需要申请空间和初始化,销毁进程需要释放掉空间。有一种办法可以减少这一部分的开销:我们在内存维护一个废弃进程空间的队列,这样下次需要创建时,我们只要在这个队列内拿出一段空间,重新初始化即可,此时我就形成了一个类似内核数据结构的缓存。

相关推荐
Azure DevOps36 分钟前
Azure DevOps Server:允许讨论但不允许修改工作项
运维·microsoft·azure·devops
Jerry.张蒙37 分钟前
SAP实现物料分类与订单类型匹配检查
运维·人机交互·能源·运维开发·创业创新·制造·学习方法
双木的木40 分钟前
Coggle数据科学 | 并行智能体:洞察复杂系统的 14 种并发设计模式
运维·人工智能·python·设计模式·chatgpt·自动化·音视频
waves浪游1 小时前
进程控制(上)
linux·运维·服务器·开发语言·c++
Mr.Ja1 小时前
【Docker 从入门到实战】——解决跨环境部署痛点的完整指南
运维·docker·容器·dockerfile·dockerimage
q***87601 小时前
Nginx 常用安全头
运维·nginx·安全
youxiao_901 小时前
LVS负载均衡集群与LVS+Keepalived集群
运维·负载均衡·lvs
SweerItTer1 小时前
RK3566 泰山派 IMX415驱动移植+设备树修改+iq文件复制
linux·csdn·泰山派·imx415·rk356x·驱动移植
i***t9191 小时前
Nginx 之Rewrite 使用详解
运维·nginx