5. 进程

首先,操作系统是一个进行软硬件管理的软件,是一个基本的程序集合。

设计操作系统的目的是:通过对下与硬件交互,管理所有的软硬件资源从而对上为用户程序提供一个良好执行环境。

如何理解操作系统:

1. 软硬件体系结构层状结构

这说的是:计算机不是一块铁板,而是一个多层蛋糕

你的程序(QQ/浏览器/游戏)
        ↓
  库函数(C++标准库 / glibc)
        ↓
  系统调用接口(操作系统内核提供的“门”)
        ↓
  操作系统内核(管理CPU、内存、磁盘、网卡)
        ↓
  硬件(CPU、内存、硬盘、网卡)

关键认知:每一层都只和它上下两层打交道。你的程序是不直接碰硬件的

目录

5. 进程

5.1 进程理解

5.1.1 怎么理解进程

5.1.2 查找进程

5.1.3 父进程

5.1.4 代码创建子进程的方式及理解

5.2 进程状态

5.2.1 进程状态理解

5.2.2 进程队列

5.2.3 理解系统内核链表

5.2.4 Linux的进程状态

5.2.5 僵尸进程

5.2.6 孤儿进程

5.3 进程优先级及切换

5.3.1 进程优先级理解

5.3.2 优先级极值问题

5.3.3 优先级调整命令

5.3.4 竞争,独立,并行,并发

5.3.5 进程切换

5.4 进程调度


,而是一层层往下传。

2. 访问操作系统,必须使用系统调用 —— 其实就是函数,只不过是系统提供的

你写的程序运行在“用户态”,没有权限直接访问硬件。如果想读写文件、发网络包、分配内存,必须请操作系统帮忙

  • 操作系统提供了一组固定的函数接口,叫系统调用(System Call)

  • 比如:read()write()open()send()recv()malloc()(底层调了 brkmmap)。

本质上,它们就是函数,只不过这些函数是操作系统内核写的,并且运行在“内核态”。

3. 你的程序,只要你判断出它访问了硬件,那么它必须贯穿整个软硬件体系结构

这句话非常硬核,是对整个路径的断言:

  • 如果你的程序读了磁盘上的文件,那它一定走了:你的代码 → 库函数 → 系统调用 → 操作系统文件系统 → 磁盘驱动 → 磁盘硬件

  • 如果你的程序发了网络包,那它一定走了:你的代码 → 库函数 → 系统调用 → 操作系统网络协议栈 → 网卡驱动 → 网卡硬件

不存在“跳过中间层直接访问硬件”的可能(除非你写的是内核驱动或嵌入式裸机程序,那是另一回事)。用户态程序必须贯穿整个栈。

4. 库可能在底层封装了系统调用

这是对第二点的补充,也是理解“为什么我们平时不用直接写系统调用”的关键。

  • printf() 底层调用了 write() 系统调用。

  • fopen() / fread() 底层调用了 open() / read() 系统调用。

  • std::vector 的扩容,底层调用了 malloc(),而 malloc() 底层又调用了 brkmmap 系统调用。

库把你和“原始的系统调用”隔开了,让你用起来更方便。但你每调一个库函数,背后都有一串系统调用在帮你干活。

5.1 进程理解

5.1.1 怎么理解进程

程序的一个执行实例,正在执行的程序都是进程,是担当分配系统资源的实体。

首先程序运行以后都会被加载到内存,包括操作系统,开机的时候本质就是在打开操作系统。然后操作系统会对其他每一个被加载到内存的程序进行管理,为他们各自生成一个类struct,然后存放数据,也就是每一个程序的属性然后进行管理,比如存放了它们的地址。

然后每一个类还存着指针指向下一个程序地址形成程序链表。意思就是一个个结构体,里面放着程序的所有属性,然后一个指针指向下一个结构体,一个则是指向代码或者数据的存放地址。

因此进程也可以理解成内核数据结构对象 + 自己的代码和数据。而那个内核数据结构对象也叫pcb,进程控制块(task_struct)。也可以简单理解成这个pcb就是进程。

就好像你找工作,hr会筛选简历。是你在找工作没错,但是实际上是你的简历在找。筛选简历没轮到你,说在排队是你真的在排队吗,是你的简历在排队。这个简历包含了你的个人信息属性等等不就相当于pcb吗,你就相当于代码或数据。进程在排队也不是真的代码和数据在排队,是它们的内核数据结构对象也就是pcb在排队。你 + 简历 才是一个进程是一个找工作的人。你躺在宿舍床上你就是一个可执行程序。

我们历史上执行的指令,程序,工具只要运行起来都是进程。

task_struct大致内容有如下:

标示符本进程的唯一ID(PID)。简历上的 “身份证号”或“工号”。HR靠这个精确找到你,而不是喊“那个会编程的”。
状态进程当前在干嘛(运行、就绪、阻塞)。简历上写的 “目前状态”:是“待面试”、“面试中”,还是“等二面通知”。
优先级相对于其他进程的“插队权”。简历上贴的 “VIP标签”。有的人(系统进程)可以插队,比普通简历先被HR处理。
程序计数器下一步要执行的指令地址。简历里夹着的 “待办事项纸条”,写着“面试完第3题后,下一步联系技术主管”。
内存指针你的代码、数据,以及共享内存块的地址。简历上写的 “现住址”“常用办公地点”。HR(操作系统)通过这个地址,才能找到你这个“实体”在哪干活。
上下文数据寄存器里的临时数据(现场)。简历上附着的 “面试记录”。当HR让你去喝茶(换下CPU)时,记录你刚才“说到哪了”、“讨论到什么参数”,回来时让你无缝接着讲。
I/O状态信息用到的文件列表、打印机请求等。简历上写的 “需要的外设”:比如“正在使用会议室投影仪(打印机)”或“等待对方传资料(硬盘IO)”。
记账信息CPU用了多久,花了多少时间。HR记录的 “考勤/工时表”:你今天面了多久,花了公司多少时间成本。

所以理解了进程以后,ctrl c就是用来杀掉进程的。也通过kill -9 杀掉指定进程(带上pid)

千万要注意,Linux中,访问任何一个文件都是进程去访问!用户也是一个进程,会通过查uid判断你是所属组还是other还是拥有者。

5.1.2 查找进程

我们可以在Linux中通过ps指令查有哪些进程。ps axj 。也可以ls -l /proc 。这个proc放的就是内存里的文件。

/proc里头有很多编号对应就是不同的进程,比如进程编号21150,你就可以ls -l /proc/21150,然后看到其进程里头有啥文件。

这里头cwd就是current work dir 显示你这个进程所在目录,exe就是对应进程的可执行文件。chdir是可以对cwd更改的。

5.1.3 父进程

ppid是parent pid。

我们会发现一点,就自己的pid会变化,但是父进程是不变的。

所以父进程到底是谁啊?我们可以ps ajx然后grep一下找关键词3934809,会发现

有个bash,这个是命令行解释器,本质是一个进程。我们当前终端所有的进程都是bash的子进程,os会给每一个登录用户分配一个bash。有一个-在前面意思是我们是远程登录的。

5.1.4 代码创建子进程的方式及理解

fork指令,这个可以用来创建子进程,同样的这个是一个系统的调用。

fork实际长这个样子:

pid_t fork ( void )

fork出来的子进程就是当前进程的子进程了。不过要注意的是,这个时候只是创建了子进程啥也没干,除了pid这些,其他的代码和数据和它的父进程也就是当前进程是一样的。

我们可以通过如下代码加深理解:

#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
​
int main()
{
    printf("父进程开始运行,pid:%d\n", getpid());
​
    pid_t id = fork();
​
    if (id < 0) 
    {
        perror("fork");
        return 1;
    }
    else if (id == 0) 
    {
        // child
        while (1) 
        {
            sleep(1);
            printf("我是一个子进程!,我的pid:%d,我的父进程id:%d\n", getpid(), getppid());
        }
    }
    else 
    {
        // father
        while (1) 
        {
            sleep(1);
            printf("我是一个父进程!,我的pid:%d,我的父进程id:%d\n", getpid(), getppid());
        }
    }
​
    return 0;
}

运行结果:

父进程开始运行,pid:1001
我是一个父进程!,我的pid:1001,我的父进程id:2000
我是一个子进程!,我的pid:1002,我的父进程id:1001
我是一个父进程!,我的pid:1001,我的父进程id:2000
我是一个子进程!,我的pid:1002,我的父进程id:1001
...

但是为什么两个while循环都可以执行到呢?

代码里确实只有一个 id,但 fork() 复制了整个进程,包括这个 id。之后父进程和子进程各自拥有一个名为 id 的独立变量,它们在不同的内存里,互不干扰。fork() 的返回值不同,是因为内核在复制完成后,分别修改了这两个变量。

说白了就是fork确实创建了子进程,但是返回值有两个,父进程返回被通过fork创建出来的子进程的本身的pid,子进程给0。从fork开始,一个进程变成了俩,父进程子进程同时进行运行这套代码(别忘了子进程代码数据这个时候和父进程一样)。

我们可以因此抛出几个问题:为什么fork给父子返回各自的不同返回值?为什么一个函数会返回两次?为什么一个变量,即==0,又大于0?导致ifelse同时成立?

对于第一个问题很好理解:如果不给不同的返回值,父子进程就没办法区分自己是谁,也就没法执行不同的任务,有了子进程的pid返回值就方便父进程对他们进行管理。

对于第二个问题,我们可以先看看fork函数做了什么:

申请新的 PCB内核给子进程分配一块 PCB(进程控制块)
拷贝父 PCB 给子进程把父进程的 PCB 内容复制一份给子进程(PID 和 PPID 会改)
子 PCB 放入进程列表让内核知道有这个进程了
放入调度队列中子进程准备就绪,等待 CPU 调度执行

这里要注意,fork返回值类型是pid_t,这个是fork函数的干的事情,我们仔细看会发现在return之前,子进程就被创建了。所以父进程子进程都会执行一次reutrn,也就是为什么两个返回值。

子进程被创建后拿到了父进程代码数据,这个时候到return,子进程相当于和父进程一样执行到了这一行(这里会看起来有点模糊,意思是子进程复制了父进程的全部内存,包括代码、数据、堆、栈,所以它“看到”的 fork() 调用点、局部变量、程序计数器,和父进程当时一模一样)。

子进程最后的id接收拿到的是0,父进程那边的id接收到的返回值是子进程pid。

同样的,进程具有独立性。

我们可以在那两个if判断里干一件事情,子进程修改一个变量让其自增,父进程只打印。我们看到结果会发现子进程的结果对应变量一直变化自增,父进程的结果只是打印。为什么会这样呢?因为子进程被创建后会复制一份父进程的代码和数据,但是一旦有数据要被修改,这一份数据会存到内存的另一个地方修改从而不影响父进程的数据独立性。

这就叫写时拷贝或者写时复制,拷贝修改时才分配新物理页,不修改时共享同一页。

5.2 进程状态

5.2.1 进程状态理解

其实这本质就是task_struct内的一个整数。

同时在操作系统内部,不单单只是链表维护我们的进程还有队列,我们的调度队列就是如此,调度队列说白了就是意思是选谁上cpu。

我们的进程状态分为三种:运行,就绪,阻塞。

运行态就是进程在cpu上执行的状态或都被称为运行态。

就绪态就是还没上cpu,在调度队列中等待。

阻塞态就是进程因等待某个事件(如 I/O 完成、锁释放、信号等)而无法继续执行。(我们的scanf,cin,终端等待我们输入,进程这个时候就进入了阻塞态)

挂起态就是进程被暂时从内存换出到磁盘(交换区)。此时进程不占用内存,也不参与CPU调度。这是在三态模型基础上扩展出来的一个核心概念。

说白了就是你运行的程序太多了。虽然队列里放的都是指针,但是进来内存的进程太多了有些还太大了。就会把一些暂时不用或者用的贼少的进程的代码数据部分放到磁盘的swap分区,这个叫唤出,调度到你的时候才拿回来,这个叫唤入。

而从阻塞态被挂起后就变成阻塞挂起态。

5.2.2 进程队列

我们实际上有不止一个队列:

1. 运行队列(Run Queue)

运行队列是操作系统调度器维护的一个数据结构,用于存放所有处于就绪态的进程。

  • 作用:保存哪些进程已经准备好使用CPU,正在等待被分配CPU时间片。

  • 调度器工作:调度器会从这个队列中选择一个进程,分配给CPU,使其进入运行态。

  • 例子:在Linux中,ps -eo pid,stat,comm 命令显示的 R 状态进程,说明它正在运行队列中或正在CPU上运行。

  1. 设备队列(Device Queue / I/O Request Queue)

设备队列是每个设备(如磁盘、网卡、键盘)维护的一个队列,用于存放等待该设备服务的进程。

  • 作用:当进程发起一个I/O请求(比如读磁盘)时,如果设备正忙,进程就会被放入该设备的队列中等待。

  • 状态:进程进入设备队列后,会从运行态变为阻塞态

  • 例子:当你同时从磁盘读取多个文件时,这些请求就会在磁盘的设备队列中排队等待。

3. 运行队列和设备队列的关系

这两个队列共同构成了操作系统的调度和资源管理模型。我们可以把进程的一生看作是在这些队列中“流动”:

text

运行队列(就绪态)
    │
    ↓(调度器分配CPU)
运行态(进程在CPU上执行)
    │
    ↓(发起I/O请求)
设备队列(阻塞态,等待设备完成)
    │
    ↓(I/O完成,产生中断)
运行队列(就绪态,重新等待CPU)

所以当我们的进程在cpu执行发现需要申请资源就会被从cpu拿下不再放入运行队列而是丢到等待队列里头,直到申请资源结束。而等待队列是个很宽泛的概念,我们的设备队列也是等待队列的一种,不过是给进程申请硬件资源的时候才会放进设备队列。

不过要注意的是,申请完资源后会离开等待队列。我们拿io资源的申请为例子。你键盘输入完东西按下回车键读给操作系统,我们的进程是不知道自己申请完资源了,操作系统才知道。也就是说不是进程自己离开的等待队列是操作系统让它离开的,包括它离开运行队列一样。然后等到运行队列轮到该进程上cpu的时候,前面代码和数据执行过不再执行,而是顺着上一次没执行完的部分开始,也就是申请io资源的那部分开始。然后读到比如scanf,操作系统就会把我们输入的那些东西或者说申请好的资源拿过来给到该进程。

进程在 CPU 执行
    ↓
执行到 scanf(系统调用 read)
    ↓
内核接管:发现数据未就绪
    ↓
内核:进程从运行队列 → 移入等待队列(设备队列)
    ↓
进程阻塞,CPU 让给其他进程
    ↓
键盘输入完成 → 硬件中断
    ↓
中断处理程序:数据写入内核缓冲区
    ↓
内核:调用 wake_up(),进程从等待队列 → 移回运行队列
    ↓
调度器选中该进程上 CPU
    ↓
内核:恢复上下文(PC 指向系统调用返回点)
    ↓
进程继续执行:scanf 拿到数据,返回
    ↓
进程继续执行后续代码

所以我们操作系统维护着很多个队列,不同的进程会去到对应的队列当中被操作系统维护。千万要注意的是这些队列都不在tasksturct也就是进程内部,而是都是在Linux或者别的操作系统内核那个大结构体内部的。

5.2.3 理解系统内核链表

1. 普通链表 vs 内核链表

特性普通链表(教科书版)Linux 内核链表
节点结构数据 + 指针只有指针(prev / next
数据如何存放数据在节点内部数据在节点外部(通过结构体包含链表节点)
操作函数为每种数据类型单独写一套通用函数(所有类型共用)

2. 内核链表的核心思想

Linux 内核的链表定义在 <linux/list.h> 中,它的节点只有两个指针:

struct list_head {
    struct list_head *next, *prev;
};

关键点:这个链表节点不包含任何用户数据!它只是一个“钩子”,用来把结构体串起来。

你真正关心的数据(比如 task_struct)是外面包了一层 list_head

struct task_struct {
    // ... 一大堆字段
    struct list_head tasks;   // 就是这个钩子
};

3. 通过链表节点找到结构体本身

这是内核链表最精彩的地方。给定一个 list_head 指针,怎么找到它所属的 task_struct

内核用了一个宏:

#define list_entry(ptr, type, member) \
    container_of(ptr, type, member)

container_of 通过计算偏移量,从成员指针反推出整个结构体的起始地址。这是内核里最常用的技巧,本质上就是指针运算。

这样能让“链表操作”和“数据结构”彻底解耦,一套代码管所有类型的链表。这是内核链表设计的核心目的——如果你给每种结构体(task_structfileinode)都单独写一套链表操作代码,那内核代码量会爆炸,维护会非常困难。如果不用这种设计,你就要为每种类型写一套链表函数。

我们怎么访问进程这个类的其他成员呢?我们的next和prev都是指向另一个进程的head的。我们类内的成员的地址是依次递增的,跟着类一起(结构体)。所以我们通过指针运算得到的其他成员。

container_of 通过指针运算解决:

#define container_of(ptr, type, member) \
    ((type *)((char *)(ptr) - offsetof(type, member))) 
  • ptr:指向 member 的指针(即 list_head 的地址)

  • offsetof(type, member)membertype 中的字节偏移量

  • ptr 减去偏移量,得到外层结构体的起始地址

  • 强制转换为 type *

  • 这里提一下为什么要转成char*,因为指针运算的单位是“指向的类型大小”。如果不转成 char *,减去的就不是字节数,而是“多少个元素”的大小。

5.2.4 Linux的进程状态

1. 运行态 (R - Running)

状态:进程正在CPU上运行,或者在运行队列里排队等待运行(即随时可以被调度)。

  • 理解:就像餐厅里的厨师,要么正在炒菜,要么正在等订单(排队),但一旦有空闲灶台,他立刻就能开始干。

  • 命令ps aux | grep ' R'

2. 可中断睡眠态 (S - Sleeping / Interruptible)

状态:进程因为等待某个事件(如用户输入、网络数据、锁释放)而主动让出CPU,进入了睡眠。它可以被信号或中断唤醒。

  • 理解:厨师在等水烧开(等待I/O),这时候他可以被打断,比如接到通知去处理其他紧急订单。

  • 常见场景:这是系统中最常见的状态,一个正常的服务进程大部分时间都处于这种“空闲等待”状态。

  • 命令ps aux | grep ' S'

3. 不可中断睡眠态 (D - Uninterruptible Sleep)

状态:进程也在睡眠,但不能被信号或中断打断。它通常是在直接等待硬件I/O(如等待磁盘读写完成),必须等内核完成操作才能醒来。

  • 理解:厨师正在往滚烫的油锅里下菜,这个操作必须一气呵成,不能中途被打断,否则会出事故(数据损坏)。

  • 关键:一个进程如果长时间处于D状态,通常是I/O瓶颈或硬件故障的信号,且无法用kill杀死

  • 命令ps aux | grep ' D'

所以大面积进程D状态是很危险的,但是正常情况下只会在关键数据传输才会有D状态。

4. 停止态 (T - Stopped / Traced)

状态:进程被暂停执行了。通常是因为收到了SIGSTOP信号(比如你在终端按Ctrl+Z),或者正在被调试器(gdb)跟踪。

  • 理解:厨师突然接到命令“暂停当前工作,原地待命”。他还在,只是不干活了。

  • 恢复:可以用fg(前台恢复)或bg(后台恢复)让其继续运行。

  • 命令ps aux | grep ' T'

这里还有一个状态是小写的t。意思是debug的时候被调试器停止了。比如设置了断点,r运行到那里停下来了,进程也停了。

不过暂停意思不是阻塞或者杀掉进程了,而是像大T这样主动通过ctrl z挂起了或者小t因为某些条件被挂起了。

5. 僵尸态 (Z - Zombie / Defunct)

状态:进程已经执行完毕并退出,但它占用的task_struct(进程控制块)还没有被父进程回收。这是一个“已经死了但还没安葬”的状态。

  • 理解:厨师干完活离职了(代码结束),但他的工牌(PCB)还在人事系统里,等着主管(父进程)来注销。

  • 关键:僵尸进程不占用内存和CPU,只占用一点内核内存来保留退出信息。但如果父进程不回收,僵尸积累过多会耗尽内核资源,导致无法创建新进程。

  • 命令ps aux | grep ' Z'。可以看到defunct标记。

6. 补充

  • I (Idle) 空闲态:这是D状态的一种优化,用于内核中不能中断的睡眠,但确保它不会阻塞系统关机。

  • X (Dead) 死亡态:进程彻底终止,即将被回收。这个状态通常看不见,因为它一闪而过。

然后对于有scanf或者printf函数的代码而言,查进程会发现他们的进程状态都是S而不是R,其实是这样的:

有scanf或者printf函数,大部分时间也是在阻塞,哪怕是printf,因为它们要读io资源也就是双引号内部的字符串。执行代码很快,但是等这个资源拿过来就慢。

对于有没有加号是这样的:

标识含义实际效果
+进程属于后台进程组可以在后台运行,Ctrl+C 不会杀死它
+进程属于前台进程组它拥有终端的控制权,Ctrl+C 会发送 SIGINT 给它

5.2.5 僵尸进程

僵尸进程是已经终止执行(调用了 exit 或收到终止信号),但其父进程尚未调用 wait()waitpid() 来回收其退出状态信息的进程。

它的进程描述符(struct task_struct)仍然保留在内核中,但代码段、数据段、堆栈等内存资源已经被释放。它只剩下一个“空壳”——内核中的一条记录,等待父进程来收尸。

僵尸进程是怎么产生的?(底层流程)

  1. 子进程终止:子进程调用 exit() 或发生致命错误,内核将其状态设为 TASK_DEAD(死亡态)。

  2. 内核发送 SIGCHLD 信号:内核向父进程发送 SIGCHLD 信号,通知它“你的子进程挂了”。

  3. 父进程处理信号

    • 如果父进程注册了 SIGCHLD 的信号处理函数,并在其中调用了 wait() / waitpid() → 内核回收子进程的 task_struct 和 PID,僵尸消失。

    • 如果父进程忽略 SIGCHLD 或没有调用 wait() → 子进程的 task_struct 保留,进入僵尸状态。

  4. 父进程最终退出:如果父进程也退出了,僵尸子进程会被 init(PID=1)进程收养,并由 init 调用 wait() 回收。

僵尸进程的产生,100% 是父进程没尽到“收尸”责任,而不是子进程的问题。

那么僵尸进程为什么杀不死?

因为 kill -9 发的是 SIGKILL,而僵尸进程已经死了——它只是内核中的一条记录,没有代码在运行,也没有信号处理函数来响应。kill -9 对僵尸无效

唯一“杀死”僵尸的办法就是让它的父进程调用 wait() 回收它或者是如果父进程已死,init 会定期 wait(),自动清理。

僵尸进程引发的内存泄漏问题:

僵尸进程本身不占用内存,占的是内核里的进程表(task_struct)和 PID。如果大量累积,会导致无法创建新进程,效果和内存泄漏一样——资源被占着不释放,最终系统瘫痪。防止的办法就是让父进程正确处理 SIGCHLD 或调用 wait()

说白了就是被释放掉代码数据,但是taskstruct还在,意思就是说进程资源没了,但是个人信息pcb这些还在内核里头占着。要是越来越多这种僵尸进程一直占着,后续想要开新进程那是做不到的。

5.2.6 孤儿进程

说白了就是失去父进程的进程就是孤儿进程。父子进程关系中,要是父进程先退出,子进程就会被1号进程收养 ,这个被收养的进程就是孤儿进程。

但是1号进程是谁啊?其实就是systemd(老系统是init),也就是系统初始化系统。为什么要领养它这个进程呢?如果不领养的话,子进程再退出的时候就没有关心他了。僵尸进程是父亲可能没管,但是孤儿进程退出没有人领养百分百就是僵尸了。

孤儿进程被领养后就会变成后台进程。

5.3 进程优先级及切换

5.3.1 进程优先级理解

(1)什么是进程优先级

说白了就是进程得到cpu资源的先后顺序。

(2)为什么需要进程优先级?

毕竟目标资源稀缺,获取资源就是需要优先级,谁优先级高谁先上cpu。就好比你下午三四点去饭店吃饭,你要排队吗?你赶饭点去那能不排队吗。或者医院救人,你都不是那种要挂了的人医院凭什么先救你,有的人都要不行了,医院当然先救那个要不行的人。这就是优先级。

(3)什么叫进程饥饿

进程一直等待本应该会提高优先级防止它一直等待上不了cpu。但是要是一直都有特别高优先级的进程进来,那你这个进程优先级再提高也比不过人家,也确实没办法上cpu,这就是进程饥饿。说白了,你排急诊,一会进来一个重症一会进来一个重症,那不活该轮不到你吗。那你什么时候才能上cpu,说白了就是你等的你也快挂的时候,医院不就先救你了吗,也就是说进程一直等,优先级被提的足够高了,那也能上cpu了。

(4)优先级和权限区别

权限是权限优先级是优先级,残障人士退役军人排队可以优先,你不行,这就是优先级。你能不能直接闯进你老板办公室或者闯入别人的住宅区域,这是权限。一个是先不先,一个是允许不允许。

(5)进程优先级操作系统是怎么做的

本质也是一种整数,放在每一个进程的taskstruct里头,有些可能设计的是值越低优先级就越高,反之越低,具体情况具体分析。

(6)怎么查进程优先级

ps -al,psProcess Status 的缩写,是 Linux/Unix 系统下最核心的进程查看命令。它用来列出当前系统中正在运行的进程快照(Snapshot),相当于任务管理器。

这里顺便带一下前面的ps -ajx,这些选项啥意思:

选项全称作用效果
lLong显示更多列把进程的 PRI(优先级)、NI(nice值)、RSS(内存)、WCHAN(内核等待通道)这些底层的“硬核”信息全列出来。
jJob显示作业控制信息显示 PGID(进程组ID)和 SID(会话ID)。这东西是Shell作业管理(比如你用 Ctrl+Z 挂起任务)的幕后核心。
xextended显示“没终端”的进程把你平时 ps 看不到的守护进程(Daemon)也显示出来。比如系统后台的 systemdsshd

5.3.2 优先级极值问题

默认优先级值是80,还有一个叫做NI是进程优先级的修正数据。我们查到的PRI是实际计算好的值。

PRI = PRI(默认)+ NI

PRI越高,优先级越低;反之PRI越低,优先级越高。

NI的调整范围是[ -20 , 20 ),所以当我们进行优先级调整的时候是没有办法调个什么100啊 -100的。所以进程优先级范围是 [ 60 , 99 ](或者说[ 60 , 100 ))

不过要注意Linux实际由实时进程和普通进程组成,我们的进程基本上全是普通进程。Linux 内核进程优先级本质上是 0~139,正常来说只有实时进程的优先级才是0-99,普通的都是100-139。用ps -al命令查会通过偏移量的计算使普通进程的优先级落在60-99的区间。因此这个命令查优先级无法区分实时进程和普通进程。

5.3.3 优先级调整命令

1. nice —— 启动时指定优先级

适用于你正要运行一个程序时,想给它一个较低的优先级(默认优先级是 0)。

# 语法:nice -n 优先级值 命令
nice -n 10 ./my_program   # 以优先级 10 启动(值越大,越“谦让”)

关键点

  • nice 值范围:-20(最高优先级) ~ 19(最低优先级)

  • 普通用户只能调 nice 值(即降低优先级,让出资源),比如 nice -n 10nice -n 19

  • 只有 root 才能调 nice 值(即提高优先级),比如 nice -n -10

2. renice —— 调整已运行进程的优先级

适用于你已经启动了一个进程(比如 ps 看到它的 PID 是 1234),想动态调整它的优先级。

# 语法:renice -n 新优先级 -p PID
renice -n 5 -p 1234        # 把 PID 1234 的优先级改为 5
renice -n -5 -p 1234       # 把优先级改为 -5(需要 root 权限)

关键点

  • 同样受范围限制(-20 ~ 19)。

  • 同样受权限限制(普通用户只能升 nice 值,root 可以降 nice 值)。

5.3.4 竞争,独立,并行,并发

竞争性:系统进程数目众多,而CPU资源只有少量,甚至1个,所以进程之间是具有竞争属性的。为了高效完成任务,更合理竞争相关资源,便具有了优先级。

独立性:多进程运行,需要独享各种资源,多进程运行期间互不干扰。

比如抖音和微信都在运行,你微信挂了,抖音不会跟着一起挂。

并行:多个进程在多个CPU下分别,同时进行运行,这称之为并行。

并发:多个进程在一个CPU下采用进程切换的方式,在一段时间之内,让多个进程都得以推进,称之为并发

5.3.5 进程切换

首先要理解一旦一个进程占有cpu,它不会真的把自己的代码全跑完,而是会根据时间片轮转。因此死循环进程不会打死我们的操作系统,不会一直占着cpu不动。既然不会一直占着cpu那就说明有人来有人就要走。这就会涉及进程切换。

cpu上会有许多各种各样的寄存器,它们会被用来保存进程的临时数据。

但是要注意的是:进程本身的代码和数据是都保存在内存的,这部分叫做这是静态资源,进程切换时不动它(它一直在内存里,CPU只负责“读”)。我们进程切换的时候的寄存器保存的是运行到哪行代码的临时数据和对应进程信息,这部分叫做这是动态现场(PC/寄存器/栈指针),是进程“此刻”的状态。然后进程要被切换了的时候,用来保存其动态现场的这些寄存器这些东西马上会被拷贝到内存保存一份。等这个进程再一次回到cpu的时候,内存保存的那一份再协会寄存器,就可以直接从上次离开cpu最后运行的那一行代码接着运行了。

也就是说:进程切换的本质是保存CPU的现场,而不是保存代码和数据。代码和数据是静态的,始终在内存中。寄存器里存的才是进程的‘临时状态’——比如程序计数器(PC)指向下一条指令地址,通用寄存器里放着正在计算的中间结果。切换时内核把现场拷贝到内存的 task_struct 里,恢复时再写回寄存器,所以进程重新上CPU时能无缝衔接,直接从上次暂停的地方继续执行。

进程被切换了以后再一次回来的时候,操作系统它们怎么知道是哪个进程回来了?又怎么知道从哪里开始继续?其实被切走那会,寄存器的动态现场数据是保存在进程的task_struct里了。是调度器通过进程的 task_struct(内核控制块)找到了这个数据。task_struct里有一个字段叫 thread( thread_struct 类型),里面保存了该进程上一次被切走时所有寄存器的完整快照,也就是寄存器保存的那部分数据拷贝给了这个进程的 thread这个字段。调度器调用 __switch_to() 汇编函数,直接从 thread 里把寄存器的值读出来,写回 CPU 寄存器。

而保存现场则是一个具体的“存”动作,当进程即将被切换出去时,内核执行的一步操作。把 CPU 当前的所有寄存器值(PC、栈指针、通用寄存器、状态寄存器等)拷贝到内存中,存到当前进程的 task_struct->thread 里。

就好比你正在看书,突然有人叫你出去。你合上书,在书上折个角,并用笔在纸上记下“第 34 页第 2 段”。这个“记页码”的动作,就是保存现场。

5.4 进程调度

这里可以先谈谈Linux以前和现在的不同调度方式:

历史调度器:O(1)调度器 (Linux 2.6.0 - 2.6.22)

它在2002年被引入,主要目的是解决早期调度器效率低下的问题,目标是实现高效、确定性的调度,并更好地支持多核CPU。

  • 数据结构优先级数组 + 位图。每个CPU都有一个运行队列,里面包含140个位置的优先级链表数组(queue[140])(有点类似哈希表的链地址法,数组上每一个位置上都是一个链表,就像vector<list>),并用一个位图(bitmap[5])来快速标记哪个优先级链表里有进程在等待。这种设计能让调度器在常数时间(O(1)) 内找到最高优先级的进程。

  • 调度与时间片:它是基于优先级和时间片的。每个进程的静态优先级在 100~139(对应 nice-20~19),这个值决定了它能分配到的时间片长度。内核会动态调整进程的优先级,比如为了提升交互体验,会奖励“善于睡眠”的I/O密集型进程,惩罚“占用CPU过久”的CPU密集型进程。

  • 公平性:它追求的是吞吐公平。每个进程在运行前,时间片就已经是确定的。一个进程的时间片耗完后,会被移到“过期队列”,等所有进程都耗完时间片后,再一起重新计算,这保证了每个任务最终都能被执行。

  • 多核支持:每个CPU都有自己的运行队列和锁,避免了全局锁的竞争,扩展性比之前的版本好很多。

现代调度器:CFS (Linux 2.6.23 - 至今)

随着多核处理器越来越复杂,O(1)调度器在处理大量进程时的公平性和交互性上也显得有些吃力了。于是CFS在2007年被引入,它的设计理念发生了根本性转变。

  • 数据结构红黑树。CFS抛弃了优先级数组,改用了一棵以进程的虚拟运行时间(vruntime 为键值的红黑树。vruntime记录了一个进程已经运行了多久。调度器每次只需要选择这棵树最左边的节点(即vruntime最小的进程)来运行,这样就能保证每个进程都能公平地获得CPU时间。

  • 调度与时间片:它是基于权重的公平调度。它的核心理念是模拟一个“理想的多任务CPU”,让所有可运行进程都“同时” 运行。vruntime是真实运行时间按进程权重(由nice值决定)比例计算后的结果,权重越高,vruntime增长越慢,获得CPU的机会就越多。

  • 公平性:它追求的是延迟公平。它不再有固定时间片的概念,而是根据系统负载动态计算出一个调度周期,在这个周期内,每个进程都按照权重比例获得CPU时间。最终目标是让所有进程的vruntime值趋近于相等,从而实现真正的“完全公平”。

  • 多核支持:虽然也使用每CPU运行队列,但为了实现全局公平,它会进行负载均衡,尝试在不同CPU之间迁移进程,保持整体负载的均衡。

表格对比如下:

特性O(1)调度器 (老)CFS调度器 (新)
核心思想优先级 + 时间片完全公平 + 虚拟运行时间
核心数据结构140个优先级队列 + 位图红黑树(按vruntime排序)
调度复杂度O(1) 常数时间O(log N) 对数时间(树查找)
时间片固定的,由优先级决定动态的,按权重在调度周期内分配
优先级映射0-99:实时;100-139:普通(nicenice值映射为权重,决定vruntime增速
公平性侧重点吞吐公平(确保都能运行)延迟公平(确保运行时间比例公平)
主要优势调度决策快,确定性高公平性好,交互响应优秀
主要局限大量进程时公平性下降复杂的负载均衡逻辑

所以调度的本质就是在运行队列里头找到对应的存放进程的队列,然后根据优先级找到对应位置,然后遍历对应位置的进程一个一个上cpu。

千万要注意的是,运行队列的底层本质是一个结构体,里面放了不同字段和存放进程的队列(实际上放进程的队列也分为好多种),不要真的以为运行队列真的就是一个queue数据结构。

这就好比把“存放就绪进程的地方”统一称为就绪队列(Ready Queue),这是一个概念层的术语,不是实现层的描述。Linux内核只是沿用了这个术语,但在实现层把它变成了一个结构体容器,和“队列”数据结构已经没有任何关系。

同时在O(1)调度器 (Linux 2.6.0 - 2.6.22)中,我们会存在两种队列是一个结构体runqueue,然后包含了两个一样queue[240],位图bitmap等等,一个用作活跃进程,一个是过期进程的队列。

意思是该cpu给这个进程的时间片用完了,用完了就得去过期队列。紧接着,然后活跃进程的队列空了也就是活跃队列进程全都上过cpu了 ,过期队列的进程就开始进活跃进程队列,并重新分配时间片上cpu。同时对于新进程而言,新进程刚创建:还没有运行过,有自己的初始时间片,还处于就绪状态,需要被调度,所以肯定是先进活跃队列的。

并且对于O(1)调度器而言,有active和expired指针:

  • active 指针:永远指向活动队列(Active Queue)。这个队列里存放的是所有时间片尚未用完、当前可以被CPU调度执行的进程。调度器每次挑选进程时,都只从这个队列里选。

  • expired 指针:永远指向过期队列(Expired Queue)。这个队列里存放的是所有时间片已经用完,需要等待下一次调度的进程。

正常进程的时间片用完,会去过期队列,这本身就是对单个进程的移动操作,就正常过期队列通过尾插法把进程加入进来指定优先级位置,然后活跃队列把该进程节点移出就行。但是当活跃队列的所有进程都上完cpu了,active指针和expired指针这个时候只需要相互swap一下,active直接去拿现在expired指向的过期队列就行了,就不用遍历整个过期队列queue[140],进行O(n)的复杂度的一个一个把进程节点移动过去了。

不过现代Linux已经没有这种活跃队列过期队列的东西了。

这就像“文件系统”不一定是“文件+系统”,而是一个复杂的VFS层;就像“虚拟内存”不一定是“内存”,而是一套地址映射机制。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐