Linux进程基础(二):进程调度、上下文切换、O(1)调度器、环境变量与虚拟地址空间

Linux进程基础(二):进程调度、上下文切换、O(1)调度器、环境变量与虚拟地址空间


🔥 星恒随风: 个人主页 ❄️ 个人专栏: 《指针合集》 | 《C语言基础》 | 《数据结构》 | 《机器学习导论》 | 《前端基础》 | 《python基础》 | 《C++从入门到入土》 | 《Linux的学习之旅》 ✨ 数据即知识,压缩即智能


文章目录

  • Linux进程基础(二):进程调度、上下文切换、O(1)调度器、环境变量与虚拟地址空间
    • 前言:操作系统到底如何让几百个进程共享几个CPU?
      • [1. 上一篇留下的问题](#1. 上一篇留下的问题)
    • [一、先提出问题:一个 CPU 怎样运行多个进程?](#一、先提出问题:一个 CPU 怎样运行多个进程?)
      • [1.1 从最简单的单核场景开始](#1.1 从最简单的单核场景开始)
      • [1.2 进程具备运行条件,不代表正在占用 CPU](#1.2 进程具备运行条件,不代表正在占用 CPU)
      • [1.3 睡眠任务为什么不直接参与 CPU 竞争?](#1.3 睡眠任务为什么不直接参与 CPU 竞争?)
    • [二、一个进程究竟在什么情况下让出 CPU?](#二、一个进程究竟在什么情况下让出 CPU?)
      • [2.1 主动让出:运行中的进程需要等待](#2.1 主动让出:运行中的进程需要等待)
      • [2.2 被动让出:操作系统需要让其他任务运行](#2.2 被动让出:操作系统需要让其他任务运行)
      • [2.3 一个完整的状态变化例子](#2.3 一个完整的状态变化例子)
    • [三、上下文切换:CPU 如何记住进程上次运行到哪里?](#三、上下文切换:CPU 如何记住进程上次运行到哪里?)
      • [3.1 为什么不能简单地把 P1 换成 P2?](#3.1 为什么不能简单地把 P1 换成 P2?)
      • [3.2 上下文切换的核心过程](#3.2 上下文切换的核心过程)
      • [3.3 task_struct 在这里扮演什么角色?](#3.3 task_struct 在这里扮演什么角色?)
      • [3.4 上下文切换有成本吗?](#3.4 上下文切换有成本吗?)
    • 四、从"切换"继续追问:谁负责选择下一个任务?
      • [4.1 调度器与上下文切换不是同一件事](#4.1 调度器与上下文切换不是同一件事)
      • [4.2 按什么标准选择?](#4.2 按什么标准选择?)
      • [4.3 为什么会有 PRI 和 NI?](#4.3 为什么会有 PRI 和 NI?)
      • [4.4 为什么要先谈优先级?](#4.4 为什么要先谈优先级?)
    • 五、最朴素的调度算法:把所有就绪任务检查一遍
      • [5.1 第一版:线性扫描](#5.1 第一版:线性扫描)
      • [5.2 这样做有什么问题?](#5.2 这样做有什么问题?)
    • [六、从"检查多少个进程"自然引出时间复杂度与 Big-O](#六、从“检查多少个进程”自然引出时间复杂度与 Big-O)
      • [6.1 先定义输入规模 n](#6.1 先定义输入规模 n)
      • [6.2 什么是时间复杂度?](#6.2 什么是时间复杂度?)
      • [6.3 Big-O 的正式含义](#6.3 Big-O 的正式含义)
      • [6.4 为什么刚才的扫描是 O(n)?](#6.4 为什么刚才的扫描是 O(n)?)
      • [6.5 那么 O(1) 到底是什么意思?](#6.5 那么 O(1) 到底是什么意思?)
      • [6.6 为什么 O(1) 也可能不够快?](#6.6 为什么 O(1) 也可能不够快?)
    • [七、Linux 2.6 历史 O(1) 调度器:怎样避免扫描 n 个任务?](#七、Linux 2.6 历史 O(1) 调度器:怎样避免扫描 n 个任务?)
      • [7.1 第一步:不按"进程"组织,而按"优先级"组织](#7.1 第一步:不按“进程”组织,而按“优先级”组织)
      • [7.2 第二步:先实现一个简单版本](#7.2 第二步:先实现一个简单版本)
      • [7.3 第三步:为什么已经是 O(1),还需要 bitmap?](#7.3 第三步:为什么已经是 O(1),还需要 bitmap?)
      • [7.4 课件中的 bitmap5 是怎么来的?](#7.4 课件中的 bitmap[5] 是怎么来的?)
      • [7.5 位图的优化到底优化了什么?](#7.5 位图的优化到底优化了什么?)
    • [八、为什么还要设计 active 和 expired 两套队列?](#八、为什么还要设计 active 和 expired 两套队列?)
      • [8.1 只有优先级还不够,还要解决"本轮运行多久"](#8.1 只有优先级还不够,还要解决“本轮运行多久”)
      • [8.2 每个 CPU 的 runqueue 保存什么?](#8.2 每个 CPU 的 runqueue 保存什么?)
      • [8.3 active 逐渐空了以后,怎么办?](#8.3 active 逐渐空了以后,怎么办?)
      • [8.4 关键设计:不搬任务,只交换指针](#8.4 关键设计:不搬任务,只交换指针)
      • [8.5 把一次调度完整串起来](#8.5 把一次调度完整串起来)
      • [8.6 O(1) 指的是整个调度过程没有开销吗?](#8.6 O(1) 指的是整个调度过程没有开销吗?)
    • [九、把历史算法放回现代 Linux:为什么仍值得学?](#九、把历史算法放回现代 Linux:为什么仍值得学?)
      • [9.1 这是哪一代调度器?](#9.1 这是哪一代调度器?)
      • [9.2 从旧调度器学到的三个思想](#9.2 从旧调度器学到的三个思想)
    • [十、动手观察:Linux 中怎样查看调度与切换?](#十、动手观察:Linux 中怎样查看调度与切换?)
      • [10.1 查看一个进程的状态、优先级和所在 CPU](#10.1 查看一个进程的状态、优先级和所在 CPU)
      • [10.2 查看系统整体上下文切换计数](#10.2 查看系统整体上下文切换计数)
      • [10.3 查看某个进程的自愿/非自愿切换统计](#10.3 查看某个进程的自愿/非自愿切换统计)
      • [10.4 用 C 程序观察自愿切换的可能变化](#10.4 用 C 程序观察自愿切换的可能变化)
    • 十一、从进程"怎样运行"转到"带着什么环境运行":环境变量
      • [11.1 一个进程启动时不仅有代码](#11.1 一个进程启动时不仅有代码)
      • [11.2 PATH:为什么 `ls` 不需要写路径,自己的程序需要 `./`?](#11.2 PATH:为什么 ls 不需要写路径,自己的程序需要 ./?)
      • [11.3 Shell 变量和导出的环境变量不同](#11.3 Shell 变量和导出的环境变量不同)
      • [11.4 用 getenv 读取环境变量](#11.4 用 getenv 读取环境变量)
    • 十二、最后一个问题:切换不同进程时,内存为什么不会混乱?
      • [12.1 问题从上下文切换重新出现](#12.1 问题从上下文切换重新出现)
      • [12.2 用 fork 做一个经典验证](#12.2 用 fork 做一个经典验证)
      • [12.3 为什么虚拟地址可以一样,而数据不一样?](#12.3 为什么虚拟地址可以一样,而数据不一样?)
      • [12.4 一个经典的进程地址空间示意](#12.4 一个经典的进程地址空间示意)
      • [12.5 内核怎样描述这片空间?](#12.5 内核怎样描述这片空间?)
      • [12.6 为什么要有虚拟地址?](#12.6 为什么要有虚拟地址?)
    • 十三、回到全文:把调度、复杂度与地址空间连成一条线
      • [13.1 一条完整的问题驱动链](#13.1 一条完整的问题驱动链)

前言:操作系统到底如何让几百个进程共享几个CPU?

1. 上一篇留下的问题

假设系统中存在:

text 复制代码
Process A
Process B
Process C
...
Process N

但是只有:

text 复制代码
8个CPU核心

甚至在最简单模型中只有:

text 复制代码
1个CPU

那么操作系统必须不断回答:

text 复制代码
下一个运行谁?

运行多久?

优先级不同怎么办?

进程睡眠以后怎么办?

时间片用完以后怎么办?

这就是:

text 复制代码
进程调度
Scheduling

一、先提出问题:一个 CPU 怎样运行多个进程?

1.1 从最简单的单核场景开始

假设系统中有三个进程:

  • P1:正在计算数组之和;
  • P2:正在编译一个 C++ 文件;
  • P3:正在等待用户输入。

这里只有一个 CPU 核心。在某一个瞬间,它通常只能执行一个线程的指令。那么,为什么我们还能一边编译、一边使用终端、一边运行其他程序?

因为操作系统会安排不同任务轮流使用 CPU。一个简化的时间线是:

text 复制代码
时间 ──────────────────────────────────────────────→
CPU  |  P1  |  P2  |  P1  |  P2  |  P1  | ...
P3   |    等待输入    | 被唤醒 | 等待CPU | ...

这里先要区分两个容易混淆的概念:

  • 并发(Concurrency):多个任务在一段时间内都获得进展,不要求同一瞬间真正执行。
  • 并行(Parallelism):多个任务在不同 CPU 核心上同一时刻执行。

单核上可以并发,多核上可以同时发生并发和并行。

1.2 进程具备运行条件,不代表正在占用 CPU

回顾上一篇的 R 状态:它通常既包含正在 CPU 上执行 的任务,也包含已经就绪、正在等待 CPU的任务。

text 复制代码
                  具备运行条件
                       │
                       ▼
                就绪队列 Runqueue
                       │
                调度器选择一个
                       │
                       ▼
                   正在运行

注意:Runqueue 不是"一条所有进程都排在里面的普通链表"的同义词;这里只是先用队列帮助理解"有一批可运行任务需要被选择"。真正的组织方式取决于调度器。

1.3 睡眠任务为什么不直接参与 CPU 竞争?

例如 P3 正在执行阻塞式输入:

c 复制代码
int x;
scanf("%d", &x);

如果输入尚未到来,程序可能进入等待状态。它此时没有可以继续执行的工作,因此操作系统通常不会把 CPU 时间白白分给它。

等输入到达后,内核使其重新可运行(runnable),它才有机会再次获得 CPU。

由此出现了第一个任务:操作系统需要管理哪些任务能运行,哪些暂时不能运行。


二、一个进程究竟在什么情况下让出 CPU?

2.1 主动让出:运行中的进程需要等待

典型情况:

text 复制代码
P1 正在执行
    ↓
发起阻塞式 I/O
    ↓
暂时无法继续
    ↓
进入等待状态
    ↓
CPU 交给其他可运行任务

这一类由任务进入等待等原因引起的切换,可粗略归入 自愿上下文切换(voluntary context switch 的范畴。

但要注意,并不是每一次系统调用都会使进程阻塞或切换。

2.2 被动让出:操作系统需要让其他任务运行

如果 P2 一直执行:

c 复制代码
while (1) {
    /* 持续计算 */
}

它不会主动调用 sleep(),但操作系统不能允许一个普通进程永久占用 CPU。

在抢占式调度中,内核可根据定时器、调度策略和更高优先级任务就绪等事件,决定让当前任务暂停并运行另一个任务。

这属于 非自愿上下文切换(involuntary context switch) 的典型情形。

2.3 一个完整的状态变化例子

text 复制代码
                P1 已就绪
                    │
                    ▼
              调度器选中 P1
                    │
                    ▼
                P1 正在运行
                 /        \
           等待 I/O       被抢占
              │             │
              ▼             ▼
            睡眠         重新就绪
              │             │
          事件完成          │
              │             │
              └──────┬──────┘
                     ▼
                  等待调度

现在问题已经比"CPU 按顺序执行程序"复杂了:任务会就绪、运行、阻塞、唤醒、再次就绪。在这些变化中,还必须保证任务重新运行时能够接着先前的位置执行。

这就自然引出了上下文切换。


三、上下文切换:CPU 如何记住进程上次运行到哪里?

3.1 为什么不能简单地把 P1 换成 P2?

假设 P1 执行如下循环:

c 复制代码
int sum = 0;
for (int i = 1; i <= 100000; ++i) {
    sum += i;
}

执行到 i = 5000 时,操作系统决定切换到 P2。

那么下一次重新运行 P1,它应该从哪里开始?显然不能从 main() 的第一行重新执行,也不能丢掉正在计算的中间状态。

CPU 运行程序时,执行现场的一部分放在寄存器中,例如:

  • 指令位置(程序计数器 / 指令指针);
  • 栈指针;
  • 通用寄存器;
  • 必要的处理器状态。

此外,进程还有地址空间及其他内核管理信息。

3.2 上下文切换的核心过程

一轮 P1 → P2 的简化步骤如下:

text 复制代码
                    CPU 正在运行 P1
                            │
                            ▼
                  发生需要调度的事件
                            │
                            ▼
                 进入内核调度相关路径
                            │
                            ▼
              调度器选择下一个任务 P2
                            │
                            ▼
               保存 P1 必要的执行上下文
                            │
                            ▼
               切换到 P2 的执行上下文
                            │
                            ▼
                    CPU 继续执行 P2

以上是逻辑过程:内核实际代码中,"保存现场""选择下一任务""低层切换"的具体先后和职责分工更精细,不必把这张教学图理解成某个内核版本逐行调用顺序。

3.3 task_struct 在这里扮演什么角色?

上一篇介绍过,Linux 用 task_struct 描述任务。操作系统保存和恢复任务现场时,会借助任务对应的内核管理信息及内核栈。

text 复制代码
P1 的 task_struct / 内核栈          P2 的 task_struct / 内核栈
              ▲                               │
              │ 保存                           │ 恢复
              │                               ▼
           CPU ─────────────── 切换 ────────── CPU

因此,进程并非只由代码和数据组成,还必须包含使它可以暂停并继续执行的管理信息。

3.4 上下文切换有成本吗?

有。它可能带来:

  • 保存和恢复部分 CPU 状态的开销;
  • 执行调度器代码的开销;
  • 切换地址空间时的相关开销(视任务关系及硬件机制而定);
  • CPU 缓存和 TLB 局部性受到影响。

所以并不是"切换越频繁,系统越快"。调度器必须在响应性、公平性、吞吐量与切换开销之间权衡。

这里产生第二个问题:内核已经准备切换了,但它究竟应该选 P2、P3 还是 P4?


四、从"切换"继续追问:谁负责选择下一个任务?

4.1 调度器与上下文切换不是同一件事

可以把它们分工理解:

text 复制代码
调度器 Scheduling
    ↓
决定:下一个运行谁?

上下文切换 Context Switch
    ↓
执行:怎样让 CPU 从旧任务转到新任务?

调度器做出选择 ,底层切换机制完成执行权交接。而且发生调度决策不等于一定切到不同任务:如果仍选择当前任务,便可能不发生进程间上下文切换。

4.2 按什么标准选择?

一个调度器不能只看"谁排在最前面"。实际还需要考虑:

  • 优先级:哪些任务更应及时运行;
  • 公平性:不能让普通可运行任务长期得不到 CPU;
  • 响应速度:输入、交互任务最好尽快得到服务;
  • CPU 利用率与吞吐量:减少不必要的切换与空闲;
  • 多核负载均衡:不能让部分 CPU 忙、其他 CPU 空闲。

我们先抽象出一个最简单的目标:

假设已经有一批可运行任务,并且可以给每个任务计算一个"适合下一次运行的评分",该怎样从中快速找到最合适的那个?

4.3 为什么会有 PRI 和 NI?

在 Linux 中,经常用以下命令观察:

bash 复制代码
ps -o pid,ppid,stat,pri,ni,comm -p $$
  • PRI:工具显示的调度优先级相关数值;
  • NI:nice 值,普通任务通常是 -20 ~ 19,数值更小一般意味着获得 CPU 的相对权重更高。

例如:

bash 复制代码
nice -n 10 ./app
renice 5 -p 1234

课件用 PRI(new) = PRI(old) + nice 帮助理解"nice 会影响优先级"。这是教学上的直观模型,不是当代 Linux 各调度策略统一适用的内核计算公式。

4.4 为什么要先谈优先级?

因为没有明确"选择标准",就无法讨论怎样快速找到符合标准的任务。现在标准有了,就可以开始设计算法。


五、最朴素的调度算法:把所有就绪任务检查一遍

5.1 第一版:线性扫描

假设我们用一条链表保存所有可运行进程:

text 复制代码
runqueue
   │
   ▼
  P1 ─→ P2 ─→ P3 ─→ ... ─→ Pn

每次调度,需要逐个查看并比较:

cpp 复制代码
// 教学伪代码:不是 Linux 内核源码
Task* pick_next(Task* head)
{
    Task* best = nullptr;

    for (Task* cur = head; cur != nullptr; cur = cur->next)
    {
        if (best == nullptr || better(cur, best))
        {
            best = cur;
    	 }
    }
    return best;
}

5.2 这样做有什么问题?

假设有 n 个可运行任务:

可运行任务数 n 最坏情况下需要检查的任务数
10 10
100 100
1,000 1,000
100,000 100,000

随着任务数增大,"每次选择下一任务"的扫描量也相应增大。

于是必须找到一种能够衡量这种增长关系的方法。


六、从"检查多少个进程"自然引出时间复杂度与 Big-O

6.1 先定义输入规模 n

这里的 n 指的是需要参与选择的可运行任务数,而不是 CPU 核心数,也不是系统里所有处于睡眠状态的进程总数。

如果算法最多需要检查 n 个任务,可粗略写成:

T ( n ) = a n + b = O ( n ) T(n) = an + b = O(n) T(n)=an+b=O(n)

其中 a 是每次检查的操作成本,b 是固定开销。我们关心的是:当 n 增大时,操作量如何增长?

6.2 什么是时间复杂度?

时间复杂度通常不直接记录某台电脑跑了多少毫秒,而是分析操作次数随输入规模增长的规律。

例如:

cpp 复制代码
int f1(const int* a) { return a[0]; }

int f2(const int* a, int n)
{
    int sum = 0;
    for (int i = 0; i < n; ++i)
        sum += a[i];
    return sum;
}

f1 读取固定一个元素,f2 要遍历 n 个元素;当 n 变大时,它们增长趋势不同。

6.3 Big-O 的正式含义

若存在正常数 C 和 n₀,使得对所有 n ≥ n₀ 都有:

T ( n ) ≤ C ⋅ f ( n ) T(n) \leq C \cdot f(n) T(n)≤C⋅f(n)

则说 T(n) = O(f(n))。因此 Big-O 是渐近上界的记号;日常分析中人们通常会给出较紧的上界来说明增长量级。

常见例子:

复杂度 典型操作 n 翻倍后的直观变化
O(1) 固定次数操作 基本不因 n 增大
O(log n) 平衡树查找 增长很慢
O(n) 扫描全部任务 工作量约翻倍
O(n²) 两层依赖 n 的循环 工作量约四倍

6.4 为什么刚才的扫描是 O(n)?

最坏情况下要看完全部 n 个任务:

T ( n ) = a n + b = O ( n ) T(n) = an + b = O(n) T(n)=an+b=O(n)

这就是 O(n) 选择算法。问题至此终于明确:如果调度频繁发生,扫描开销可能成为系统负担。

6.5 那么 O(1) 到底是什么意思?

O(1) 不是"只执行一步",也不是"执行时间一定是一纳秒"。

它表示:相对于所选输入规模 n,工作量可以由一个与 n 无关的常数限制住。

例如:

cpp 复制代码
for (int i = 0; i < 140; ++i)
{
    // 检查固定的第 i 个槽位
}

即使这个循环进行了 140 次,只要优先级数量始终固定为 140,而且 n 指的是任务数,它依然是:

O ( 140 ) = O ( 1 ) O(140) = O(1) O(140)=O(1)

对比:

cpp 复制代码
for (int i = 0; i < n; ++i)
{
    // 每个任务检查一次
}

这是 O(n)。循环是否存在不是判断依据,循环次数是否随着 n 增长才是关键。

6.6 为什么 O(1) 也可能不够快?

固定检查 140 个槽位和固定检查 5 个机器字,都可以是 O(1);但它们的实际耗时可能差很多。

所以:

  • Big-O 衡量增长趋势;
  • 具体的常数、缓存行为、机器指令也会影响实际速度;
  • O(1) 不代表在所有输入规模下一定比 O(n) 快。

到这里,已经有了足够的铺垫,才能看懂课件中的 Linux O(1) 调度器究竟"巧"在哪里。


七、Linux 2.6 历史 O(1) 调度器:怎样避免扫描 n 个任务?

7.1 第一步:不按"进程"组织,而按"优先级"组织

早期 Linux 2.6 的 O(1) 调度器使用固定数量的优先级槽位:

text 复制代码
优先级 0~99    :实时优先级
优先级 100~139:普通任务优先级

因此,可以建立 queue[140]:

text 复制代码
queue[0]   ─→ 任务 → 任务
queue[1]   ─→ 空
...
queue[119] ─→ 任务
queue[120] ─→ 任务 → 任务 → 任务
...
queue[139] ─→ 空

每个 queue[i] 是某一优先级的任务链表,不是"只能存一个进程"。

现在有多少个任务已经不重要:寻找最高优先级的非空队列,最多检查 140 个固定槽位。

7.2 第二步:先实现一个简单版本

cpp 复制代码
// 教学伪代码,不是可直接编译的 Linux 内核实现
for (int priority = 0; priority < 140; ++priority)
{
    if (!queue[priority].empty())
        return queue[priority].front();
}

在该历史实现的优先级编码中,数值越小表示优先级越高。

因为检查槽位上限固定为 140,故:

T ( n ) ≤ 140 c + b = O ( 1 ) T(n) \leq 140c+b = O(1) T(n)≤140c+b=O(1)

这里的 n 仍是可运行任务数。

关键改进发生在哪里? 从原先"最多遍历 n 个任务",变成"最多检查固定 140 个优先级槽位"。

7.3 第三步:为什么已经是 O(1),还需要 bitmap?

固定检查 140 次虽然是常数时间,但内核调度很频繁,140 次分支与内存访问仍可能不划算。

于是新增位图(bitmap):用一个 bit 标记某个优先级队列是否为空。

text 复制代码
优先级下标   0 1 2 3 4 5 6 7 ...
是否非空     0 0 0 1 0 0 1 0 ...
                   ↑
             最先遇到的非空队列

如果 queue[3] 非空,位图第 3 位就为 1;如果 queue[6] 非空,第 6 位就为 1。

这样选择过程可以变成:

text 复制代码
查找位图中最低编号的置位 bit
                ↓
          得到优先级 p
                ↓
          访问 queue[p]
                ↓
          取队列中的任务

7.4 课件中的 bitmap5 是怎么来的?

以每个机器字 32 位为例:

⌈ 140 / 32 ⌉ = 5 \lceil 140 / 32 \rceil=5 ⌈140/32⌉=5

5 个 32 位机器字提供 160 个 bit,足够表示 140 个队列的"空/非空"状态。

注意: 这是为解释 32 位平台所采用的直观表示。不同架构和内核版本中的宏定义、机器字大小与实现细节可能不同,不应把 bitmap[5] 当成永恒的源代码格式。

7.5 位图的优化到底优化了什么?

queue[140] 的线性槽位扫描已经是 O(1);位图并没有把它从 O(n) "再次降到 O(1)"。它做的是在相同渐近复杂度下,利用位操作和查找置位指令降低常数开销。

这也是本节应建立的最重要的算法观念:

先通过组织方式改变增长量级,再通过更合适的数据表示减少常数开销。


八、为什么还要设计 active 和 expired 两套队列?

8.1 只有优先级还不够,还要解决"本轮运行多久"

如果优先级高的普通任务持续运行,其他普通任务可能长期得不到 CPU。历史 O(1) 调度器通过时间片、动态优先级和交互性启发式等机制处理这类问题。

为方便理解,这里先看一个的普通任务简化模型:

text 复制代码
任务仍有本轮时间片
       ↓
    active 数组
       ↓
   获得 CPU 执行
       ↓
   本轮时间片耗尽
       ↓
重新计算/安排时间片
       ↓
    expired 数组

实际旧内核中实时任务和某些交互任务的处理还有特殊规则,因此这张图不应理解成对所有调度类的无条件描述。

8.2 每个 CPU 的 runqueue 保存什么?

抽象结构:

cpp 复制代码
// 教学结构示意,省略真实内核大量字段
struct prio_array {
    unsigned int nr_active;
    Bitmap bitmap;
    Queue queue[140];
};

struct runqueue {
    prio_array arrays[2];
    prio_array* active;
    prio_array* expired;
};

可以画成:

text 复制代码
                       CPU0
                        │
                     runqueue
                        │
               ┌────────┴────────┐
               ▼                 ▼
             active            expired
               │                 │
           bitmap              bitmap
           queue[140]          queue[140]
               │                 │
           可运行任务          本轮到期任务

课件用"一 CPU 一 runqueue"说明这一结构,多个 CPU 还需要额外考虑负载均衡。

8.3 active 逐渐空了以后,怎么办?

当 active 中本轮可调度的普通任务逐渐减少,expired 中累积了等待下一轮的任务:

text 复制代码
最开始:
active  → Array A(较多任务)
expired → Array B(较少任务)

运行一段时间后:
active  → Array A(已经空)
expired → Array B(准备下一轮)

如果逐个搬迁任务,将引入与任务数量有关的额外工作。

8.4 关键设计:不搬任务,只交换指针

cpp 复制代码
// 教学伪代码
prio_array* tmp = active;
active = expired;
expired = tmp;

交换前:

text 复制代码
active  ───→ Array A
expired ───→ Array B

交换后:

text 复制代码
active  ───→ Array B
expired ───→ Array A

不论里面有 10 个还是 10 万个任务,交换两个指针需要的步骤都不随任务数增长:

T ( n ) = O ( 1 ) T(n)=O(1) T(n)=O(1)

这里的本质是间接层 + 指针交换 :通过改变"谁叫 active",避免逐个移动数据结构中的所有任务。

8.5 把一次调度完整串起来

下面是历史调度器的教学化主流程:

text 复制代码
① 当前进程 P1 正在 CPU 上执行
                  │
② 发生阻塞、抢占或其他调度事件
                  │
③ 内核需要做一次调度决策
                  │
④ 查看 runqueue 的 active
                  │
         active 是否为空?
            /           \
          是             否
          │              │
⑤ 交换 active/expired   直接继续
          └──────┬───────┘
                 │
⑥ 通过 bitmap 找到最高优先级非空队列
                 │
⑦ 从对应 queue[p] 选择适当任务 P2
                 │
⑧ 若 P2 不同于 P1,则切换执行上下文
                 │
⑨ CPU 开始/继续执行 P2

注意:active/expired 切换是队列轮次机制,不是每次上下文切换都发生;"挑选下一任务"与"保存恢复寄存器"同样是两个不同环节。

8.6 O(1) 指的是整个调度过程没有开销吗?

不是。O(1) 在这里主要描述历史调度器关键队列管理和选择下一个任务的复杂度相对于可运行任务数不会线性增长;它并不表示上下文切换没有成本,也不表示实际调度延迟永远完全相同。缓存、多核负载均衡、策略与硬件差异仍会影响实际时间。


九、把历史算法放回现代 Linux:为什么仍值得学?

9.1 这是哪一代调度器?

active / expired + bitmap + 140 个队列 属于早期 Linux 2.6 的经典 O(1) Scheduler。

2007 年合入 Linux 2.6.23 的 CFS(Completely Fair Scheduler) 替代了当时普通任务的旧调度策略。CFS 经典设计中使用虚拟运行时间(vruntime)和按运行时间组织的树结构。Linux 从 6.6 开始逐步引入 EEVDF 相关机制。

所以本文并不是在声称今天 Linux 的普通任务仍使用旧双数组调度器;我们学习它,是为了理解数据结构怎样改变算法代价。

9.2 从旧调度器学到的三个思想

  1. 先描述,再组织 :task_struct 描述任务,runqueue 组织任务;
  2. 按查找需求选择结构:固定优先级数组和 bitmap 比遍历所有任务更适合此类选择;
  3. 避免不必要的数据迁移:用指针交换代替逐个搬运。

十、动手观察:Linux 中怎样查看调度与切换?

10.1 查看一个进程的状态、优先级和所在 CPU

bash 复制代码
ps -o pid,ppid,stat,pri,ni,psr,time,comm -p $$

其中:

  • STAT:进程状态;
  • PRI / NI:调度优先级相关数值与 nice 值;
  • PSR:最近运行过的逻辑处理器编号;
  • TIME:累计 CPU 时间。

PSR 不是"永久绑定的 CPU"。

10.2 查看系统整体上下文切换计数

bash 复制代码
vmstat 1

关注 cs 列,它表示系统层面每秒上下文切换的数量。它不是"当前终端那个进程的切换数"。

10.3 查看某个进程的自愿/非自愿切换统计

bash 复制代码
grep ctxt /proc/$$/status

可能看到:

text 复制代码
voluntary_ctxt_switches:        ...
nonvoluntary_ctxt_switches:     ...

这两个计数的具体变化取决于系统负载和程序行为,不应要求所有机器输出完全相同。

10.4 用 C 程序观察自愿切换的可能变化

c 复制代码
#include <stdio.h>
#include <unistd.h>
#include <sys/resource.h>

static void show_switches(const char* tag)
{
    struct rusage usage;

    if (getrusage(RUSAGE_SELF, &usage) == 0) {
        printf("%s: voluntary=%ld, involuntary=%ld\n",
               tag,
               usage.ru_nvcsw,
               usage.ru_nivcsw);
    }
}

int main(void)
{
    show_switches("before");

    for (int i = 0; i < 3; ++i)
        sleep(1); /* 主动等待,通常使任务离开 CPU */

    show_switches("after");
    return 0;
}

编译:

bash 复制代码
gcc -std=gnu11 -Wall -Wextra context_demo.c -o context_demo
./context_demo

这并不是用用户态程序复现内核调度器,而是观察操作系统提供的上下文切换统计,辅助建立对"阻塞和切换"的直觉。


十一、从进程"怎样运行"转到"带着什么环境运行":环境变量

11.1 一个进程启动时不仅有代码

进程还会收到启动相关的信息,例如:

text 复制代码
命令行参数 argv
环境变量 environment

环境变量可以理解成由父进程或启动环境传给程序的一组字符串配置:

text 复制代码
PATH=/usr/local/bin:/usr/bin:/bin
HOME=/home/user
SHELL=/bin/bash

11.2 PATH:为什么 ls 不需要写路径,自己的程序需要 ./?

在 Shell 中输入:

bash 复制代码
ls

Shell 会根据 PATH 查找可执行程序(某些命令也可能是 Shell 内建命令)。

但当前工作目录通常不在 PATH 中,因此自己编译的程序一般需要:

bash 复制代码
./myprogram

查看:

bash 复制代码
echo "$PATH"

临时添加一个目录:

bash 复制代码
export PATH="$PATH:/home/user/bin"

PATH 主要控制可执行文件搜索,不应简单认为编译器依赖 PATH 自动找到所有静态库和头文件;链接器与编译器还有独立的搜索规则及选项。

11.3 Shell 变量和导出的环境变量不同

只设置 Shell 变量:

bash 复制代码
MYENV=hello

运行的外部子程序通常不会自动接收到它。导出后:

bash 复制代码
export MYENV=hello

以后启动的子进程就可以继承这一环境变量。

11.4 用 getenv 读取环境变量

c 复制代码
#include <stdio.h>
#include <stdlib.h>

int main(void)
{
    const char* value = getenv("MYENV");
    printf("MYENV=%s\n", value ? value : "(not set)");
    return 0;
}

编译并测试:

bash 复制代码
gcc env_demo.c -o env_demo
MYENV=hello ./env_demo

setenv("MYENV", "new value", 1) 则是 POSIX 环境下设置当前进程 环境变量的常见接口,第三个参数 1 表示允许覆盖同名变量。


十二、最后一个问题:切换不同进程时,内存为什么不会混乱?

12.1 问题从上下文切换重新出现

CPU 从 P1 切到 P2 时,不仅代码的执行位置不同,二者看到的内存也通常不同:

text 复制代码
P1 的全局变量、堆、栈
P2 的全局变量、堆、栈

如果直接把程序中的地址当成全局唯一的物理内存地址,就很难做到隔离与灵活分配。

于是操作系统为用户进程提供了虚拟地址空间这一抽象。

12.2 用 fork 做一个经典验证

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int g_value = 0;

int main(void)
{
    pid_t id = fork();

    if (id < 0) {
        perror("fork");
        return 1;
    }

    if (id == 0) {
        g_value = 100;
        printf("child:  value=%d, address=%p\n",
               g_value, (void*)&g_value);
        fflush(stdout);
        _exit(0);
    }

    if (waitpid(id, NULL, 0) < 0) {
        perror("waitpid");
        return 1;
    }

    printf("parent: value=%d, address=%p\n",
           g_value, (void*)&g_value);
    return 0;
}

编译:

bash 复制代码
gcc -std=gnu11 -Wall -Wextra address_demo.c -o address_demo
./address_demo

常见现象:

text 复制代码
child:  value=100, address=0x55...
parent: value=0,   address=0x55...

父子进程中的同名变量可能具有相同的虚拟地址数值 ,但保存不同的值;这是因为它们处于不同的地址空间,子进程写入时会触发写时拷贝等内存管理机制。上面用 waitpid() 明确保证父进程在子进程完成后输出,而不是靠 sleep() 猜执行顺序。

12.3 为什么虚拟地址可以一样,而数据不一样?

text 复制代码
父进程虚拟地址 0x1000 ─→ 父进程页表 ─→ 物理页 A

子进程虚拟地址 0x1000 ─→ 子进程页表 ─→ 物理页 B

同一个虚拟地址在不同进程中可以映射到不同物理页。刚 fork() 时父子可能共享某些物理页;发生写时拷贝后才形成独立副本。

12.4 一个经典的进程地址空间示意

text 复制代码
                  高地址
        ┌─────────────────────┐
        │  参数 / 环境相关区域 │
        ├─────────────────────┤
        │  Stack(典型向下)  │
        │          ↓          │
        │                     │
        │   mmap / 共享库等   │
        │                     │
        │          ↑          │
        │  Heap(典型向上)   │
        ├─────────────────────┤
        │ BSS(零初始化数据) │
        ├─────────────────────┤
        │ Data(已初始化数据)│
        ├─────────────────────┤
        │ 只读数据 / 代码区域 │
        └─────────────────────┘
                  低地址

这是帮助理解的典型布局 ,并非每个 64 位 Linux 进程都严格按这张图连续排列;ASLR、PIE、mmap、线程栈等都会影响具体分布。

12.5 内核怎样描述这片空间?

课件介绍的核心结构是:

text 复制代码
task_struct
    │
    └── mm_struct
           │
           └── VMA:多个虚拟内存区域
  • task_struct:描述任务;
  • mm_struct:描述用户态内存管理信息;
  • VMA(Virtual Memory Area):描述具有一组属性的虚拟地址区间,例如权限、映射文件等。

课件中的旧内核实现展示了通过链表与红黑树组织 VMA 的方法;这是版本相关的内核实现细节,不能直接当成所有现代 Linux 版本都采用的固定结构。

12.6 为什么要有虚拟地址?

从课件的提问出发:如果每个进程都直接操作物理内存,会怎样?

  1. 隔离性差:一个程序可能意外破坏其他程序的数据;
  2. 地址安排困难:程序必须关心自己被装在物理内存的哪里;
  3. 管理不灵活:共享、保护、按需分配、交换等机制更难实现。

虚拟地址空间与页表帮助操作系统做到:

text 复制代码
程序视角:连续、独立的虚拟内存
         │
         ▼
      页表映射
         │
         ▼
内核视角:分散、可共享、可保护的物理页

因此进程调度模块不需要直接决定每个对象处于哪个物理内存位置,进程管理与内存管理通过明确的抽象与数据结构协作。


十三、回到全文:把调度、复杂度与地址空间连成一条线

13.1 一条完整的问题驱动链

text 复制代码
多个进程希望使用 CPU
          ↓
可运行任务需要排队
          ↓
运行中的任务可能阻塞或被抢占
          ↓
切换前后要保存、恢复执行现场
          ↓
调度器必须选择下一个任务
          ↓
逐一检查 n 个任务:O(n)
          ↓
历史 O(1) 调度器按固定优先级组织队列
          ↓
queue[140]:固定规模扫描 O(1)
          ↓
bitmap:在 O(1) 的基础上降低常数开销
          ↓
active/expired:用指针交换完成轮次转换 O(1)
          ↓
任务重新运行时,还需要环境与独立地址空间
          ↓
环境变量 + mm_struct + 页表映射
相关推荐
牧羊人.3331 小时前
动手学深度学习 06|学习率调整
人工智能·深度学习·学习
yi0111 小时前
DAY23: LeetCode 27 → 283:从移除元素到移动零,理解快慢指针的两种思路
人工智能·笔记·python·算法·leetcode·排序算法·双指针
xbzb1 小时前
一边装一边报错:CentOS 7 + LNMP 跑 ECShop 的常见故障与解法
linux·运维·centos
遇印记1 小时前
数据通信初步
运维·服务器·网络·windows·学习
学linux的QQ蛋1 小时前
图解 Linux 驱动编译进内核的完整工作流程
linux·运维·服务器
陈年老古董1 小时前
Hive 文件格式与查询学习笔记
hive·笔记·学习
承渊政道1 小时前
Linux网络学习【UDP Socket编程实战:网络命令与客户端访问Linux验证】
linux·网络·学习·ubuntu·编程实战·udp socket
小O的算法实验室1 小时前
IEEE TEVC,知识与学习驱动人工蜂群算法+家庭医疗护理路径与调度
学习
JWASX1 小时前
go 学习 - prometheus 指标协程池监控
学习·golang·prometheus