【Linux】操作系统之进程介绍(二)

前言:上一篇介绍进程,这篇接着看看进程的状态、优先级与切换。


1.进程的状态

1.1运行、就绪与等待

我们平时说"这个程序正在运行",一般是在说它已经启动,并且还没有结束。但从操作系统的角度看,一个还没有结束的进程,并不一定正在使用CPU。

比如程序需要从键盘读入一个数字,但我们还没有输入。这个时候即使一直把CPU交给它,它也没办法完成后面的计算,因为它缺的不是CPU,而是输入的数据。

所以先把下面两种"等待"区分开:

  • 就绪:执行所需的条件已经具备,只是在等CPU。
  • 阻塞:还在等某个事件或资源,例如输入数据、I/O完成,此时暂时不具备继续执行的条件。

真正得到CPU、正在执行指令,才是通常状态模型中的运行。就绪和运行之间可以相互转换;运行中的进程如果需要等待某个事件,就可能进入阻塞状态,事件满足后再回到就绪状态。

这里需要注意的是,被唤醒不等于马上执行。被唤醒只是重新具备了参与调度的条件,什么时候得到CPU,还需要操作系统安排,这点会在下面介绍调度队列时会有解释。

上面是帮助理解的简化关系图。如果落实到Linux中,我们经常会在ps的输出里看到这些状态字母,下面是我收集到的一些进程状态:

状态 含义 理解时需要注意的地方
R running / runnable 正在运行,或者已经就绪、在运行队列中等待CPU
S interruptible sleep 可中断睡眠,正在等待事件,可以被合适的信号唤醒
D uninterruptible sleep 不可中断等待,常见于I/O相关等待
T stopped 被停止信号暂停执行
t tracing stop 在被调试器跟踪时停止
Z zombie 已经退出,但退出信息还没有被回收
X dead 死亡状态,通常不会在进程列表中观察到

Linux中的R把"就绪"和"正在运行"都包含进去了。 因此看到多个进程都是R

S也不只来自sleep。等待输入、等待某些事件时同样可能进入S;

D虽然经常被叫作"磁盘睡眠",对于严格的不可中断等待,即使发送SIGKILL,也可能要等等待条件结束以后才能真正退出哦。

T的话则是暂停,不是执行完毕。例如可以在另一个终端,对自己已经确认可以操作的测试进程执行:

bash 复制代码
kill -STOP PID
kill -CONT PID

这里的PID为实际进程编号,两条命令分别执行、分别观察。前一条使进程暂停,后一条使它能够继续运行,不过不要对不认识的系统进程操作,不然容易翻车。

查看时仍然使用我们熟悉的命令:

bash 复制代码
ps axj
ps aux

如果看到S+Z+,前面的S、Z才是基本状态。不同发行版之间可能会有所变化,所以还是要参考官方文档为主具体字母可以对照ps手册的状态说明

1.2状态变化

上一篇我们提到过,操作系统管理进程的基本思路是"先描述,再组织"。

进程状态同样不是一个贴在程序外面的标签,而是由内核中的状态字段等信息来描述,再配合相关队列组织任务。

可以把运行队列理解成"已经具备执行条件的可运行任务集合",其中既包括正在运行的任务,也包括就绪等待CPU的任务;

这样,状态变化就可以和数据结构联系起来:

  1. 进程需要等待事件时,内核更新相关状态,使它暂时不再作为可运行任务参与调度,并建立相应的等待关系。
  2. 事件发生以后,内核找到相关等待者,将满足条件的任务唤醒。
  3. 任务重新具备运行条件,重新进入后续的调度过程。

内核并不只维护一条链表,也不是把整个PCB在内存中搬来搬去。很多时候,改变的是结构中的状态值以及不同链表、队列之间的连接关系。

Linux中常见的链表组织方式也很有意思:把链表连接成员嵌在被管理对象里面,用其中的前后指针把对象串起来。一个对象还可以有多个连接成员,分别参与不同用途的组织关系,并不是每条链表都重新复制一份完整对象。

1.3僵尸进程

假设父进程创建子进程,是为了让子进程完成一项任务。那么子进程结束以后,父进程是不是还需要知道:任务完成得怎么样,是正常退出,还是出现了问题?

因此,子进程在退出时要留下退出信息方便父进程知道任务是否完成,完成得怎么样

在默认退出处理方式下,子进程已经退出,而父进程还没有通过waitwaitpid等接口读取并回收相关信息,子进程就会处于Z状态,也就是僵尸进程

先来看一个例子。将父进程的休眠时间设为30秒,子进程休眠5秒后退出,给我们留出观察的时间:

c 复制代码
#include <stdio.h>
#include <stdlib.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)
    {
        printf("parent[%ld] is sleeping...\n", (long)getpid());
        sleep(30);
    }
    else
    {
        printf("child[%ld] will exit after 5 seconds...\n", (long)getpid());
        sleep(5);
        exit(EXIT_SUCCESS);
    }

    return 0;
}

保存为Myprocess.c,在Linux下编译运行:

bash 复制代码
gcc Myprocess.c -o Myprocess
./Myprocess

然后在另一个终端查看:

bash 复制代码
ps axj | grep Myprocess

按这段程序的执行流程,最初父子进程都还活着。大约5秒后,子进程退出,而父进程还在休眠,也没有调用回收接口,于是出现Z状态。

下面这次观察中,就能看到对应的现象:

那僵尸进程有什么影响呢?

一个僵尸进程不会继续消耗CPU执行代码,它原来的主要用户空间资源也已经释放。但用于保存退出信息的内核记录和进程表位置仍然需要保留。如果父进程不断创建子进程,又一直不回收,就可能积累大量这样的记录,最终影响新进程的创建。

同样,kill -9也不是清理僵尸进程的方法,因为它已经退出了,再发送结束信号并不能替父进程读取退出信息。应当由父进程或后续接管者完成回收。

1.4孤儿进程

刚才是子进程先退出。如果反过来,父进程已经结束,子进程还没有结束,这个子进程通常就称为孤儿进程

先把它和僵尸区分开:

对比 僵尸进程 孤儿进程
重点 子进程退出信息尚未回收 原父进程先退出
子进程是否已经退出 已经退出 可以仍在正常执行或等待
是否是一个独立的状态字母 对应Z 不是一种额外的状态,仍可处于R、S等状态

所以孤儿进程不等于坏掉的进程,也不等于僵尸进程。它只是原来的父进程不在了,需要由其他进程接管后续的回收责任。

下面这个例子把顺序反过来,子进程休眠10秒,父进程休眠3秒后退出:

c 复制代码
int main()
{
    pid_t id = fork();
    if (id < 0)
    {
        perror("fork");
        return 1;
    }
    else if (id == 0)
    {
        printf("I am child, pid: %d\n", getpid());
        sleep(10);
    }
    else
    {
        printf("I am parent, pid: %d\n", getpid());
        sleep(3);
        exit(0);
    }

    return 0;
}

正常执行时,大约3秒以后父进程结束,而子进程还有一段时间才结束。这个时间段内继续用ps axj查看子进程,可以观察它的PPID是否已经变成了接管者的编号。睡眠时间用于留出观察窗口,不是精确的同步保证。

在很多系统上,接管者是1号进程。例如我这台系统,PID为1的进程是systemd

2.进程的优先级

2.1PRI和NI

系统里可以同时存在很多进程,但能够同时执行任务的CPU资源肯定是有限的。当多个任务都已经就绪时,操作系统就需要决定怎样分配CPU。

优先级就是影响这种分配的因素之一。优先级更高,CUPh会优先为这个进程分配资源

我们可以通过长格式查看相关信息:

bash 复制代码
ps -l

如果需要扩大显示范围,也可以使用ps -al。在输出中先认识这几列:

字段 含义
UID 进程对应的用户身份编号
PID 进程编号
PPID 父进程编号
PRI 当前显示格式下的优先级数值
NI nice值,用来影响普通任务的调度待遇

用户名是便于人阅读的表示,内核中的身份判断需要使用对应的编号。

再来看PRINI。对于这里讨论的普通任务,nice值的范围是**-20到19,一共40个取值**:

  • nice值越小,通常越有利于竞争CPU。
  • nice值越大,通常越愿意把CPU让给其他竞争者。
  • 常见的一般初始nice值是0

是否更快拿到CPU,还和其他任务、调度策略以及资源限制等有关。

2.2查看和调整nice

最直观的方式是使用top

  1. 输入top,找到需要操作的测试进程。
  2. r,输入它的PID。
  3. 输入准备设置的nice值,随后观察NI
  4. 看完按q退出。

普通用户通常可以把自己进程的nice值调大。

另外两个相关命令是:

  • nice:以调整后的nice启动程序。
  • renice:调整已经存在的进程。

但这个东西一般还是不要动,至少对于我这样的小白是这样

3.并发、并行与进程切换

3.1多个进程推进

前面提到进程要竞争CPU,这就是进程的竞争性 。不过它们又有各自的运行数据和管理信息,普通私有数据的修改不会随意影响另一个进程,这体现了进程的独立性

接下来再区分两个经常一起出现的词:

  • 并发:多个任务在一段时间内都得到推进。
  • 并行:多个任务在同一时刻真正同时执行。

下面这个咖啡机的例子就比较直观:

上面两队人共用一台咖啡机,如果两队交替得到服务,一段时间里两队都在向前推进,但同一时刻只有一台机器在处理一份工作,可以类比单个CPU上的并发。

下面两队分别使用两台咖啡机,两份工作就可以在同一时刻进行,可以类比多个CPU执行资源上的并行。

3.2进程切换

如果只有一个CPU执行资源,却要让多个进程轮流运行,就会遇到一个问题:进程A的计算做到一半,换成进程B以后,再回到A时怎么知道应该接着算哪里?

比如A刚完成一部分计算,寄存器里还保存着中间结果。如果直接让B把这些寄存器覆盖掉,再回来时A就无法沿着原来的执行过程继续了。

因此,切换之前必须保存A恢复执行所需的信息,切换到B时再恢复B对应的信息。这些用于恢复执行的现场,就叫作上下文

其中会涉及指令执行位置、栈指针以及需要保存的寄存器等信息。

可以按三个动作来理解:

  1. 保存A的现场:记住A执行到哪里,以及恢复它所需的状态。
  2. 选择下一个任务:调度器决定接下来由谁使用CPU。
  3. 恢复B的现场:让CPU按照B之前的执行位置和状态继续工作。

CPU中的寄存器在某一时刻保存的是当前任务的执行状态,而内存中可以保存多个任务各自的上下文。所以我们看到的是"程序轮流执行",并不是"所有进程共用同一份执行进度"。

时间片可以理解成某些调度策略允许任务在一轮中使用的CPU时间额度。用完以后,调度器可能换另一个任务运行;但是CUP的执行速度太快了,时间片一般也会很短,我们平常用电脑时几乎感受不会到进程切换

4.Linux的O(1)调度队列

4.1运行队列与优先级数组

前面知道了进程状态和优先级,现在还有一个问题:已经就绪的进程很多,操作系统怎样快速找到下一个合适的任务?

这里通过早期Linux 2.6的O(1)调度器来理解一种具体的实现方式,后期肯定会变化的但我这里只是为了学习目的。

这个设计中,每个CPU有自己的运行队列runqueue。如果系统有多个CPU,就不能只看一条队列,还需要考虑不同CPU之间的负载分配。

运行队列里有两套优先级数组,分别承担两种角色:

  • active:活动数组,组织当前可以参与这一轮调度的任务。
  • expired :过期数组,通常组织已经用完这一轮时间额度、暂时等待后续轮次的任务。每套数组内部不是一条大链表,是按优先级分组的多条链表。早期实现里有140个内部优先级位置,下标从0到139,数值越小表示内部优先级越高。

其中0到99用于实时任务的内部优先级,100到139用于普通任务。

同一个优先级下的任务放在同一条链表里。调度时先确定哪个优先级非空,再从对应链表取任务,而不是每次都遍历系统里所有进程做一次全面比较。

4.2bitmap快速找到任务

先来看优先级数组的结构:

c 复制代码
struct prio_array {
    unsigned int nr_active;
    DECLARE_BITMAP(bitmap, MAX_PRIO+1); /* include 1 bit for delimiter */
    struct list_head queue[MAX_PRIO];
};

这是内核中的结构片段。

queue是链表头数组。MAX_PRIO为140时,就对应140个优先级位置;每个位置连接这个优先级下的任务。

那么bitmap有什么用呢?

假设140条队列里只有第120条和第139条有任务。我们当然可以从下标0开始一条一条看,但实际上只需要知道哪些位置非空:

  • 对应位为1,表示这个优先级的队列有任务。
  • 对应位为0,表示这个优先级的队列为空。

插入任务使一个空队列变成非空时,要设置相应的位;最后一个任务离开队列时,也要清除对应的位。链表和位图要配合维护。

这样就可以极大的降低查找的成本

4.3 活动数组与过期数组

如果总从active中取任务,那么这些任务用完自己的时间额度以后怎么办?

先沿着普通任务的简化主线来看:

  1. 从active里选出合适的任务运行。
  2. 该任务时间片耗尽时,重新计算相关优先级并补充下一轮时间片,然后通常放到expired中,等待后续轮次。
  3. 当active已经没有任务时,交换active和expired两个指针,让原来的过期数组成为新的活动数组。

这里尤其要注意:是交换指针,不是把两套数组中所有任务逐个复制一遍。

这也解释了为什么保存两个指针很方便。active这个名字描述的是当前角色,并不是永远只能指向数组A;expired同理。

当然真实环境下实现会更加复制还会考虑交互性和饥饿问题。例如某些交互任务在时间片耗尽后可能重新进入active,同时又要防止expired中的任务长期得不到机会;实时任务也有自己的规则。因此上图表达的是理解结构的主线,不是全部分支。相应结构与处理可以在早期Linux 2.6的调度源码中对照查看。

这样再回过头来看,"先描述,再组织"就更具体了:状态说明任务现在能不能参与执行,优先级影响资源分配,队列把任务组织起来,而上下文切换让选中的任务能够沿着自己的执行进度继续运行。


相关推荐
Zenova EdgeOS1 小时前
Linux ss 工业边缘网络分析实战
linux·python·边缘计算·工业网关
第十人i1 小时前
Linux 日志管理系统:journald 配置与优化
linux·服务器
wuminyu1 小时前
HotSpot的轻量锁与膨胀后的ObjectMonitor状态重建原理
java·linux·c语言·jvm·c++
从零开始的嵌入式之旅1 小时前
day47
arm开发·经验分享·笔记·嵌入式硬件
m4Rk_2 小时前
【论文阅读】Agent 记忆机制(77):TSM——让记忆回到事件真正发生的时间
论文阅读·人工智能·学习·开源·github
陈年老古董2 小时前
MediaPipe 姿态检测与脸部关键点检测
笔记·python·opencv·学习·dlib
旺仔小馒头wang2 小时前
AI 智能手机发布会:赋予人类的五种新能力
人工智能·学习·音视频
人工智能培训2 小时前
未来三年,AI 落地的五个确定性判断
大数据·人工智能·学习·生活·ai写作
CCCCCCCCharlie2 小时前
Linux进程控制四大核心操作
linux·开发语言