# 0. Linux核心概念详解

操作系统是软件世界的根基，其屏蔽了硬件世界，并为应用程序创造了一个执行环境；Linux是当前最流行的服务器端操作系统，本文将尝试着深入 Linux 内核代码，详细探讨一些核心概念的实现方式，以及其构建出来的抽象模型。

## 规划章节

笔者初步规划的章节如下：

1. 调试环境  - *Done*\
   本章介绍如何基于Docker构建内核的编译、运行、以及调试环境，并简要介绍如何基于GDB对内核代码进行调试。
2. 调度器 - *Done*\
   本章简要介绍了调度器的发展历史，深入探讨了不同调度类m并详细分析了CFS的实现，除此之外，还讨论了Linux如何实现组调度、负载追踪与负载均衡等话题。
3. 内存管理 - *Ongoing*\
   本章将详细介绍Linux的内存模型，以及内存管理的方方面面，包括内存分配与释放、Buddy System、SLAB/SLUB/SLOB、虚拟内存等方面。
4. 同步机制 - *TODO*
5. 中断与异常 - *TODO*
6. 容器基础：Cgroups与Namespace - *TODO*

各个章节的内容会滚动发布，将来也可能规划更多内容。

## 如何阅读 <a href="#orgca7081c" id="orgca7081c"></a>

Linux博大精深，整个内核代码非常庞大，一个人穷尽一生可能也无法精通内核的所有方面；虽然本文主要围绕着Linux的源代码展开，但文中也只是提取了重要代码的核心部分，旨在对系统核心的概念和模型进行讲解。

笔者强烈建议读者配合源代码阅读本文，本文使用的内核版本在第一章[调试环境](/s3e1)有详细介绍，如果在探讨某些话题时涉及到其它历史版本的话，对应的章节都会做特别的说明，读者可以自行下载。

文中笔者没有对任何操作系统的理论性知识做任何探讨，这方面的资料汗牛充栋，如果读者想要了解可以自行找一些经典书籍进行阅读，例如[Operating System Concepts](https://www.os-book.com/OS10/) 与 [Operating System: Three Easy Pieces](https://pages.cs.wisc.edu/~remzi/OSTEP/) 都是很好的入门资料，笔者强烈推荐后者。

## 参考资料

在整理本文的过程中，笔者参考了大量的其它资料来辅助阅读内核源码。可以说没有这些资料的帮助，笔者本人可能完全无法从代码层面上理解内核的实现原。零散的参考资料在对应的章节都有记录，这里对主要的资料来源做一个整理，以便读者参考：

* <https://www.kernel.org/doc/html/latest/index.html>\
  Linux官网资料永远应该是第一个查阅的地方。
* <https://lwn.net/>\
  该站点的很多文章包含了特定主题的详细讲解，质量都非常高。
* <http://www.wowotech.net/>\
  每篇文章都很硬核，而且是中文哦！

除了以上的在线内容，笔者还着重参考了如下书籍：

* 《Understanding the Linux Kernel》
* 《Professional Linux kernel architecture》
* 《Linux Kernel Development》
* 《Understanding the Linux Virtual Memory Manager》

但需要注意的一点是这些书籍所基于的代码版本都是2.6的，读者可以着重参考书中对高层架构的讨论，具体的实现细节需要以最新的代码为准。


# 1. 调试环境

使用Docker构建Linux Kernel的运行及调试环境

## 1.1 构建环境 <a href="#org00b72fb" id="org00b72fb"></a>

如果没有一个便捷的方式运行与调试代码，那么想要阅读 Linux Kernel 的源码将是十分困难的，本文意在分享如何通过 Docker, 并基于 busybox, qemu-kvm 以及 gdb 等工具构建一个编译、运行、调试 Linux Kernel 的容器环境。

首先，我们需要一个能够编译 Linux Kernel 的 Docker 镜像。新建目录 `$HOME/linux/docker`:

在该目录下创建文件 `build-kernel.sh` 并写入如下内容：

```
#!/bin/bash

cd /workspace/linux-5.12.14
make O=../obj/linux/ -j$(nproc)
```

该文件用来编译内核，在后续”编译Kernel“一节会使用到。

在该目录下创建文件 `start-gdb.sh` 并写入如下内容：

```
#!/bin/bash

echo 'add-auto-load-safe-path /workspace/linux-5.12.14/scripts/gdb/vmlinux-gdb.py' > /root/.gdbinit # 让 gdb 能够顺利加载内核的调试脚本，如果在下一节编译 Linux Kernel 时下载的是另一版本的 Linux Kernel 代码，请修改这里的版本号
cd /workspace/obj/linux/
gdb vmlinux -ex "target remote :1234" # 启动 gdb 远程调试内核
```

该文件用于调试 Linux Kernel 时进入 GDB 调试器，在最后一节“调试Linux Kernel”一节中会使用到。

另外再创建文件 Dockerfile 并写入如下内容：

```
FROM debian:10.8-slim

RUN apt-get update
RUN apt install -y apt-transport-https ca-certificates \
    && echo 'deb https://mirrors.tuna.tsinghua.edu.cn/debian/ buster main contrib non-free \n\
    deb https://mirrors.tuna.tsinghua.edu.cn/debian/ buster-updates main contrib non-free \n\
    deb https://mirrors.tuna.tsinghua.edu.cn/debian/ buster-backports main contrib non-free \n\
    deb https://mirrors.tuna.tsinghua.edu.cn/debian-security buster/updates main contrib non-free\n'\
    > /etc/apt/sources.list \
    && apt update && apt-get install -y \
    bc \
    bison \
    build-essential \
    cpio \
    flex \
    libelf-dev \
    libncurses-dev \
    libssl-dev \
    vim-tiny \
    qemu-kvm \
    gdb
ADD ./start-gdb.sh /usr/local/bin
ADD ./build-kernel.sh /usr/local/bin
RUN chmod a+x /usr/local/bin/*.sh
WORKDIR /workspace

```

通过如下命令构建镜像：

```
docker build -t linux-builder .
```

为了简便，我们将运行与调试需要的工具 qemu-kvm 与 gdb 也安装到了该镜像中，这样本文涉及到的所有操作都可以使用一个镜像完成。

## 1.2 编译 Linux Kernel <a href="#org0cc53df" id="org0cc53df"></a>

### 1.2.1 下载代码 <a href="#org82ea43a" id="org82ea43a"></a>

下载最新稳定版的内核代码：

```
cd $HOME/linux/
wget https://cdn.kernel.org/pub/linux/kernel/v5.x/linux-5.12.14.tar.xz
tar -xvJf linux-5.12.14.tar.xz
```

或者访问 [https://kernel.org](https://kernel.org/), 通过如下链接下载代码，并将代码解压至目录 `$HOME/linux/`

### 1.2.2 编译Kernel <a href="#orgc7f0fb9" id="orgc7f0fb9"></a>

创建编译结果的输出目录：

```
mkdir -p $HOME/linux/obj
```

进入目录 `$HOME/linux/` 并运行如下命令，进入容器编译内核：

```
docker run -it --name linux-builder -v $HOME/linux:/workspace linux-builder
```

在容器内进入解压后内核源代码目录，并配置 Kernel 的编译选项：

```
cd /workspace/linux-5.12.14
make O=../obj/linux menuconfig
```

该命令会打开一个基于命令行的配置页面，如下图所示：&#x20;

![](/files/-Me-6oHfhTkKK8uZZ4vU)

我们需要将调试信息编译至内核文件中，导航至 `Kernel hacking ---> Compile-time checks and compiler options` 并选中如下选项：&#x20;

![](/files/-Me-70_aaSll63LjAd-d)

保存并退出。此时的配置信息写入到了文件 `/workspace/obj/linux/.config`, 也可以直接修改该文件来完成配置。例如此处我们勾选的编译选项对应的配置项为：

> &#x20;CONFIG\_DEBUG\_INFO=y
>
> CONFIG\_GDB\_SCRIPTS=y&#x20;

选中 `CONFIG_GDB_SCRIPTS` 是为了调试时能够使用内核代码中提供的 gdb 脚本，gdb 支持使用 python 来扩展命令，内核代码中提供的脚本在目录 `$HOME/linux/linux-5.12.14/scripts/gdb` 中。

完成配置之后，通过如下命令编译内核：

```
docker run -it --name linux-builder --rm -v $HOME/linux:/workspace linux-builder bash build-kernel.sh
```

第一次编译耗时较长，编译结果都在目录 `/workspace/obj/linux` 中。

将上述编译命令保存到文件 `build-kernel.sh` 中，并且为该文件添加可执行权限：

```
chmod a+x build-kernel.sh
```

以后每次修改了代码后，就可以运行该脚本来编译内核了。

## 1.3 使用 Busybox 构建 initramfs <a href="#orgaece56d" id="orgaece56d"></a>

### 1.3.1 编译busybox <a href="#orgfa13f80" id="orgfa13f80"></a>

回到宿主机，下载 busybox 到工作目录并解压:

```
cd $HOME/linux
wget https://busybox.net/downloads/busybox-1.33.1.tar.bz2
tar -vxjf busybox-1.33.1.tar.bz2
```

回到编译内核的容器 `linux-builder` 中，对 busybox 进行编译配置：

```
mkdir -p /workspace/obj/busybox # 创建 busybox 的编译输出目录
cd /workspace/busybox-1.33.1
make O=../obj/busybox menuconfig
```

最后一条命令会打开配置目录，选中 `Settings ---> Build static binary (no shared libs)`, 如下图所示：

![](/files/-Me-7f2FDXM_JT612SNW)

然后通过如下命令编译并安装 busybox:

```
cd /workspace/obj/busybox/
make -j$(nproc)
make install
```

### 1.3.2 构造 initramfs <a href="#org8bfb4b3" id="org8bfb4b3"></a>

此处我们使用 busybox 构建一个极简的 initramfs, 能引导 Linux 启动并进入一个 shell 环境就足够。在容器中回到目录 `/workspace` 执行如下命令：

```
mkdir -p /workspace/initramfs/busybox
cd !$
mkdir -p {bin,sbin,etc,proc,sys,usr/{bin,sbin}}
cp -av /workspace/obj/busybox/_install/* .
```

此时我们已经将 busybox 生成的可执行文件全部拷贝到了对应目录，但还缺少一个 init 程序，可以简单写一个 shell 脚本来充当 init, 将如下内容写入文件 `/workspace/initramfs/busybox/init` 中：

```
#!/bin/sh

mount -t proc none /proc
mount -t sysfs none /sys

echo -e "\nBoot took $(cut -d' ' -f1 /proc/uptime) seconds\n"

exec /bin/sh
```

为文件添加可执行权限：

```
chmod a+x /workspace/initramfs/busybox/init
```

通过如下命令将所有内容打包：

```
cd /workspace/initramfs/busybox
find . -print0 \
    | cpio --null -ov --format=newc \
    | gzip -9 > /workspace/obj/initramfs-busybox.cpio.gz
```

文件 `/workspace/obj/initramfs-busybox.cpio.gz` 便是最终的 initramfs, 该文件会在启动内核时作为参数传递给 qemu.

## 1.4 运行 Linux Kernel <a href="#org34ce8d0" id="org34ce8d0"></a>

重新启动一个容器，在该容器中启动编译好的内核：

```
docker run -it --name linux-runner -v $HOME/linux:/workspace linux-builder
```

&#x20;进入容器后运行如下命令启动内核：

```
qemu-system-x86_64 -kernel /workspace/obj/linux/arch/x86/boot/bzImage -initrd /workspace/obj/initramfs-busybox.cpio.gz -nographic -append "console=ttyS0"
```

成功启动并进入 shell:

![](/files/-Me-8RG0CxunxktTI85z)

&#x20;我们将运行内核的命令整理成一个脚本保存在文件 `start-kernel.sh` 中：

```
docker run -it --name linux-builder --rm -v $HOME/linux:/workspace linux-builder qemu-system-x86_64 -kernel /workspace/obj/linux/arch/x86/boot/bzImage -initrd /workspace/obj/initramfs-busybox.cpio.gz -nographic -append "console=ttyS0"
```

&#x20;并且为该文件添加可执行权限：

```
chmod a+x start-kernel.sh
```

&#x20;然后就可以运行该脚本来启动内核了。

## 1.5 调试 Linux Kernel <a href="#orgd23553d" id="orgd23553d"></a>

### 1.5.1 GDB

使用上一节的方式启动内核，但需要为 `qemu-system-x86_64` 添加参数 `-s -S`:

> &#x20;-s shorthand for -gdb tcp::1234&#x20;
>
> -S freeze CPU at startup (use ’c’ to start execution)

同时还需要将参数 `-append "console=ttyS0"` 修改为 `-append nokaslr`, 否则 qemu 在启动过程不会在断点处停止。我们创建一个启动脚本 `start-kernel-debugger.sh`, 包含如下内容：

```
docker run -it --name linux-debugger --rm -v $HOME/linux:/workspace linux-builder qemu-system-x86_64 -s -S -kernel /workspace/obj/linux/arch/x86/boot/bzImage -initrd /workspace/obj/initramfs-busybox.cpio.gz -nographic -append nokaslr
```

此时该容器不会有任何输出，因为参数 `-S` 让 qemu 暂时停止执行，需要通过 gdb 发送命令让其继续。接下来我们进入当前容器，并使用脚本 `/usr/local/bin/start-gdb.sh` 进入GDB：

```
docker exec -it linux-debugger bash start-gdb.sh
```

进入 gdb 命令行之后，连上 qemu 暴露的端口进行远程调试，然后就可以正常调试内核代码了：

![](/files/-Me-8r_rn86s1VSyN9oO)

由于去掉了 `-append "console=ttyS0"`, 所以此时 qemu 的窗口不会输出任何日志，我们可以通过内核代码中附带的命令 `lx-dmesg` 来查看：&#x20;

![](/files/-Me-8ziGVlow9YxQpqkw)

内核附带的命令都有前缀 `lx-`, 可以通过如下命令查看所有的命令：&#x20;

![](/files/-Me-93tXf0CbMTC6IKvI)

此时，就可以通过GDB来调试内核的所有代码了！

### 1.5.2 printk

GDB 的使用有一定的学习门槛，更便捷的方式是使用函数`printk` 打印日志来对代码进行追踪与调试，我们只需要在感兴趣的地方打印出日志，然后使用前面讨论过的方式重新编译内核并运行内核，就可以在启动日志中看到结果。

例如我们找到内核的入口函数，位于`init/main.c`文件中的函数`start_kernel()`, 在函数开头添加如下代码：

```
asmlinkage __visible void __init __no_sanitize_address start_kernel(void)
{
  printk("*******************start kernel*********************");
  char *command_line;
  char *after_dashes;
  
  // 省略后续所有代码
  }
```

然后使用脚本`build-kernel.sh`编译内核，这次的编译会快很多，编译完成之后执行脚本`start-kernel.sh`, 便可以在启动日志的最前面看到我们添加的内容：

{% hint style="info" %}
脚本`build-kernel.sh`与`start-kernel.sh`请参见前文对应章节。
{% endhint %}

![](/files/-Me-MI1EwOE6XNJgJxJL)

可以看到第一行便是我们添加的日志。 `printk` 的详细用法可以参考 <https://www.kernel.org/doc/html/latest/core-api/printk-basics.html>


# 2.1 任务

进程是现代操作系统中最重要的概念之一，一个进程表示程序的一个执行实例。进程本身也是一个动态的概念，代表着程序一次从头到尾的执行过程，因此进程有自己的生命周期，可能处于不同的状态，这方面的资料已经非常丰富，就不在这里赘述了，感兴趣的同学[上网搜](https://www.google.com/search?q=process+state+diagram)就可以了。

然而在Linux中，进程并不是最小的执行单元，系统中最小的执行单元是线程（Thread）。但在内核层面并没有对进程与线程做显示的区分，二者统一都叫着任务（Task），区别体现在对资源的共享程度上：进程本质上是对资源的一种抽象与隔离，包括内存地址空间、文件、信号量等，Linux在通过系统调用 `clone` 创建一个新的 task 时，如果所有的资源都不共享，那么对用户而言就是创建了一个新的进程，如果除了函数调用栈（Stack）不共享、其他所有资源都共享，则创建的便是一个线程。

Linux 使用 `task_struct` 来表示一个任务，其定义如下：

```
/* file: include/linux/sched.h */
struct task_struct {
    /* 与优先级相关的字段 */
    int prio;
    int static_prio;
    int normal_prio;

    unsigned int rt_priority;

    /* 调度策略 */
    const struct sched_class *sched_class;
    /* 调度实体，调度器的调度对象，该字段用于 CFS */
    struct sched_entity se;
    /* 该字段用于 RT 调度器 */
    struct sched_rt_entity rt;
#ifdef CONFIG_CGROUP_SCHED
    struct task_group *sched_task_group;
#endif
    /* 该字段用于 DL 调度器 */
    struct sched_dl_entity dl;
}
```

`task_struct` 是一个非常大的结构体，包含了进程运行时的所有信息，这里我们只罗列出了几个与调度逻辑相关的字段，这些字段的作用在本章的后续章节中会做进一步分析。

从调度的角度来看，不同类型进程的调度需求是不一样的：

* 交互式进程（Interactive Process） 进程运行过程中需要不断通过 I/O 与用户交互，由于用户行为的速度与CPU的执行的速度有巨大落差，所以在 OS 看来这类进程大多数时间都在等待I/O事件。

  为了提升用户体验，该类进程最好能够有较好的响应时间，即一旦用户完成了操作，我们希望进程能够以最快地速度完成响应。所以对于调度器而言，如果这类进程是就绪的，那么最好立刻执行他们。也就是说，这类进程应该具备较高的优先级。
* 批处理进程（Batch Process） 这类进程几乎没有交互需求，只要他们在后台默默运行就好。用户对他们的期望是有足够大的吞吐量，因此每次被调度到时，最好都能稳稳当当运行一段时间，以便能够更好的利用CPU缓存以提升效率。
* 实时进程（Real-time Process） 这类进程对响应时间要求极高，因此一旦进程处于就绪状态，就需要立刻扔到 CPU 上执行。

在接下来的章节中，我们将详细探讨Linux对进程的调度原理，弄清楚内核如何区分不同类型的进程，并着重分析CFS调度期的实现细节。


# 2.2 核心概念

在讨论调度的具体实现之前，我们先来看一下内核提取出来的几个重要的抽象概念，以及他们之间的关系。


# 2.2.1 核心概念 - 调度实体

简要介绍调度器内部的调度单元，即Sched Entity

想要研究调度器的工作原理，我们首先需要了解调度器的工作对象。

在相当长的一段时间内，Linux 都将 `task_struct` 作为调度器的工作对象。但这样做存在一个问题，特别是在CFS引入之后，对时间公平性的讨论让这个问题更加明显。

为了解决调度时时间分配的公平性问题，Linux 在2.16版本中引入了CFS(Complete Fair Scheduler). 早期的CFS实现只在进程层面上实现了时间的公平分配，例如，如果一个系统有100个进程，CFS会保证每个进程都获得1%的CPU时间，但实际上系统中的这100个进程可能隶属于两个用户A与B, 其中用户A拥有10个进程，用户B拥有90个进程，这种实现造成的结果是用户A只获得了10%的CPU时间，而用户B获得了90%的时间。如果用户B了解CFS的调度原理，那么他可以肆无忌惮地fork出更多的进程以攫取更多的CPU时间。可见CFS在进程层面上的公平，却导致了系统在用户层面上的不公平，甚至是漏洞。对于这种情况，一种更合理的策略是系统首先保证每个用户获得相同的时间，然后再对隶属于同一个用户的所有进程公平地分配该用户的时间。

将该概念进一步抽象，我们就得到了进程组的概念。进程组的引入实际上是增加了一个调度层级，调度器首先完成进程组的时间分配，再处理组内进程之间的时间分配，前文提到的用户分组只是进程组的一个特例。

为了简化调度器模型，内核单独抽象出了一个概念叫“调度实体”，用来封装调度对象， `struct task_struct` 与调度实体相关的字段包括：

```
/* file: include/linux/sched.h */
struct task_struct {
    /* 调度实体，调度器的调度对象，该字段用于 CFS */
    struct sched_entity se;
    /* 该字段用于 RT 调度器 */
    struct sched_rt_entity rt;
#ifdef CONFIG_CGROUP_SCHED
    struct task_group *sched_task_group;
#endif
    /* 该字段用于 DL 调度器 */
    struct sched_dl_entity dl;
}
```

三个表示调度实体的字段 `se`, `rt`, `dl` 分别用于不同的调度策略。下一节将介绍调度策略的概念，而调度实体的详细信息与用法会在讲解调度策略的实现章节详细介绍。


# 2.2.2 核心概念 - 调度类

简要介绍Sched Class

系统中可能运行着各种类型的进程，用户对不同种类进程的期望值是不一样的，例如对于实时进程，我们希望进程具备超高的响应速度，进程一旦就绪就立马被调度到；而对于批处理进程，用户更多关注的是其吞吐量，只要他能够在后台默默运行完成就OK, 因此对于调度器而言，不同的进程类型意味着不同的调度逻辑。

Linux 在 2.6 版本中引入了“调度类（Sched Class）”的概念，意在将调度逻辑模块化，内核通过 `struct sched_class` 抽象出了调度类的通用行为：

```
/* file: kernel/sched/sched.h */
struct sched_class {
#ifdef CONFIG_UCLAMP_TASK
    int uclamp_enabled;
#endif

    void (*enqueue_task)(struct rq *rq, struct task_struct *p, int flags);
    void (*dequeue_task)(struct rq *rq, struct task_struct *p, int flags);
    /* 任务主动让出 CPU,  但是其状态依然是 runnable */
    void (*yield_task)(struct rq *rq);
    bool (*yield_to_task)(struct rq *rq, struct task_struct *p);

    /* 检查 p 是否会抢占 rq 中当前正在运行的 task. 通常情况下是在 p 进入 runnable
     * 状态时，检查 p 是否会抢占当前正在运行的 task */
    void (*check_preempt_curr)(struct rq *rq, struct task_struct *p, int flags);

    /* 从 rq 中选择下一个任务来运行 */
    struct task_struct *(*pick_next_task)(struct rq *rq);

    void (*put_prev_task)(struct rq *rq, struct task_struct *p);
    void (*set_next_task)(struct rq *rq, struct task_struct *p, bool first);

#ifdef CONFIG_SMP
    /* 检查选中的 cpu 在其 sched_domain
       中是否负载是平衡的，如果不平衡则需要对任务进行迁移。
       * 并不是所有的 scheduling class 都会实现该方法
       * */
    int (*balance)(struct rq *rq, struct task_struct *prev, struct rq_flags *rf);

    /* 当系统调用 fork, exec 创建一个新的 task 时，在 SMP 系统中需要选择一个合理的
     * runqueue 来将该 task 入队。Scheduler 此时需要考虑负载均衡问题 */
    int (*select_task_rq)(struct task_struct *p, int task_cpu, int flags);

    /* 将任务迁移到目标 CPU 上 */
    void (*migrate_task_rq)(struct task_struct *p, int new_cpu);

    /* 当任务被唤醒（wake up）之后调用 */
    void (*task_woken)(struct rq *this_rq, struct task_struct *task);

    /* 修改任务的 CPU 偏好，即其可以被调度到哪些 CPU 上运行 */
    void (*set_cpus_allowed)(struct task_struct *p, const struct cpumask *newmask,
                             u32 flags);

    void (*rq_online)(struct rq *rq);
    void (*rq_offline)(struct rq *rq);

    struct rq *(*find_lock_rq)(struct task_struct *p, struct rq *rq);
#endif

    void (*task_tick)(struct rq *rq, struct task_struct *p, int queued);
    void (*task_fork)(struct task_struct *p);
    void (*task_dead)(struct task_struct *p);

    /*
     * The switched_from() call is allowed to drop rq->lock, therefore we
     * cannot assume the switched_from/switched_to pair is serliazed by
     * rq->lock. They are however serialized by p->pi_lock.
     */
    void (*switched_from)(struct rq *this_rq, struct task_struct *task);
    void (*switched_to)(struct rq *this_rq, struct task_struct *task);
    void (*prio_changed)(struct rq *this_rq, struct task_struct *task,
                         int oldprio);

    unsigned int (*get_rr_interval)(struct rq *rq, struct task_struct *task);

    void (*update_curr)(struct rq *rq);

#define TASK_SET_GROUP 0
#define TASK_MOVE_GROUP 1

#ifdef CONFIG_FAIR_GROUP_SCHED
    void (*task_change_group)(struct task_struct *p, int type);
#endif
};
```

结构体的字段几乎都是函数申明，这对于有 OO 编程经验的同学而言是很熟悉的： `struct sched_class` 实际上定义了一个接口（Interface），而各个调度类来负责具体的实现。

在讨论调度类的具体实现之前，我们先来看一下调度器是如何通过各个调度类来选择下一个任务的。该逻辑定义在主方法 `struct task_struct * pick_next_task()` 中，与调度类相关的代码如下：

```
static inline struct task_struct *
pick_next_task(struct rq *rq, struct task_struct *prev, struct rq_flags *rf) {
    const struct sched_class *class;
    struct task_struct *p;

    for_each_class(class) {
        p = class->pick_next_task(rq);
        if (p)
            return p;
    }

    /* The idle class should always have a runnable task: */
    BUG();
}
```

调度器按照顺序对具体的调度类进行遍历，依次调用每个调度类的 `pick_next_task` 方法从指定 rq 中寻找下一个可执行任务，如果返回非空，则将该任务返回。

这里引出了两个问题：

1. 遍历调度类的顺序为何？
2. 内核有哪些调度类实现？

顺序遍历实际上意味着调度类之间存在优先级关系，Linux 总共实现了5种调度类，按照优先级有高到底排序依次为：

1. `stop_sched_class` \
   Stop 是特殊的调度类，内核使用该调度类来停止 CPU. 该调度类用来强行停止CPU 上的其他任务，由于该调度类的优先级最高，因此一旦生效就将抢占任何当前正在运行的任务，并且在运行过程中自己不会被抢占。

   该调度类只有在SMP架构的系统中存在，内核使用该调度类来完成负载均衡与CPU热插拔等工作。
2. `dl_sched_class` \
   有些任务必须在指定时间窗口内完成。例如视频的编码与解码，CPU 必须以特定频率完成对应的数据处理；这类任务是优先级最高的用户任务，CPU 应该首先满足。

   Deadline 调度类用来调度这类任务，dl 便是单词 Deadline 的缩写，因此该调度类的优先级仅仅低于 Stop 调度类。
3. `rt_sched_class` \
   在本节开头我们提到，实时任务（Real-time Task）对响应时间要求更高，例如编辑器软件，它可能由于等待用户输入长期处于睡眠之中，但一旦用户有输入动作，我们就期望编辑器能够立马响应，而不是等系统完成其它任务之后才开始反应，这一点对用户体验十分重要。

   RT 调度类用来调度这类任务，该调度类的优先级低于 DL.
4. `fair_sched_class` \
   Fair 调度类用来调度绝大多数用户任务，CFS 实现的就是这种调度类，其核心逻辑是根据任务的优先级公平地分配 CPU 时间。我们会在后续章节中详细讨论 CFS 的实现细节。
5. `idle_sched_class` \
   与 Stop 类似，Idle 调度类也是仅供内核使用的特殊调度类，其优先级最低，只有在没有任何用户任务时才会用到。内核会为每个 CPU 绑定一个内核线程（kthread）来完成该任务，该线程会在队列无事可做的情况下启动该任务，并将 CPU 的功耗降到最低。

对调度类的遍历通过宏 `for_each_class` 完成，相关代码定义如下：

```
/* Defined in include/asm-generic/vmlinux.lds.h */
extern struct sched_class __begin_sched_classes[];
extern struct sched_class __end_sched_classes[];

#define sched_class_highest (__end_sched_classes - 1)
#define sched_class_lowest (__begin_sched_classes - 1)

#define for_class_range(class, _from, _to)          \
    for (class = (_from); class != (_to); class --)

#define for_each_class(class)                                       \
    for_class_range(class, sched_class_highest, sched_class_lowest)
```

代码从变量 `__end_sched_classes` 开始，反向遍历至变量 `__begin_sched_classes`, 这两个变量定义在文件 `include/asm-generic/vmlinux.lds.h` 中，顺序如下：

```
#define SCHED_DATA                              \
    STRUCT_ALIGN();                             \
    __begin_sched_classes = .;                  \
    *(__idle_sched_class)                       \
        *(__fair_sched_class)                   \
        *(__rt_sched_class)                     \
        *(__dl_sched_class)                     \
        *(__stop_sched_class)                   \
        __end_sched_classes = .;
```

该顺序便是各个调度类的优先级顺序。

接下来我们简单看一下各个调度类的实现及初始化。

每种调度类的实现都在单独的文件中：

* idle: `kernel/sched/idle.c`
* fair: `kernel/sched/fair.c`
* rt: `kernel/sched/rt.c`
* dl: `kernel/sched/deadline.c`
* stop: `kernel/sched/stop_task.c`

每个调度类都实现了 `sched_class` 中申明的所有方法，以 idle 调度器为例， `pick_next_task` 方法的实现如下：

```
struct task_struct *pick_next_task_idle(struct rq *rq)
{
    struct task_struct *next = rq->idle;

    set_next_task_idle(rq, next, true);

    return next;
}
```

调度类的每个方法实现，都以该调度类的名称为后缀，内核在初始化时将其与 `struct sched_class` 中定义的方法关联起来。调度类的初始化工作通过宏 `DEFINE_SCHED_CLASS` 实现：

```
#define DEFINE_SCHED_CLASS(name)                                        \
    const struct sched_class name##_sched_class __aligned(__alignof__(  \
                                                              struct sched_class)) __section("__" #name "_sched_class")
```

可以发现各调度类变量的命名格式： `__<class name>_sched_class`, 这与前文中看到的调度类变量名一致。以 idle 为例，初始化代码如下：

```
DEFINE_SCHED_CLASS(idle) = {

/* no enqueue/yield_task for idle tasks */

/* dequeue is not valid, we print a debug message there: */
.dequeue_task       = dequeue_task_idle,

.check_preempt_curr = check_preempt_curr_idle,

.pick_next_task     = pick_next_task_idle,
.put_prev_task      = put_prev_task_idle,
.set_next_task          = set_next_task_idle,

#ifdef CONFIG_SMP
.balance        = balance_idle,
.select_task_rq     = select_task_rq_idle,
.set_cpus_allowed   = set_cpus_allowed_common,
#endif

.task_tick      = task_tick_idle,

.prio_changed       = prio_changed_idle,
.switched_to        = switched_to_idle,
.update_curr        = update_curr_idle,
};
```

其中可以清楚地看到各个方法的绑定逻辑。


# 2.2.3 核心概念 - 调度策略

简要介绍Sched Policy

上一节我们探讨了调度类的概念与代码实现，总体来说，调度类实际上是根据优先级对任务的一个粗略划分，调度器总是从高优先级的调度类开始寻找可执行的任务。但对于同一个调度类中的多个任务，如果他们的优先级相同的话，调度器如何决定该选哪一个呢？

这个问题通过调度策略（Sched Policy）来解决，不同调度类的调度策略实现如下：

* Stop 调度类 \
  Stop 调度类中只有一个任务可供执行，不需要定义任何调度策略。
* DL （Deadline）调度类\
  DL 只实现了一种调度策略：`SCHED_DEADLINE`, 用来调度优先级最高的用户任务。
* RT （Real-Time）\
  RT 提供了两种调度策略：`SCHED_FIFO` 与 `SCHED_RR,`对于使用 `SCHED_FIFO` 的任务，其会一直运行到主动放弃CPU; 而对于 `SCHED_RR` 的任务，如果多个任务的优先级相同，则大家会按照一定的时间配额来交替运行，即使一个任务一直处于可运行状态，在使用完自己的时间切片之后也会被抢占，然后被放入队列的尾巴等待下次机会。
* Fair CFS 实现了三种调度策略：
  1. `SCHED_NORMAL`: 被用于绝大多数用户进程
  2. `SCHED_BATCH`: 适用于没有用户交互行为的后台进程，用户对该类进程的响应时间要求不高，但对吞吐量要求较高，因此调度器会在完成所有 `SCHED_NORMAL` 的任务之后让该类任务不受打扰地跑上一段时间，这样能够最大限度地利用缓存。
  3. `SCHED_IDLE`: 这类调度策略被用于系统中优先级最低的任务，只有在没有任何其他任务可运行时，调度器才会将运行该类任务。
* Idle 同 Stop 一样，Idle 调度类也没有实现调度策略，注意不要将这类调度类与 CFS 中的 `SCHED_IDLE` 混淆。

调度策略的定义如下：

```
#define SCHED_NORMAL 0
#define SCHED_FIFO 1
#define SCHED_RR 2
#define SCHED_BATCH 3
/* SCHED_ISO: reserved but not implemented yet */
#define SCHED_IDLE 5
#define SCHED_DEADLINE 6
```

每个进程在创建时都会指定一个调度策略，从而自动归结到某个调度类下。

我们可以通过 `/proc/<pid>/sched` 中的内容来查看进程的调度策略，例如下面例子中，进程的调度策略为 `SCHED_NORMAL`:

```
grep 'policy' /proc/11742/sched
policy                                       :                    0
```


# 2.2.4 核心概念 - 运行队列

简要介绍runqueue

调度器的核心工作就是把可运行的任务扔到CPU上去运行。如何有效、合理地组织可运行的任务，是调度器需要考虑的重要问题。

Linux 内核使用一个运行队列（runqueue）来存放可运行的任务，该数据结构的定义如下：

```
/* file: kernel/sched/sched.h */
struct rq {
    unsigned int nr_running;

    u64 nr_switches;

    /*
     * runqueue 采用的模块化的实现方式，各个不同的 Scheduling Class 都有自己的
     runqueue 实现。不同的 runqueue 的数据结构保存在如下属性之中
    */
    struct cfs_rq cfs; /* CFS runqueue 的实现 */
    struct rt_rq rt;   /* RT runqueue 的实现 */
    struct dl_rq dl;   /* Deadline runqueue 的实现 */

    struct task_struct __rcu *curr; /* 当前 runqueue 中正在运行的进程 */
    struct task_struct *idle;       /* Idle 调度类的任务 */
    struct task_struct *stop;       /* Stop 调度类的任务 */
    struct mm_struct *prev_mm;

    atomic_t nr_iowait;

#ifdef CONFIG_SMP
    struct root_domain *rd;
    struct sched_domain __rcu *sd;

    unsigned long cpu_capacity;
    unsigned long cpu_capacity_orig;
#endif /* CONFIG_SMP */

#ifdef CONFIG_SCHEDSTATS
    /* latency stats */
    struct sched_info rq_sched_info;
    unsigned long long rq_cpu_time;
    /* could above be rq->cfs_rq.exec_clock + rq->rt_rq.rt_runtime ? */

    /* sys_sched_yield() stats */
    unsigned int yld_count;

    /* schedule() stats */
    unsigned int sched_count;
    unsigned int sched_goidle;
#endif
};
```

此处我们删除了该结构体的大多数字段，仅仅保留了一些主要的字段一探究竟，这些字段涉及如下几个方面：

1. 具体调度类的运行队列。不同的调度类选择下一个任务的逻辑是不同的，因此不同的调度类会有不同的调度队列实现方式，例如字段 `struct cfs_rq cfs` 就是 CFS 的调度队列
2. 当前正在运行的进程，以及 stop 与 idle 这种特殊任务
3. 如果是 SMP 架构，则 rq 中还会包含调度域的字段，调度器使用调度域中的信息来做负载均衡
4. 一些统计信息

rq 是系统可运行任务的容器，调度器的很多工作都是围绕着 rq 来进行的，调度类 `struct sched_class` 所申明的函数中，绝大多数函数都与 rq 相关。在系统中，每个 CPU 都有一个自己的 rq, 这样可以避免多个 CPU 访问同一个 rq 时产生的并发问题，提升调度器效率。

> 在 Linux 的早期实现中，系统只维护一个全局的 rq，为了解决同步问题，调度器通过自旋锁来保护对队列的同步访问。2.4 版本的 O(n) 调度器采用的就是这种实现方式。
>
> 对于有些变量，内核会为每个 CPU 单独维护一份拷贝，这种变量叫着 per-cpu 变量（per-cpu variable），除了 rq, 还有很多其他的场景需要这种变量。内核定义了各种宏和工具函数来对这类变量进行处理，所有的定义都在文件 `include/linux/percpu-defs.h` 中。例如申明 per-cpu 变量的宏定义如下：
>
> ```
> #define DEFINE_PER_CPU(type, name)              \
>     DEFINE_PER_CPU_SECTION(type, name, "")
> ```


# 2.2.5 核心概念 - 优先级

简要介绍任务的优先级，即Priority

在前面讨论调度类与调度策略时，我们反复地提到优先级，因为不管是调度类还是调度策略，本质上都是在对任务按照优先级排序，这一节中我们将详细探讨一下优先级在内核中的实现。

> 优先级并不是隐藏在内核中的概念，对于Linux用户而言，更加熟悉的是进程的 `nice` 值。 `nice` 值的范围是 \[-20, 19], 数字越低优先级越高。可以理解为一个进程的 nice 数值越大就对其他进程越 nice, 因此越愿意让渡自己的CPU时间，最终自己得到的时间就越少。
>
> 用户可以通过 `top` 命令的 `NI` 这一列查看进程的 nice 值， 用户可以在启动程序时指定 nice 值，也可以对已经运行的程序通过 `renice` 命令进行修改。进程启动进程时，nice 的默认值是0。

优先级是调度器工作的重要基础，优先级如何使用取决于调度类的调度算法，我们先分类来看一下：

* Deadline 进程 DL调度类使用EDF(Earliest Deadline First) 算法来对多个Deadline进程进行调度，即谁的Deadline 在前面，就选择谁来执行，优先级不在调度逻辑的考虑之中。 `struct dl_rq` 队列使用一颗红黑树来组织进程，各个节点根据进程的 deadline 排序：

  ```
  struct dl_rq {
      /* runqueue is an rbtree, ordered by deadline */
      struct rb_root_cached root;

      /* 其他字段此处被删除 */
  }
  ```

  详细的调度算法可以参见：[Deadline Task Scheduling](https://www.kernel.org/doc/html/latest/scheduler/sched-deadline.html)

  因此所有Deadline类型的进程优先级都是 -1, 该值其实相当于只是一个标识符，用来表示当前进程是Deadline类型，在系统中优先级最高，仅此而已。
* 实时进程 RT 调度类的逻辑很简单：永远挑优先级最高的进程执行，如果优先级相同，则根据调度策略进行选择。

  RT 优先级的范围是 \[1, 99], 其中99的优先级最高。
* 普通进程 普通进程被 CFS 调度，该类进程的优先级就是进程的 nice 值。CFS 的调度逻辑会在后文中详细介绍。

可见，只有实时进程与普通进程的优先级会参与调度逻辑，Linux 也提供了系统调用（System Call）来修改这两类进程的优先级，其中 `sys_nice` 用来修改普通进程的 nice 值， `sys_sched_setscheduler` 用来设定与修改调度策略与 RT 优先级。

与优先级相关的字段都在 `task_struct` 中，整理如下：

```
/* file: include/linux/sched.h */
struct task_struct {
    int prio;
    int static_prio;
    int normal_prio;
    unsigned int rt_priority;
```

* rtpriority: 实时优先级 记录上文中的实时优先级，有效范围是 \[1, 99], 数字越大优先级越高，当数值是0时表示该进程是普通进程。
* staticprio: 静态优先级 当用户通过 `sys_nice` 与 `sys_sched_setscheduler` 两个系统调用修改优先级时，改变的就是该字段。

  前文提到 nice 值的范围是\[-20, 19], 而实时优先级的有效范围是\[1, 99], 二者的重叠部分如何处理呢？

  内核在处理 nice 时会加上120, 完成 nice 值与静态优先级之间的转换，内核定义了两个宏来完成二者之间的转换，相关的代码如下：

  ```
  #define MAX_NICE 19
  #define MIN_NICE -20
  #define NICE_WIDTH (MAX_NICE - MIN_NICE + 1)
  #define DEFAULT_PRIO (MAX_RT_PRIO + NICE_WIDTH / 2)

  /* nice 与静态优先级相互转换的宏 */
  #define NICE_TO_PRIO(nice) ((nice) + DEFAULT_PRIO)
  #define PRIO_TO_NICE(prio) ((prio)-DEFAULT_PRIO)
  ```

  新进程创建时该值从父进程继承，静态优先级在进程执行过程中是不会改变的，而当其值发生改变的时候，其他相关优先级也需要重新计算（例如动态优先级）。
* normalprio: 归一化优先级 如果我们使用了不同的方式来刻画同一个概念，那么势必会带来管理上的麻烦，所谓归一化，就是设计一种转换方式，将这些不同的方法统一到同一种方法上去，从而简化问题的模型。

  例如在前文中，我们提到 rtpriority 数字越大，优先级越高；nice 值则相反，而 deadline 进程始终要维持最高优先级。为了便于管理，内核设计了一种归一化算法，将所有的优先级统一到 \[-1, 139] 这个区间上，并且数字越小优先级越大，该优先级就叫着归一化优先级。具体实现如下：

  ```
  static inline int __normal_prio(struct task_struct *p) {
      return p->static_prio;
  }

  static inline int normal_prio(struct task_struct *p) {
      int prio;

      if (task_has_dl_policy(p))
          /* MAX_DL_PRIO为0, 因此Deadline的优先级永远为-1 */
          prio = MAX_DL_PRIO - 1;
      else if (task_has_rt_policy(p))
          /* MAX_RT_PRIO为100, 而rt_priority的范围是[1,99]且数字越大对应的优先级越高，下面的算法实现了优先级反转，高优先级将对应小的数字。
           */
          prio = MAX_RT_PRIO - 1 - p->rt_priority;
      else
          /* 对于普通进程，直接返回静态优先级static_prio */
          prio = __normal_prio(p);
      return prio;
  }
  ```
* prio: 动态优先级 调度器工作时实际使用的优先级，通过函数 `effective_prio()` 计算得到，具体实现如下：

  ```
  static int effective_prio(struct task_struct *p) {
      p->normal_prio = normal_prio(p);
      /* 如果 p.prio 的值小于 100(MAX_RT_PRIO的值), 则返回 1, 否则返回 0 */
      if (!rt_prio(p->prio))
          return p->normal_prio;
      return p->prio;
  }
  ```

在各类进程的调度算法中，CFS 对优先级的使用最为复杂，也最为精巧，后续我们将详细讨论。


# 2.3 演进历史

当前调度器的实现不是一蹴而就的，而是经历了漫长的演进过程。但调度器的内在核心始终围绕着如下两个方面：

1. 如何管理待调度的任务 如何将待调度的任务有效地组织起来，是调度器首先需要考虑的事情。组织任务的策略决定了调度器的核心数据结构，并会直接影响调度算法的实现。
2. 如何管理待分配的时间 调度器本质上是在管理CPU的运行时间，如何将时间分配给各个任务是调度器的本职工作。

在讨论当前调度器的实现细节之前，我们先简要地回顾一下Linux调度器的演进历史，看一下不同阶段阶段的调度器是如何处理上述两件事情的。


# 2.3.1 O（n）调度器 - 调度逻辑

[O(n) 调度器](https://en.wikipedia.org/wiki/O\(n\)_scheduler)是 Linux 在 2.4 版的内核中引入的，由于调度器算法的时间复杂度为 O(n), 该调度器的名字也由此而来；本节我们基于 [linux-2.4.18](https://cdn.kernel.org/pub/linux/kernel/v2.4/linux-2.4.18.tar.gz) 来了解一下该调度器的实现逻辑。

整个O(n)调度器的实现都在文件 `kernel/sched.c` 中，总共只有1300行左右的代码，整个调度逻辑的代码量远少于当前版本中的任何一个调度器（例如 dl 与 rt 调度器都有接近3000行代码），因此其实现思路非常简单。

我们先来看一下O(n)调度器如何管理任务。O(n)调度器采用一个全局的运行队列来管理任务，该队列的表头保存在全局变量 `runqueue_head` 中。所有的就绪任务都会被放入该队列中，每次调度时，系统会遍历整个队列然后挑出最适合的任务，即优先级最高的任务来执行，因此该调度算法的时间复杂度为O(n), 这也是该调度器被叫着O(n)调度器的原因。

函数 `schedule()` 是调度逻辑的入口，其中选取任务的逻辑如下：

```
asmlinkage void schedule(void) {
    next = idle_task(this_cpu);
    c = -1000;
    list_for_each(tmp, &runqueue_head) {
        p = list_entry(tmp, struct task_struct, run_list);
        if (can_schedule(p, this_cpu)) { /* 判断 p 是否可以在 this_cpu 上运行 */
            int weight = goodness(p, this_cpu, prev->active_mm);
            if (weight > c)
                c = weight, next = p;
        }
    }
}
```

调度器通过一次遍历选取了优先级最高的任务，如果队列为空，则系统将在该CPU上运行idle task。任务的优先级通过函数 `goodness()` 来判断。

注意在该版本中，调度器仅支持实时任务与普通任务，实时进程的调度策略包含 `SCHED_FIFO` 与 `SCHED_RR`, 普通进程的调度策略叫 `SCHED_NORMAL`, 相当于现在的 `SCHED_NORMAL`, 实时进程的优先级高于普通进程，需要优先保证其时间供给。

`goodness()` 在计算任务优先级时会综合考虑各种因素，以确保系统在时间分配上是公平的。 `struct task_struct` 中与优先级计算有关的字段包含如下几个：

```
/* file: include/linux/sched.h */

struct task_struct {
    /* 任务当前剩余的运行时间，以时钟嘀嗒（tick）为单位 */
    long counter;
    /* 普通任务的优先级，即用户设置的进程 nice 值 */
    long nice;
    /* 任务的调度策略 */
    unsigned long policy;
    /* 任务的内存地址空间，由同一个进程的不同线程共享 */
    struct mm_struct *mm;
    /* 实时任务的优先级 */
    unsigned long rt_priority;
}
```

我们将 `nice` 与 `rt_priority` 叫着静态优先级，因为他们是用户设置的，并且在任务运行过程中不会改变。函数 `goodness()` 的返回结果我们叫着动态优先级，动态优先级是基于静态优先级计算出来的，并且综合考虑了其它各种情况。其完整代码如下：

```
static inline int goodness(struct task_struct *p, int this_cpu,
                           struct mm_struct *this_mm) {
    int weight;

    weight = -1;
    if (p->policy & SCHED_YIELD)
        goto out;

    /* 普通任务，静态有限级为 nice */
    if (p->policy == SCHED_OTHER) {
        /* 根据任务剩余的时间对任务进行奖励 */
        weight = p->counter;

        /* 如果该任务已经没有剩余时间，则直接忽略 */
        if (!weight)
            goto out;

#ifdef CONFIG_SMP
        /* 根据 CPU 对任务权重进行奖励 */
        if (p->processor == this_cpu)
            weight += PROC_CHANGE_PENALTY;
#endif

        /* 根据内存空间对任务权重进行奖励 */
        if (p->mm == this_mm || !p->mm)
            weight += 1;
        weight += 20 - p->nice;
        goto out;
    }

    /* 实时任务，静态优先级为 rt_priority */
    weight = 1000 + p->rt_priority;
out:
    return weight;
}
```

对于实时任务，系统将其静态优先级（`rt_priority`）加上1000然后返回，这样能够确保所有实时任务的动态优先级都高于普通进程。而对于普通进程，系统除了考虑静态优先级（nice）之外，还会如下几个方面对优先级进行调整：

1. 根据任务的剩余时间进行调整 如果一个任务剩余的CPU时间很多，就说明该任务之前得到的运行机会很少，可能是任务在等待某个外部事件（例如IO事件），也可能是一直被高优先级的任务排挤。为了更加公平，系统在每次调度时会将任务的剩余时间作为权重的奖励，这样越到调度后期，剩余时间更多的任务就越有机会被调度。
2. 根据CPU进行调整 在 SMP 架构中，如果将任务 p 继续运行在其上次运行的 CPU 上的话，可能能够提升 CPU 缓存与 TLB 的命中率。
3. 根据任务的内存空间进行调整 代码中 `p->mm == this_mm` 用来判断任务p与当前任务的内存地址空间是否一样，如果等式成立，则说明两个任务是同一个进程中的两个线程；而 `!p->mm` 用来判断任务 p 是否是内核线程，所有的内核线程都共享一个内存空间。在这两种情况下切换任务的话运行效率会更高，因为进程切换时不用切换地址空间，所以调度器会为权重+1作为奖励。


# 2.3.2 O（n）调度器 - 时间分配

对普通任务而言，为了保证系统在时间分配上的公平性，O(n)调度器按照“轮”来计量调度周期，在每轮调度中，调度器根据各个任务的优先级为其分配时间，当所有任务的时间片都消耗完了之后，本轮度结束，系统开启下一轮调度。在 `goodness()` 中我们可以看到，如果一个普通任务已经没有了剩余时间，则会直接被忽略。

> 实时任务不受调度周期的限制，因为实时任务的高优先级最高，调度器要确保只要实时任务处于Ready状态，那么就立刻被调度到，本质上不存在预先分配时间的必须性。
>
> 调度器只是用运行时间来完成对 `SCHED_RR` 任务的轮询控制，函数 `schedule()` 在遍历就绪队列之前存在如下逻辑：
>
> ```
> asmlinkage void schedule(void) {
>     if (unlikely(prev->policy == SCHED_RR))
>         if (!prev->counter) {
>             prev->counter = NICE_TO_TICKS(prev->nice);
>             move_last_runqueue(prev);
>         }
>     ｝
> ```
>
> 如果当前 `SCHED_RR` 任务的运行时间已经耗尽，则重新通过 `NICE_TO_TICKS(prev->nice)` 重新为其分配时间，并将其移动到队列尾部，这样如果队列中有相同优先级的 `SCHED_RR` 任务的话，本次就会被选中，从而达到轮询的目的。

如果在遍历队列时没有找到合适的任务，那么说明所有任务的运行时间都已经耗尽，本次调度周期已经结束了，调度器需要为任务分配时间、开启新的一轮调度周期。处理该逻辑的代码如下：

```
/* file: kernel/sched.c */

asmlinkage void schedule(void) {
    /* 删除前面的代码 */

    /* 1. 遍历队列 */
repeat_schedule:
    next = idle_task(this_cpu);
    c = -1000;
    list_for_each(tmp, &runqueue_head) {
        p = list_entry(tmp, struct task_struct, run_list);
        if (can_schedule(p, this_cpu)) { /* 判断 p 是否可以在 this_cpu 上运行 */
            int weight = goodness(p, this_cpu, prev->active_mm);
            if (weight > c)
                c = weight, next = p;
        }
    }

    /* 2. 通过 c 判断是否有合适的任务被选中 */
    if (unlikely(!c)) {
        struct task_struct *p;

        spin_unlock_irq(&runqueue_lock);
        read_lock(&tasklist_lock);
        /* 遍历系统的所有任务，并为其分配运行时间 */
        for_each_task(p) p->counter = (p->counter >> 1) + NICE_TO_TICKS(p->nice);
        read_unlock(&tasklist_lock);
        spin_lock_irq(&runqueue_lock);
        /* 分配完运行时间后重新调度 */
        goto repeat_schedule;
    }
}

/* 删除后面的代码 */
```

在下一轮调度周期中，任务的运行时间为 `(p->counter >> 1) + NICE_TO_TICKS(p->nice);`, 这里涉及两个方面：

1. p->counter >> 1 \
   上面遍历时不是没有找到合适的任务吗，那这里为何还要关心 p->counter 呢？因为上面遍历的是就绪队列（`runqueue_head`），所以调度器只是没有找到合适的就绪任务，而这里为任务分配时间片时，针对的是系统所有的任务。 此时系统中处于睡眠状态的任务就可能还有剩余时间，因此我们需要对这些任务的运行时间进行累积，让他们醒过来之后能够有更多的机会运行。为了避免此类任务的运行时间无限累积下去，每次都只累计一半的剩余时间。
2. `NICE_TO_TICKS`(p->nice) \
   ticks 就代表着运行时间，宏 `NICE_TO_TICKS` 根据任务的静态优先级（nice）为其分配运行时间。O(n)调度器的时间分配与CPU 频率也有关，具体逻辑如下：

   ```
   /* file: kernel/sched.c */

   #if HZ < 200
   #define TICK_SCALE(x) ((x) >> 2)
   #elif HZ < 400
   #define TICK_SCALE(x) ((x) >> 1)
   #elif HZ < 800
   #define TICK_SCALE(x) (x)
   #elif HZ < 1600
   #define TICK_SCALE(x) ((x) << 1)
   #else
   #define TICK_SCALE(x) ((x) << 2)
   #endif

   #define NICE_TO_TICKS(nice) (TICK_SCALE(20 - (nice)) + 1)
   ```

   从直觉上来说，给任务分配时间应该只与优先级相关，与CPU的频率无关。系统做这种处理的目的是为了将运行时间根据CPU的频率做一个伸缩(scale), 否则如果一个低频CPU给每个任务分配的运行时间很长的话，就会导致一次调度周期很久都不会结束。


# 2.3.3 O（n）调度器 - 调度时机

聊完了调度的具体逻辑与时间分配之后，我们再来简单聊一下调度时机，即调度这件事情在什么情况下会发生。O(n) 的调度主要发生在如下场景中：

1. 进程主动发起调度，搜索内核代码会发现大量的地方调用了 `schedule()` 方法
2. timer 发现当前进程已经耗尽了自己的时间，触发调度。代码如下：

   ```
   /* file: kernel/timer.c */
   void update_process_times(int user_tick) {
       struct task_struct *p = current;
       int cpu = smp_processor_id(), system = user_tick ^ 1;

       update_one_process(p, user_tick, system, cpu);
       if (p->pid) {
           /* 运行时间耗尽，设置调度标记 */
           if (--p->counter <= 0) {
               p->counter = 0;
               p->need_resched = 1;
           }
           if (p->nice > 0)
               kstat.per_cpu_nice[cpu] += user_tick;
           else
               kstat.per_cpu_user[cpu] += user_tick;
           kstat.per_cpu_system[cpu] += system;
       } else if (local_bh_count(cpu) || local_irq_count(cpu) > 1)
           kstat.per_cpu_system[cpu] += system;
   }
   ```

   在定时器中不会直接触发调度，而是设置任务的调度位： `p->need_resched = 1;`, 系统在下一轮调度中才会做对应的处理。
3. 用户修改进程优先级的时候，这一点很好理解。
4. 系统通过 `fork()` 创建新任务的时候，新任务的产生会导致系统产生调度需求。

还有很多其它的情况会引发调度，这里不再一一列举了。


# 2.3.4 O（1）调度器 - 简介

O(n)调度器的实现思路非常简单，在它的年代也够用了，但随着多核架构的发展，其扩展性方面的问题越来越明显。其中最大的问题是系统中所有的CPU共享一个全局的运行队列，这个设计会导致如下问题：

1. 并发访问的问题，系统需要通过加锁来同步对队列的访问
2. 每次调度需要遍历整个队列，时间复杂度太高
3. 实时任务与普通任务共用一个队列，影响实时任务的响应速度
4. 任务在调度时，很容易在不同的CPU之间来回跳转，无法很好地利用CPU缓存
5. 在每个调度周期后期，容易造成有的CPU在空跑，例如此时还有剩余时间的就绪任务的总量小于CPU数量的话，就一定有CPU此时处于idle状态
6. 给任务分配调度时间时需要遍历系统的所有任务，效率过于低下，并且在该过程中其余的CPU实际上无事可做

总之，O(n) 调度器简单粗暴的实现很难适应SMP架构下的扩容问题，而随着系统任务数量的增加，其性能存在严重的缺陷，我们需要一种更精细化的策略来完成任务调度。

O(1)调度器就是对针对这种需求进行的优化，由Linux在2.6.0版本中引入，直到后来被CFS取代，本节我们基于[linux-2.6.11.1](https://cdn.kernel.org/pub/linux/kernel/v2.6/linux-2.6.11.1.tar.gz)的代码来简单探索一下该调度器的实现思路。


# 2.3.5 O（1）调度器 - 调度逻辑

在O(n)调度器中，“运行队列”实际上只是个逻辑概念，内核只是用一个列表将就绪任务串起来了而已，并没有单独定义一个数据结构。O(1)调度器的实现中专门定义了结构体 `struct runqueue` 来对各种信息进行封装：

```
/* file: kernel/sched.c */

struct runqueue {
    spinlock_t lock;

    unsigned long nr_running;
#ifdef CONFIG_SMP
    unsigned long cpu_load;
#endif
    unsigned long long nr_switches;

    unsigned long nr_uninterruptible;

    unsigned long expired_timestamp;
    unsigned long long timestamp_last_tick;
    task_t *curr, *idle;
    struct mm_struct *prev_mm;
    prio_array_t *active, *expired, arrays[2];
    int best_expired_prio;
    atomic_t nr_iowait;

#ifdef CONFIG_SMP
    /* SMP 下用于负载均衡的字段 */
    struct sched_domain *sd;

    /* For active balancing */
    int active_balance;
    int push_cpu;

    task_t *migration_thread;
    struct list_head migration_queue;
#endif

#ifdef CONFIG_SCHEDSTATS
    /* 删掉调度器的信息统计字段*/
#endif
};
```

为了解决全局运行队列额问题，O(1)调度器为每个CPU单独维护了一个运行队列（runqueue），这种变量叫着 `per cpu variable`, 我们在[核心概念](/linux-sched/concepts)一章中讨论运行队列时已经提及过了。队列的申明如下：

```
/* file: kernel/sched.c */

static DEFINE_PER_CPU(struct runqueue, runqueues);
```

这样CPU在调度时，会首先拿到与自己绑定的运行队列在进行各种操作，不会影响其他CPU的工作。

为了降低时间复杂度，调度器在runqueue中又为每个优先级单独维护了一个任务列表，这部分信息封装在数据结构 `struct prio_array` 中：

```
/* file: kernel/sched.c */
struct prio_array {
    unsigned int nr_active;
    unsigned long bitmap[BITMAP_SIZE];
    struct list_head queue[MAX_PRIO];
};
```

其中数组 `queue` 中的每个元素都是一个任务列表，位图 `bitmap` 用来标识列表是否为空。示意图如下：

![RT Task Runqueue](/files/2R7OLIHcRavVwCOP6Jt5)

图中展示的任务列表中，只有优先级为1与4的列表非空。有了 `prio_array` 的支持，调度器在寻找下一个任务时的时间复杂度就变为了O(1): 先找到bitmap中的第一个非0的位置，然后将其作为索引从queue中取出任务列表，再取出第一个任务即可。核心调度逻辑如下：

```
asmlinkage void __sched schedule(void) {
    task_t *prev, *next;
    runqueue_t *rq;
    prio_array_t *array;
    struct list_head *queue;
    unsigned long long now;
    unsigned long run_time;
    int cpu, idx;

    /* 拿到当前CPU的runqueue */
    rq = this_rq();

    /* 拿到队列中的任务列表，为prio_array_t类型，实际上就是上面讨论过的
     * prio_array */
    array = rq->active;

    /* 从prio_array中找到下一个进程，O(1)复杂度 */
    idx = sched_find_first_bit(array->bitmap); /* 找到目标优先级队列的索引 */
    /* 通过索引拿到对应的runqueue, 该队列即是当前优先级最高的任务队列 */
    queue = array->queue + idx;
    /* 从runqueue中取出第一个task, 该task即是下一个应该扔CPU上运行的task */
    next = list_entry(queue->next, task_t, run_list);
}
```

调度器从 `rq->active` 列表中挑选出下一个任务，在新的数据结构的支持下，总体的时间复杂度为 O(1), 这也是该调度器被叫着O(1)的原因。

当然，由于每个CPU现在都有自己的runqueue, 因此调度器还需要对各个CPU做负载均衡，这个主题我们会在后面 `sched_domain` 的章节单独讨论。


# 2.3.6 O（1）调度器 - 时间分配

每个调度周期结束后需要单独为每个任务分配时间，效率底下。为了解决这个问题，O(1)调度器为每个runqueue准备了两个任务列表，对应的字段为 `prio_array_t *active, *expired, arrays[2];` 其中 `active` 用来存放就绪任务，而 `expired` 用来存放时间片已经耗尽的任务。在调度时，调度器使用 `active` 来寻找下一个任务，这在前文中我们探讨调度逻辑时已经看到了，而当任务的运行时间被耗尽后，系统会立刻为其计算下一轮的运行时间并将其放入 `expired` 列表，这样当 `active` 为空时，调度器就不用暂停所有工作来分配时间，而是直接切换 `active` 与 `expired` 两个列表即可。该逻辑发生在调度函数 `schedule()` 中：

```
/* file: kernel/sched.c */
asmlinkage void __sched schedule(void) {
    array = rq->active;
    if (unlikely(!array->nr_active)) {
        schedstat_inc(rq, sched_switch);
        rq->active = rq->expired;
        rq->expired = array;
        array = rq->active;
        rq->expired_timestamp = 0;
        rq->best_expired_prio = MAX_PRIO;
    }
}
```

这段代码就在前面主调度逻辑之前。系统在时钟中断函数中会检查任务的运行时间，如果时间耗尽则会为其重新充值，并考虑是否放入 `expired` 列表中。这段逻辑在函数 `schedule_tick()` 中：

```
/* file: kernel/sched.c */
void scheduler_tick(void) {
    /* 拿到当前CPU, 当前CPU 的runqueue, 以及当前正在运行的任务 */
    int cpu = smp_processor_id();
    runqueue_t *rq = this_rq();
    task_t *p = current;

    /* 每个tick将任务的运行时间减1, 如果运行时间已经耗尽，则需要单独处理 */
    if (!--p->time_slice) {
        /* 从 active 列表中剔除，后面根据任务的特点看是放入 active 还是 expired */
        dequeue_task(p, rq->active);
        /* 标记该任务需要调度了 */
        set_tsk_need_resched(p);
        /* 重新计算任务的动态优先级 */
        p->prio = effective_prio(p);
        /* 分配任务下一周期的运行时间 */
        p->time_slice = task_timeslice(p);
        p->first_time_slice = 0;

        if (!rq->expired_timestamp)
            rq->expired_timestamp = jiffies;
        /* 识别任务是否是交互式任务 */
        if (!TASK_INTERACTIVE(p) || EXPIRED_STARVING(rq)) {
            /* 非交互式任务，或者是交互式任务，但是已经奖励过了，乖乖到expired列表中去*/
            enqueue_task(p, rq->expired);
            if (p->static_prio < rq->best_expired_prio)
                rq->best_expired_prio = p->static_prio;
        } else
            /* 交互式任务，奖励一下，继续呆在active中 */
            enqueue_task(p, rq->active);
    } else {
        /* 防止交互式任务过长时间独占CPU */
        if (TASK_INTERACTIVE(p) &&
            !((task_timeslice(p) - p->time_slice) % TIMESLICE_GRANULARITY(p)) &&
            (p->time_slice >= TIMESLICE_GRANULARITY(p)) &&
            (p->array == rq->active)) {

            requeue_task(p, rq->active);
            set_tsk_need_resched(p);
        }
    }
}
```

如果任务被扔到expired列表，那么它在这一轮周期内就没有机会再运行了，但对于交互式任务而言可能导致响应延迟，造成不好的用户体验，因此我们希望尽可能地不要将交互式任务从active中剔除，系统通过宏 `TASK_INTERACTIVE` 来判断任务是否是交互式任务，主要判断依据是任务的平均睡眠时间。但也要避免交互式任务一直呆在active里面导致expired中的任务被饿死，所以系统会使用宏 `EXPIRED_STARVING` 来判断expired列表中的任务是否要饿死了，如果是的话，那么依然会将任务扔进expired中，乖乖地等待下一轮调度。

计算任务时间片的函数是 `task_timeslice`, O(1)计算时间片时与CPU的频率无关，只根据任务的静态优先级计算出一个确定的时间片。由于O(1)在调度时已经奖励过交互式任务，因此这里的逻辑就简单了，这里不再深入讨论。


# 2.3.7 RSDL

设计调度器是个很复杂的工作，特别是要设计出一款通用调度器，其面临的挑战更大，因为不同的应用场景有不同的需求，交互式任务希望响应速度更快，批处理任务希望吞吐量更大，多核架构下，又需要在高效利用所有CPU的前提下，尽量保证高速缓存与TLB的命中率。尽管O(1)调度器针对O(n)的问题做了很多优化，在大多数情况下也工作得很好，但依然有用户抱怨在特定场景下存在各种问题。

识别与奖励交互式任务本质上是在追求时间分配的公平性，而仅仅通过进程行为来判断其是否是交互式任务实际上是没有理论依据的，尽管O(1)在算法上做了很多改进，包括考量进程在CPU 上的运行时间、在runqueue中的等待时间、睡眠时间以及进程睡眠时的状态等，但这些努力也只是能够提升“猜中”的概率而已，总有用户举出在特殊场景下交互式任务响应太慢的例子，反而导致代码变得非常复杂。同时不管是O(n)还是O(1), 其对交互式任务的奖励方式都会造成任务的调度延迟是不确定的，我们无法通过有效的手段得到某个任务的延迟响应时间。

后来，大家尝试着抛弃“评估任务的交互性”这种思路来寻找解决办法，RSDL(Rotating Staircase Deadline Scheduler)便是这样的一种方案。该方案由[Con Kolivas](https://en.wikipedia.org/wiki/Con_Kolivas)提出，我们在这里简单讨论一下其设计思路。

同O(1)一样，在每个调度周期内，RSDL会根据任务的优先级为其分配固定的CPU时间，并且为每一个优先级维护一个任务列表。但在任务运行过程中，任务不会始终呆在自己优先级所对应的任务列表中，而是随着时间的消耗顺着优先级的任务阶梯逐级下降，直到所有任务的运行时间耗尽，调度器再开启下一轮调度。下图展示了4个任务分别在4个优先级中的调度流程：

不同的优先级任务队列形成一个阶梯 - Staircase, 每当一个任务在当前优先级的运行时间耗尽后，就会发生一次轮转 - Rotating. RSDL的核心思想体现在对任务运行时间的管理上，其本质上是将任务的运行时间逐级下降地分配到了每个优先级上，这种设计简化了调度逻辑：调度器只需要从高到底地遍历优先级任务队列，然后运行每个任务即可。

当然，上图只体现了RSDL的核心思想，具体实现中还要考虑很多其他因素，例如如何避免某个优先级的任务列表中堆积了太多任务，导致更低优先级的任务等待时间过长。详细的设计可以参见作者的[这篇文章](https://lwn.net/Articles/224654/)

虽然RSDL最终没有并入Linux的内核中，但它在公平调度这方面的想法启发了后来的CFS, 因此对其加以了解也是有益的。更多资料可以参考：

* <https://lwn.net/Articles/224865/>
* <https://lwn.net/Articles/87729/>
* <https://lwn.net/Articles/87244/>


# 2.3.8 CFS

内核在2.6.23版中引入了CFS(Completely Fair Scheduler)以取代O(1)调度器，CFS吸取了RSDL中关于公平调度的思想，着力于改善调度器在交互性与公平性两方面的性能。CFS 的设计思路发生了很大的变化，完全切换了一个方向来思考“公平”这件事情。调度器抛弃了基于时间片来划分调度周期的做法，引入了vruntime(虚拟时间) 的概念来度量公平，并使用红黑树来管理任务；同时该版本也引入了\[\[调度类 - Sched Class]\[调度类]]的概念，从而将调度器的实现模块化，从此以后内核引入新调度器的难度大为降低。

CFS现在依然是内核的核心，所有普通类任务（SCHED\_NORMAL）的调度都是CFS来完成的。除此之外，基于调度类的思想内核还实现了其他调度器，例如Deadline调度器与RT调度器，在后续章节中我们会逐一详细介绍。


# 2.4 DL调度器

本章我们将详细介绍DL调度器的实现，DL任务是系统中优先级最高的任务类型，我们首先会分析用于DL调度器的核心算法，然后再从代码层面上探索其实现方式。

除了内核代码，本章内容还着重参考了如下资料:

* <http://disi.unitn.it/\\~abeni/rtslike.pdf>
* <http://ceur-ws.org/Vol-1291/ewili14\\_5.pdf>
* <https://www.kernel.org/doc/html/latest/scheduler/sched-deadline.html>
* <https://lwn.net/Articles/743740/>
* <https://lwn.net/Articles/743946/>


# 2.4.1 DL调度器 - 调度算法

Deadline调度器的核心思想是EDF(Earliest Deadline First)与CBS(Constant Bandwidth Server), 我们将在本节详细讨论。

Deadline任务有三个重要的属性：runtime, period, deadline, 调度器需要确保任务在每个period时间窗口内得到runtime这么多的CPU时间，并且必须在deadline这个时间点之前得到。我们可以将一个Deadline任务看着是一个连续的子任务流，而period实质上代表着子任务的激活模式。例如一个视频处理引擎每秒需要处理60帧数据，每个数据帧的处理时间最长为10ms，并且新的数据帧每16ms就会到达，因此该进程的period是16ms, runtime是10ms, 由于这里没有显示的指定每帧数据处理的deadline, 因此默认与period相同。

这三个属性在用户启动Deadline任务时就需要指定，例如：

```
chrt -d --sched-runtime 5000000 --sched-deadline 10000000 \
    --sched-period 16666666 0 video_processing_tool
```

在该命令中，三个参数的单位都是纳秒，其中参数 `0` 用来表示进程优先级，由于Deadline 任务的优先级是固定的-1, 所以该值在这里没有实际意义，仅用来充当占位符的作用。

接下来我们讨论一下系统如何对Deadline任务进行调度。首先来看最简单的情况：系统只有一个任务，那么调度器在每次任务激活的时候直接调用即可，如下图所示：

![](/files/6GQH1NLxdkOvP2ZrsJIe)

但如果有多个任务的话，调度器就需要合理地安排每个任务的执行时间，确保每个任务在每个period内都按时完成。Linux采用的调度算法叫EDF(Earliest Deadline First), 即在任意时刻，调度器都挑选Deadline最近的任务执行。下图展现了三个任务的调度示例：

![](/files/uVncdrpzPam9AQ8tSeNr)

图中三个任务的runtime与period都不一样，此时将产生一个问题：当有多个任务同时存在时，如何确保所有任务在理论上确实是能够按时完成的呢？系统通过CPU带宽（Bandwidth）来进行判断，一个任务需要的CPU带宽的计算方式如下：B = runtime/period, 如果系统中所有Deadline任务的带宽之和不超过1, 那么理论上调度器就可以按时完成所有的任务。直白一点说，就是如果在时间窗口T内，所有任务对时间的需求总量小于T, 那么这些任务就可以按时完成，这是很自然的逻辑。例如上图中，各个任务的参数为：

| Task | Runtime | Period |
| ---- | ------- | ------ |
| T1   | 1       | 4      |
| T2   | 2       | 6      |
| T3   | 3       | 8      |

三个任务消耗的CPU带宽为：U = 1/4 + 2/6 + 3/8 = 23/24, 因此系统在理论上可以完成调度。

为了保证所有的Deadline任务能够按时完成，系统有一个准入机制，前面提到系统在启动Deadline任务时必须指定runtime, deadline与period, 系统会根据这些参数对任务的带宽需求进行校验，如果已经没有足够的剩余带宽来负担新任务，那么该任务就会被拒绝。

到此为止，调度器能够正确地工作还基于一个隐式的假设：即在每个period内，每个任务的运行时间都不会超过自己的runtime时长。如果某个任务的执行时间超出了自己的runtime的话，就可能会对其他任务造成影响，甚至造成雪崩效应，导致所有任务的执行时间都超过deadline, 无法按时完成。 这是调度器必须解决的问题，runtime的设置取决于用户对每个子任务耗时的预估，最大值可以设置为子任务的WCET(Worst-case Execution Time)，但这样仍然存在两个问题：

1. 造成带宽浪费，降低调度器的吞吐量 \
   因为只有在少数情况下，子任务的处理时间才会达到WCET, 其他绝大多数情况下都不需要这么多时间，所以如果所有任务都按照WCET设置runtime的话，事实上是给每个任务预留了过多的CPU带宽，这会导致系统的准入机制拒绝更多的任务进入系统。
2. WCET也不可靠 \
   即使将每个任务的runtime都设置为WCET, 系统也有可能在某些极端情况下导致某个任务在某次处理中超出了runtime. runtime不够实际上并不是这个问题的核心，问题的核心是我们需要某种机制来对任务的行为进行隔离：如果一个任务的runtime设置过小，那么它应该只影响自己，其它“守信”的任务不应该受到影响。

Linux采用CBS来解决这个问题，CBS 实际上是一种带宽预留机制，其原理如下：

1. 每个任务通过 `scheduling deadline` 与 `remaining runtime` 来描述当前状态，前者表示当前子任务的deadline, 后者表示任务在 `scheduling deadline` 之前还剩多少runtime可以用
2. 当任务被唤醒时，系统会对该任务做如下两方面的校验：

   1. `scheduling deadline < now`, 即查看该任务是否已经错过了当前deadline
   2. `remaining runtime / (scheduling deadline - now) > runtime / period`, 校验带宽是否溢出，即在当前period中，CPU是否已经无法满足该任务的带宽要求

   如果二者都不成立，则说明该任务状态良好，否则则说明该任务已经无法在当前period内按时完成了，需要将其往后顺移，调度器会对其状态做如下修改： `scheduling deadline = now + deadline` `remaining runtime = runtime`
3. 任务的运行时间会从 `remaining runtime` 中及时减去
4. 当 `remaining runtime <= 0` 时，说明该任务已经没法在deadline前交差了，此时该任务的状态会被设置为throttled, 调度器将暂停对其进行调度。调度器会在任务的 `scheduling deadline` 这个时间点重新为其设置状态，这个动作叫着replenishment, 该时间点被称为 replenishment time
5. 当时间运行到throttled任务的replenishment time时，调度器会通过如下方式更新任务的状态： `scheduling deadline = scheduling deadline + period` `remaining runtime = remaining runtime + runtime`

上述的规则细看起来比较绕，其实核心思想就是为给每个任务预留固定的runtime，如果有任务在自己的runtime内无法交差，那么就在下一个period中再为其分配时间。这样只要任务“老老实实”地遵守自己的参数设定去运行，调度器就能够确保为其分配足够的CPU时间，而如果有任务“不老实”，那么它也只会影响它自己。这样就完成了对任务的隔离，保证了系统的稳定性。


# 2.4.2 DL调度器 -核心代码

Deadline 调度器的实现在文件 `kernel/sched/deadline.c` 中，任务的相关参数、CBS需要使用到的各种状态信息都封装在 `sched_dl_entity` 中：

```
/* file: include/linux/sched.h */
struct sched_dl_entity {
    /*
      任务的参数，即前文提到的 runtime, deadline 与 period. 这几个参数通过系统调用
      sched_setattr 进行修改，在运行过程中保持不变。
    */
    u64 dl_runtime;  /* Maximum runtime for each instance   */
    u64 dl_deadline; /* Relative deadline of each instance  */
    u64 dl_period;   /* Separation of two instances (period) */

    u64 dl_bw;      /* dl_runtime / dl_period       */
    u64 dl_density; /* dl_runtime / dl_deadline     */

    /*
      即前文提到的 scheduling deadline 与 remaining runtime, CBS
      用来控制CPU的带宽分配。
    */
    s64 runtime;  /* Remaining runtime for this instance    */
    u64 deadline; /* Absolute deadline for this instance    */

    /* 标识该任务是否是 throttled 状态，如果是的话调度器需要在下一个 replenishment
     * time 时调整 runtime 与 deadline 属性。replenishment 操作通过定时器 dl_timer
     * 完成 */
    unsigned int dl_throttled : 1;
    /* 标识该任务是否在消耗完自己的 runtime 之前主动让出 CPU */
    unsigned int dl_yielded : 1;

    /*
      高精度定时器，例如用来为throttled任务做replenishment操作。
    */
    struct hrtimer dl_timer;
};
```

此处仅仅保留了前文中讨论到的几个重要字段。EDF算法要求调度器每次找到deadline最近的任务，为了提升效率，dl调度器使用红黑树（Red-black Tree）来组织任务，以任务的deadline作为key值。相关字段定义在运行队列 `dl_rq` 中：

```
/* kernel/sched/sched.h */

struct dl_rq {
    /* runqueue is an rbtree, ordered by deadline */
    struct rb_root_cached root;

    /* 运行队列中的任务总数 */
    unsigned long dl_nr_running;

    /* 此处删除剩余的其他字段 */
}
```

因此 dl 调度类挑选下一个任务的逻辑是很直观的：

```
/* file: kernel/sched/deadline.c */

/* 从dl_rq中取出最左边的节点，该节点即为deadline最近的任务节点，并从该节点中抽取出对应的 sched_dl_entity */
static struct sched_dl_entity *pick_next_dl_entity(struct rq *rq,
                                                   struct dl_rq *dl_rq)
{
    struct rb_node *left = rb_first_cached(&dl_rq->root);

    if (!left)
        return NULL;

    return rb_entry(left, struct sched_dl_entity, rb_node);
}

/* 实现调度类中的 pick_next_task 方法 */
static struct task_struct *pick_next_task_dl(struct rq *rq)
{
    struct sched_dl_entity *dl_se;
    struct dl_rq *dl_rq = &rq->dl;
    struct task_struct *p;

    if (!sched_dl_runnable(rq))
        return NULL;

    dl_se = pick_next_dl_entity(rq, dl_rq);
    BUG_ON(!dl_se);
    p = dl_task_of(dl_se);
    set_next_task_dl(rq, p, true);
    return p;
}
```

CBS机制的所有逻辑也都在deadline.c中，例如函数 `update_dl_entity` 用来对唤醒的任务进行校验，即完成上一节CBS算法中的第二步，其主体逻辑如下：

```
/* file: kernel/sched/deadline.c */
static void update_dl_entity(struct sched_dl_entity *dl_se)
{
    struct dl_rq *dl_rq = dl_rq_of_se(dl_se);
    struct rq *rq = rq_of_dl_rq(dl_rq);

    if (dl_time_before(dl_se->deadline, rq_clock(rq)) || /* 校验是否已经错过 deadline */
        dl_entity_overflow(dl_se, rq_clock(rq))) { /* dl_entity_overflow 用来判断带宽是否会溢出 */

        /* 更新 scheduling deadline 与 remaining runtime */
        dl_se->deadline = rq_clock(rq) + pi_of(dl_se)->dl_deadline;
        dl_se->runtime = pi_of(dl_se)->dl_runtime;
    }
}
```

函数 `sched_dl_overflow` 对整个调度器的带宽是否溢出进行校验，例如创建Deadline任务或者修改其调度参数（runtime, deadline, period）时，系统的准入机制就需要调用该函数进行逻辑判断：

```
/* file: kernel/sched/core.c */

static int __sched_setscheduler(struct task_struct *p,
                                const struct sched_attr *attr, bool user,
                                bool pi)
{
    /* 省略前面部分代码 */

    /* 对于 dl 任务，如果此次修改会导致调度器的带宽溢出，则报错 */
    if ((dl_policy(policy) || dl_task(p)) &&
        sched_dl_overflow(p, policy, attr)) {
        retval = -EBUSY;
        goto unlock;
    }


    /* 省略后面部分代码 */
}
```

该文件中还包括很多其他逻辑，例如针对 SMP 架构的各种处理，此处不再一一讲解。


# 2.5 RT调度器

本节我们简要介绍一下实时任务的调度思想，RT调度器的所有实现都在文件 `kernel/sched/rt.c` 中，想要详细研究的同学可以自行学习。

RT任务的调度与优先级直接相关，在前文介绍优先级时，我们提到调度器使用的是动态优先级，在动态优先级中，RT任务的优先级区间是\[1, 99], 数字越大优先级越小。为了提升效率，调度器为每个优先级都单独维护了一个任务列表，对应的数据结构是：

```
/* file: kernel/sched/sched.h */

struct rt_prio_array {
    DECLARE_BITMAP(bitmap,
                   MAX_RT_PRIO + 1); /* include 1 bit for delimiter */
    struct list_head queue[MAX_RT_PRIO]; /* MAX_RT_PRIO的值为100 */
};
```

该结构体与 O(1) 调度器中的实现思路是一样的，都是为了降低调度器在查找下一个任务的时间复杂度。

`rt_prio_array` 包含在RT任务的运行队列 `rt_rq` 中：

```
/* file: kernel/sched/sched.h */
struct rt_rq {
    struct rt_prio_array active;
    unsigned int rt_nr_running;
    unsigned int rr_nr_running;
    int rt_queued;

    int rt_throttled;
    u64 rt_time;
    u64 rt_runtime;

#ifdef CONFIG_RT_GROUP_SCHED
    unsigned long rt_nr_boosted;

    struct rq *rq;
    struct task_group *tg;
#endif
};
```

此处仅保留了队列的部分字段，active 就是各优先级的任务列表，初始化函数 `init_rt_rq` 会对各个字段做初始化。这样调度器的调度算法就很简单了：先根据 bitmap 选出第一个为空的队列，然后从该队列中取出第一个任务。逻辑如下：

```
/* file: kernel/sched/rt.c */

static struct sched_rt_entity *pick_next_rt_entity(struct rq *rq,
                                                   struct rt_rq *rt_rq)
{
    struct rt_prio_array *array = &rt_rq->active;
    struct sched_rt_entity *next = NULL;
    struct list_head *queue;
    int idx;

    /* 从位图中找出第一个为空列表的索引位置 */
    idx = sched_find_first_bit(array->bitmap);
    BUG_ON(idx >= MAX_RT_PRIO);

    /* 通过索引位置拿到任务队列，并从中取出下一个元素，即下一个RT任务的调度实体 */
    queue = array->queue + idx;
    next = list_entry(queue->next, struct sched_rt_entity, run_list);

    return next;
}

static struct task_struct *_pick_next_task_rt(struct rq *rq)
{
    struct sched_rt_entity *rt_se;
    struct rt_rq *rt_rq  = &rq->rt;

    /* 处理组调度，组调度的逻辑后续会有专门的章节进行分析 */
    do {
        rt_se = pick_next_rt_entity(rq, rt_rq);
        BUG_ON(!rt_se);
        rt_rq = group_rt_rq(rt_se);
    } while (rt_rq);

    return rt_task_of(rt_se);
}

static struct task_struct *pick_next_task_rt(struct rq *rq)
{
    struct task_struct *p;

    if (!sched_rt_runnable(rq))
        return NULL;

    p = _pick_next_task_rt(rq);
    set_next_task_rt(rq, p, true);
    return p;
}
```

函数 `pick_next_task_rt` 就是挑选下一个RT任务的逻辑。

这里只提供了对RT调度逻辑最简略的描述，完整的实现中还包含对CPU带宽的控制、对SMP架构下的处理（例如负载均衡）等，我们在这里不再一一探讨。


# 2.6 CFS

CFS是最重要的调度器，其逻辑最复杂，设计也很精妙，本章我们将对其做详细探讨。


# 2.6.1 公平性

CFS的全称是Complete Fair Scheduler, 可见公平性是CFS的核心目标。所谓公平，就是调度器将CPU时间“按需分配”给每个进程。如何实现“按需分配”，以及如何简洁精炼地实现该逻辑，是CFS要考虑的首要问题。

在讨论调度器的演进历史时，我们看到不管是O(n)还是O(1)，实际上都在做这方面的工作：根据任务的优先级为其分配CPU时间。这是最直观也是最容易实现的策略，但他们判定交互式进程的逻辑过于复杂。CFS吸收了RSDL在公平性上的思想，放弃了“猜测”进程交互性的无谓尝试，根据任务的优先级来给任务分配时间，但与RSDL不同的是，CFS还放弃了“调度周期”这个概念，引入了“虚拟时间（vruntime）”来判断任务是否得到了公平对待。本章我们将深入分析CFS的算法思想，一探CFS设计者们是如何将复杂的问题抽象成一个简洁优雅的模型，并予以实现的！

## 理想模型

我们先来看在理想模型下，CFS如何有效地保证进程调度的公平性。这里我们首先明确两个概念：一是什么是理想模型；二是如何定义公平性。

理想模型：我们知道调度器的调度对象是进程（严格地说是调度实体），现实中进程有不同的种类，同一类进程也有不同的优先级，这些因素会造成进程对运行时间的需求存在差异性，从而影响调度器对公平性的定义。这里我们暂时忽略这些因素，将所有进程对时间的需求完全视为等价的。

公平性：对于理想模型而言，公平性就是每个进程得到同样多的CPU时间。即如果CPU运行的时间总量为T, 并且系统中有N个进程，那么最终每个进程得到的时间为T/N

但在实际情况下，系统是无法预先知道T的，此时调度器应该采用何种算法保证系统在任意时刻的公平性呢？在现实中，绝对的公平几乎是不可能实现的，也就是说，在任意时刻系统都存在着某种程度的不公平，即某些进程暂时获得了比其他进程更多的CPU时间。调度器要维持公平性，实际上就是要去除掉不公平性，那么每次调度时，调度器选择当前消耗CPU时间最少的进程即可。

这样我们抽象出了调度器的核心逻辑：以进程当前的实际运行时间为度量单位，记为runtime, 调度器每次调度时都选择runtime最小的进程。

该逻辑非常简单，因为此时我们只需要一个维度的指标（runtime）来度量当前系统的公平性。

## 优先级与权重

“理想模型”在现实情况中是不存在的！在现实世界中，我们必须考虑进程种类及优先级的不同所造成的对CPU时间需求的差异性。

> 前面章节我们已经详细讨论过了进程优先级的概念，这里我们只考虑CFS, 因此只讨论普通进程（Normal Process）的优先级给调度器带来的影响，即我们通常讨论的 nice 值。

CFS的公平性本质上是对CPU时间的公平分配，而优先级的加入并没有改变这一点，优先级本质上是为了区分不同进程的重要性，只要调度器能够根据每个进程的重要性的比例来分配时间，那么它就依然是公平的。例如我们有三个进程 A, B, C, 其中B与C的重要性相等，而A的重要性是他们的两倍，那么在公平的分配策略下，三个进程得到的CPU时间比例应该是：

* A: 50%
* B: 25%
* C: 25%

不同的优先级实际上代表着不同的权重，Linux中普通进程的nice值为\[-20, 19], Linux将nice值映射成了一个权重数组 `sched_prio_to_weight` ：

```
/* file: kernel/sched/core.c */
const int sched_prio_to_weight[40] = {
/* -20 */ 88761, 71755, 56483, 46273, 36291,
/* -15 */ 29154, 23254, 18705, 14949, 11916,
/* -10 */ 9548,  7620,  6100,  4904,  3906,
/*  -5 */ 3121,  2501,  1991,  1586,  1277,
/*   0 */ 1024,  820,   655,   526,   423,
/*   5 */ 335,   272,   215,   172,   137,
/*  10 */ 110,   87,    70,    56,    45,
/*  15 */ 36,    29,    23,    18,    15,
};
```

权重与nice值的关系是： `weight = 1024 / 1.25^nice`, 这样设计的目的是为了满足CFS的分配原则：进程的nice数值每变化1, 那么其获得的CPU时间将变化10%. 我们以任意两个相邻的权重来校验一下：

* A: 88761 / (88761 + 71755) = 0.55
* B: 71755 / (88761 + 71755) = 0.45

也就是说，如果进程A,B最初的nice值都是-20(此时两个进程各分配50%的时间)，那么当B变为-19后，它的CPU时间就会比A少了10%.

有了权重的概念之后，我们需要使用权重比例来刻画公平性。调度算法需要根据runtime计算出当前任务被分配到的实际比例，然后跟自己应该得到的比例相比较，差值最大的那个任务就是最应该被调度的任务。同样拿前文中A,B,C三个进程作为例子（三个任务的权重比重分别为：50%,25%,25%），假设系统已经运行了100ms, 三个任务均分了该时间片：

* A: 33.3ms, 实际比例1/3, 期望比例为1/2, 差值为：1/6
* B: 33.3ms, 实际比例1/3, 期望比例为1/4, 差值为：1/12
* C: 33.3ms, 实际比例1/3, 期望比例为1/4, 差值为：1/12

此时A的比例差值最大，说明A所分配到的时间比例偏少，调度器应该选择A作为下一个任务来执行。整个过程略显复杂，需要每个任务都记录下自己的时间比例与权重比例，而前文我们讨论理想模型时，我们只需要知道每个任务的runtime即可。

有没有什么度量方式，让我们在引入权重差异性之后，依然保持理想模型下的调度简洁性呢？答案是“虚拟时间”！

## 虚拟时间

在上一节讨论权重时，我们自然地从权重比例想到了时间比例，导致了计算逻辑稍显复杂，但如果换一种思路：通过任务的权重比例对runtime进行伸缩（scale）的话，那么我们依然可以使用“伸缩后的时间”这一个维度来观察系统的公平性。

再次回到前文中的例子：A,B,C 三个任务的权重比分别为50%, 25%, 25%, 如果100ms的总时间按照公平的方式分配，那么他们将分别得到：

* A: 50ms
* B: 25ms
* C: 25ms

我们将该时间称为墙上时间（wall time, 时钟挂在墙上，所以我们将物理时间叫着墙上时间），虽然三者的墙上时间不同，但只要将他们除以各自的权重比例，就可以得到一个归一化的数值：

* A: 50 / 50% = 100
* B: 25 / 25% = 100
* C: 25 / 25% = 100

我们可以将该结果叫着虚拟时间（virtual runtime, 简称vruntime），如果任务的墙上时间分配是公平的，那么大家的vruntime就肯定是相等的。我们可以理解为每个任务有一个自己的虚拟时钟，该时钟的走速等于墙上时钟的走速除以任务的权重比例，也就是说任务的权重越低，其虚拟时钟就走得越快，权重越高就走得越慢，例如上例中，B与C的虚拟时钟走速就是A的两倍，但只要大家的虚拟时间在数值上相等，系统在时间分配上就是公平的。

有了虚拟时间这个概念，我们依然可以维持调度算法的简洁性：调度器要维持公平性，就是要维持所有任务的虚拟时间相同，也就是说每次选择虚拟时间最小的任务进行执行即可。

既然虚拟时间这么重要，那么系统如何计算呢？上一节我们给出了任务权重的计算公式： `weight = 1024 / 1.25^nice`, 当nice=0时，任务的权重值就是1024, 1024是权重的一个基准，内核中专门使用宏 `NICE_0_LOAD` 来记录该数值, 其他所有的权重都是在该基准上进行伸缩得到的。因此，在已知墙上时间之后，虚拟时间的计算就简单了： `vruntime = wall_time * NICE_0_LOAD / weight`, 可以看到 nice=0 的任务虚拟时间就是墙上时间。为了避免浮点数计算，程序中会将数字先放大再缩小，所以最终的计算公式为： `vruntime = (wall_time * ((NICE_0_LOAD * 2^32) / weight)) >> 32`, 其中 232/weight 也叫着 invweight, 其值预先计算好了存放在数组 `sched_prio_to_wmult` 中，所以可以得到最终的虚拟时间计算公式为： `vruntime = (wall_time * NICE_0_LOAD * inv_weight) >> 32`. 之所以优化做到了这种程度，是因为计算虚拟时间是调度器中非常高频次的操作，调度器需要将任务消耗掉的墙上时间计算成虚拟时间，然后累计起来。后续讨论CFS的runqueue时我们将看到Linux使用一颗红黑树来保存所有的任务，Key值就是任务的虚拟时间，因此红黑树最左侧节点的任务就是调度器应该选择的任务。

总结起来，虚拟时间就是CFS衡量系统公平性的唯一指标。可以发现CFS在公平性这方面的思路比O(n)和O(1)都简洁，到底CFS如何基于虚拟时间完成调度，我们将在后续章节中深入讨论。


# 2.6.2 调度逻辑

通过前文对于公平性的讨论，我们知道对于CFS所管理的任务而言，vruntime相等就是公平的，CFS的工作职责就是维持所有任务的vruntime尽可能相等。总结起来，CFS的工作原理如下：CFS为每个任务维护一个虚拟时间vruntime, 每次调度时都从runqueue中挑选vruntime最小的任务来执行，并将任务执行时所耗费的墙上时间根据任务的权重换算成虚拟时间累计起来；随着时间的消耗，当当前任务的vruntime数值不再是runqueue中最小的时，调度器将其放回runqueue, 重新选择下一个任务。

CFS作为一个单独的调度类（sched\_class），其所有的代码都在文件 `kernel/sched/fair.c` 中。虽然CFS 的工作原理看起来比O(1)简单很多，但该文件的体量却比O(1)大了一个数量级：总共有10k+行代码。除了核心的调度逻辑之外，还涉及到任务组调度、CPU带宽控制、SMP架构下的负载均衡等逻辑。本章我们将详细介绍CFS 的核心调度逻辑，其它主题将在后续章节中探讨。


# 2.6.2.1 调度逻辑 - 数据结构

不管是O(n)还是O(1), 调度器采用的都是列表来组织任务，O(n)将所有的任务都放在一个列表中，每次调度时都需要遍历所有节点；O(1)采用了多级列表，不同的列表通过优先级来划分，显著地提高了查找效率。由于CFS在调度时只需要选择vruntime最小的节点，因此CFS选择了红黑树（Red-Black Tree）这种更高效的数据结构来管理任务，红黑树的Key就是vruntime, 这样红黑树最左子节点永远都是下一个需要调度的任务。

> 红黑树是二叉查找树（BST - Bianry Search Tree）的一种，其特点是在对树节点进行增加、删除操作时能够尽可能地保持平衡，这样可以保证即使在最坏的情况下，总体的时间复杂度都保持在对数级别。这里我们不对红黑树做深入讨论，感兴趣的读者可以自行查阅资料。
>
> Linux中黑红树的实现在文件 `lib/rbtree.c` 中，此处有详细介绍：<https://lwn.net/Articles/184495/>

CFS 使用调度实体 `sched_entity` 来封装调度时需要的各种属性，删除组调度与统计信息后代码如下：

```
/* file: include/linux/sched.h */
struct sched_entity {
/* 记录当前se的权重信息，由进程的nice值计算得到 */
struct load_weight load;
/* 指向该se在红黑树中的节点 */
struct rb_node run_node;
/* 是否在 runqueue 上，1 则表示在 rq 中 */
unsigned int on_rq;

/* 当该se被调度时，记录其开始执行的时间，以便后期用来计算该se此次调度的时长 */
u64 exec_start;
/* 记录总运行时间 */
u64 sum_exec_runtime;
/* 该进程的虚拟运行时间，该值是红黑树中的 key, CFS 依据该值来保证公平调度 */
u64 vruntime;
/* 截止该调度周期开始时，进程的总运行时间，在check_preempt_tick中会使用到 */
u64 prev_sum_exec_runtime;
}

struct load_weight {
/* nice 对应的权重数组sched_prio_to_weight中的值 */
unsigned long weight;

/* 记录 sched_prio_to_wmult 数组中的值 */
u32 inv_weight;
};
```

CFS 定义了自己的运行队列 `cfs_rq`, 同样删除额外字段后内容如下：

```
/* file: kernel/sched/sched.h */
struct cfs_rq {
/* 保存 CFS 整个运行队列的总权重，用于给每个调度实体计算 vruntime */
struct load_weight load;
/* 队列里面处于 runnable 状态的 se 的总数 */
unsigned int nr_running;
/* h 代表 hierarchy, 该字段会在组调度时讲解 */
unsigned int h_nr_running;

/* 记录该 cfs_rq 的总运行时间，用于统计 */
u64 exec_clock;
/* 记录队列中当前最小的 vruntime */
u64 min_vruntime;

/* 保存一个指向红黑树根节点与最左子节点的缓存 */
struct rb_root_cached tasks_timeline;

/* 指向当前正在运行的 se */
struct sched_entity *curr;
struct sched_entity *next;
struct sched_entity *last;
struct sched_entity *skip;
};

/* file: tools/include/linux/rbtree.h */
/* 指向红黑树根节点与最左子节点的缓存结构 */
struct rb_root_cached {
struct rb_root rb_root;
struct rb_node *rb_leftmost;
};
```

为什么需要保存 `min_vruntime` 呢？CFS 每次调度都选择 vruntime 最小的进程来运行，试想一下当系统创建一个新的进程时，如何给该进程初始化 vruntime 呢？正常来说该任务还没有运行过，所以该值应该为0, 但如果这样的话该进程的 vruntime 将远远小于队列中所有的任务，它将一直霸占 CPU 运行下去直到 vruntime 追上队列中其它任务，而这显然是不合理的。因此我们需要一个方法来对这些情况进行调节，该方法就是 `min_vruntime`, 后续我们将介绍详细的调节方式。

总体而言，CFS的核心数据结构关系如下：

![CFS rq](/files/8m0EPfjvfv3Dmu6oojK8)


# 2.6.2.2 调度逻辑 - vruntime

vruntime是CFS的核心，到目前为止，我们已经详细探讨了vruntime的概念与用途，这一节我们深入代码，看一下调度器是如何计算与调整任务的vruntime的。

对于当前正在执行的任务，调度器每过一段时间就需要将该任务使用过的墙上时间计算成vruntime然后累计到 `sched_entity->vruntime` 中，具体的算法我们在前面已经讨论过了，内核通过函数 `calc_delta_fair` 来完成这项工作：

```
/* file: kernel/sched/fair.c */
/* 参数delta代表墙上时间 */
static inline u64 calc_delta_fair(u64 delta, struct sched_entity *se) {
/* 如果se的权重不等于NICE_0_LOAD,则需要根据其权重计算出vruntime */
if (unlikely(se->load.weight != NICE_0_LOAD))
delta = __calc_delta(delta, NICE_0_LOAD, &se->load);

/* 如果se的权重等于NICE_0_LOAD, 则该se的vruntime就是墙上时间，即返回delta即可
*/
return delta;
}
```

函数 `__calc_delta` 就是算法 `vruntime = (wall_time * ((NICE_0_LOAD * 2^32) / weight)) >> 32` 的具体实现，由于 `wall_time`, `NICE_0_LOAD` 与 `weight` 都是通过参数传递进去的，所以准确地说，该函数的结果就是后两个参数的比值乘以第一个参数，调度器在为每个任务计算一个调度周期内的时间配额时还会用到该函数。

除了为运行的任务计算vruntime之外，调度器还需要调整新创建的任务及久睡方醒的任务的vruntime, 否则他们的vruntime将远远落后于一直在执行的任务，从而导致长久的霸占CPU. 该任务通过函数 `place_entity` 完成：

```
/* file: kernel/sched/fair.c */
static void place_entity(struct cfs_rq *cfs_rq, struct sched_entity *se,
                    int initial) {
/* 以队列的min_vruntime为基础进行调整 */
u64 vruntime = cfs_rq->min_vruntime;

/* 对新创建的进程（initial=1）适当的惩罚，为其加上一定的 vruntime,
* 函数sched_vslice将任务在一个调度周期内应当分配到的墙上时间换算成虚拟时间，调度周期会在下一节介绍
*/
if (initial && sched_feat(START_DEBIT))
vruntime += sched_vslice(cfs_rq, se);

if (!initial) {
/* 如果进程睡眠了很久，那么其 vruntime 可能远远小于队列中其他任务的vruntime,
    * 我们也需要对其vruntime
    * 进行惩罚，但进程被唤醒（initial=0）说明它所等待的事件已经得到了满足，需要马上干活，所以这里减去一定的vruntime作为补偿。*/
unsigned long thresh = sysctl_sched_latency;

if (sched_feat(GENTLE_FAIR_SLEEPERS))
    thresh >>= 1;

vruntime -= thresh;
}

/* 确保不要因为 vruntime -=thresh 导致 se.vruntime 的值越来越小了 */
se->vruntime = max_vruntime(se->vruntime, vruntime);
}
```

该函数总是为目标任务加上一定的vruntime, 因此事实上是一个“惩罚函数”。

接下来我们再来看一下系统如何更新任务的vruntime以及其他的时间信息的，完成该任务的是函数 `update_curr()`:

```
/* file: kernel/sched/fair.c */
static void update_curr(struct cfs_rq *cfs_rq) {
struct sched_entity *curr = cfs_rq->curr;
/* 获取当前时间 */
u64 now = rq_clock_task(rq_of(cfs_rq));
u64 delta_exec;

if (unlikely(!curr))
return;

/* 本次更新vruntime与上次更新vruntime之间的时间差，即任务本次的运行时间，该值为墙上时间
*/
delta_exec = now - curr->exec_start;
if (unlikely((s64)delta_exec <= 0))
return;

/* 记录这次更新的时间 */
curr->exec_start = now;

/* 更新总的运行时间 */
curr->sum_exec_runtime += delta_exec;

/* 更新vruntime, 通过函数calc_delta_fair计算出墙上时间delta_exec对应的虚拟时间
*/
curr->vruntime += calc_delta_fair(delta_exec, curr);
/* 更新队列的 min_vruntime */
update_min_vruntime(cfs_rq);

/* 下面的逻辑可以暂时不管 */
if (entity_is_task(curr)) {
struct task_struct *curtask = task_of(curr);

trace_sched_stat_runtime(curtask, delta_exec, curr->vruntime);
cgroup_account_cputime(curtask, delta_exec);
account_group_exec_runtime(curtask, delta_exec);
}

account_cfs_rq_runtime(cfs_rq, delta_exec);
}
```

任务的vruntime就靠函数 `update_curr` 来维护，系统在很多情况下都会调用该方法，包括任务在入队、出队时，调度中断函数也会周期性地调用该方法，以确保任务的各种时间信息随时都是最新的状态。


# 2.6.2.3 调度逻辑 - 调度周期

从CFS的调度原理来考虑的话，CFS似乎不需要调度周期的概念，因为CFS并不是预先给任务分配时间片，而是根据大家当前的运行时间来判断谁应该是下一个该执行的任务，这样所有任务都随着时间的推进齐头并进。但为了用来调度延迟，CFS也需要引入调度周期。

什么是调度延迟呢？CFS不仅需要保证时间分配的公平，还要保证各个任务每隔一段时间就能够执行一次，一个任务在两次被调度到的时间间隔就是调度延迟。相反，调度器还需要保证任务在每次得到机会执行时，除了任务主动放弃CPU, 尽量不要太快地被踢出来，因为太频繁的上下文切换会导致系统的总体性能降低。所以 CFS 没有使用固定的时间长度作为调度周期，而是根据当前队列中的任务数量动态计算出调度周期的长度，该逻辑由函数 `__sched_period` 实现：

```
/* file: kernel/sched/fair.c */
/* 参数 nr_running 表示当前 cfs_rq 中的任务总数 */
static u64 __sched_period(unsigned long nr_running) {
/* sched_nr_latency: 8 */
if (unlikely(nr_running > sched_nr_latency))
/* sysctl_sched_min_granularity: 0.75ms */
return nr_running * sysctl_sched_min_granularity;
else
/* sysctl_sched_latency: 6ms*/
return sysctl_sched_latency;
}
```

当队列中所有的任务超过8个时，CFS的调度周期为任务总数乘以0.75ms，否则调度周期为固定的6ms, 这样可以保证任务的切换频率比较合理。

算出调度周期之后，系统还需要为任务计算其在一个调度周期内的时间配额，函数 `sched_slice` 用来实现该逻辑：

```
/* file: kernel/sched/fair.c */
static u64 sched_slice(struct cfs_rq *cfs_rq, struct sched_entity *se) {
unsigned int nr_running = cfs_rq->nr_running;
u64 slice;

/* 调度周期 */
slice = __sched_period(nr_running + !se->on_rq);

/* 暂时不考虑组调度，此处的循环只会执行一次 */
for_each_sched_entity(se) {
struct load_weight *load;
struct load_weight lw;

cfs_rq = cfs_rq_of(se);
/* 整个运行队列 cfs_rq 的总权重 */
load = &cfs_rq->load;

/* se->load.weight为se的权重，调用函数__calc_delta得到slice*se->load.weight/load.weight,
    * 即根据 se 在整个队列中的权重比例分配时间 */
slice = __calc_delta(slice, se->load.weight, load);
}

if (sched_feat(BASE_SLICE))
slice = max(slice, (u64)sysctl_sched_min_granularity);

return slice;
}
```

当任务在当前调度周期内的耗时超过自己的配额时，调度器就会将其踢出去，换其他任务来执行，下一节将会详细讨论该逻辑。


# 2.6.2.4 调度逻辑 - 调度节拍

计算机系统随着时钟节拍需要周期性地做很多事情，例如刷新屏幕、数据落盘等，而进程调度是众多任务中最重要的之一。周期性调度也叫调度节拍，它的入口是函数 `schedule_tick()`, 该函数最终会调用调度类的 `task_tick()` 方法完成操作：

```
/* file: kernel/sched/core.c */
void scheduler_tick(void) {
int cpu = smp_processor_id();
struct rq *rq = cpu_rq(cpu);
struct task_struct *curr = rq->curr;

/* 调用当前任务的调度类的 task_tick 方法 */
curr->sched_class->task_tick(rq, curr, 0);

#ifdef CONFIG_SMP
/* SMP 架构下触发负载均衡 */
rq->idle_balance = idle_cpu(cpu);
trigger_load_balance(rq);
#endif
}
```

CFS 中实现 `task_tick` 方法的是函数 `task_tick_fair`:

```
/* file: kernel/sched/fair.c */
static void task_tick_fair(struct rq *rq, struct task_struct *curr,
                    int queued) {
struct cfs_rq *cfs_rq;
struct sched_entity *se = &curr->se;

/* 在不考虑组调度的情况下，此处的循环只会迭代一次，处理的就是当前任务 */
for_each_sched_entity(se) {
cfs_rq = cfs_rq_of(se);
entity_tick(cfs_rq, se, queued);
}
}
```

实际的逻辑都在函数 `entity_tick` 中，删除无关代码及与组调度相关的逻辑，主要逻辑如下：

```
/* file: kernel/sched/fair.c */
static void entity_tick(struct cfs_rq *cfs_rq, struct sched_entity *curr,
                int queued)
{
/* 首先更新当前任务及队列的各种时间信息，详见 vruntime 一节 */
update_curr(cfs_rq);

if (cfs_rq->nr_running > 1)
/* 检查是否需要抢占当前任务 */
check_preempt_tick(cfs_rq, curr);
}
```

该函数的主要任务有两个，一个是更新任务的各种时间信息；另一个是检查当前任务是否已经执行地足够久了，如果是的话就需要对其进行抢占，换其它任务来执行。关于抢占的概念将在下一节中介绍。


# 2.6.2.5 调度逻辑 - 任务抢占

所谓抢占，就是停止当前正在执行的任务，换另一个任务来执行。导致这种情况发生的原因很多，例如当前任务已经运行了太长时间，需要让出CPU; 用户修改了任务优先级，导致当前任务应该被换下；或者优先级更高的任务被唤醒，需要立刻开始运行。但当这种情况发生时，调度器并不会真的立刻切换任务，而是调用 `resched_curr()` 函数为当前任务设置一个叫着 `TIF_NEED_RESCHED` 的标记位，该函数的主要逻辑如下：

```
/* file: kernel/sched/core.c */
void resched_curr(struct rq *rq) {
struct task_struct *curr = rq->curr;
int cpu;

lockdep_assert_held(&rq->lock);

/* 如果当前任务已经设置了 TIF_NEED_RESCHED 标记位，则返回 */
if (test_tsk_need_resched(curr))
return;

cpu = cpu_of(rq);

if (cpu == smp_processor_id()) {
/* 设置标记位 */
set_tsk_need_resched(curr);
set_preempt_need_resched();
return;
}
}
```

调用该函数的地方非常多，这里主要介绍两个典型场景：

**1. 任务运行时间耗尽**

经过前面的讨论，我们知道任务在每个调度周期内的时间配额是有限的，当任务耗尽了该时间片之后就需要让出CPU, 以便给其它任务提供运行的机会。上一节在讨论周期性调度时，我们看到函数 `entity_tick` 最后调用了 `check_preempt_tick`, 后者就是检查任务时间是否耗尽的函数，我们来看一下它的实现：

```
/* file: kernel/sched/fair.c */
static void check_preempt_tick(struct cfs_rq *cfs_rq,
                        struct sched_entity *curr) {
unsigned long ideal_runtime, delta_exec;
struct sched_entity *se;
s64 delta;

/* 计算出当前任务在一个调度周期内的时间配额 */
ideal_runtime = sched_slice(cfs_rq, curr);
/* 计算出当前任务已经运行了多长时间 */
delta_exec = curr->sum_exec_runtime - curr->prev_sum_exec_runtime;
if (delta_exec > ideal_runtime) {
/* 如果运行时长已经超过了任务自己的时间配额，则对任务进行抢占 */
resched_curr(rq_of(cfs_rq));
return;
}

/* 避免任务抢占发生得太过频繁 */
if (delta_exec < sysctl_sched_min_granularity)
return;

/* 从cfs_fq中挑出vruntime最小的任务，即红黑树中最左子节点；并计算出当前任务与该任务的vruntime的差值
*/
se = __pick_first_entity(cfs_rq);
delta = curr->vruntime - se->vruntime;

/* 如果当前任务的vruntime依然小于红黑树中所有任务的vruntime, 则不发生抢占 */
if (delta < 0)
return;

/* 如果已经多除了相当部分，则可以抢占当前任务了 */
if (delta > ideal_runtime)
resched_curr(rq_of(cfs_rq));
}
```

**2. 新任务被创建**

新任务创建后可能需要立刻投入运行，这时候也需要检查抢占情况。从用户态（user mode）来看，Linux的新任务通过系统调用 `clone()` 创建，其最终会调用到 `kernel_clone` 方法，该方法中与调度相关的逻辑裁剪如下：

```
/* file: kernel/fork.c */

pid_t kernel_clone(struct kernel_clone_args *args) {
struct task_struct *p;
pid_t nr;

/* 删除了巨多的代码 */

p = copy_process(NULL, trace, NUMA_NO_NODE, args);

/* 删除了巨多的代码 */
wake_up_new_task(p);

/* 删除了巨多的代码 */
return nr;
}
```

函数 `copy_process` 为新的任务创建并初始化 `task_struct`, 中途会调用函数 `sched_fork` 对与调度相关的字段进行初始化：

```
/* file: kernel/fork.c */
static __latent_entropy struct task_struct *
copy_process(struct pid *pid, int trace, int node,
        struct kernel_clone_args *args) {
struct task_struct *p;
u64 clone_flags = args->flags;

/* 初始化调度相关的数据 */
retval = sched_fork(clone_flags, p);

return p;
}
```

函数 `sched_fork` 与 `wake_up_new_task` 都定义在调度文件 `kernel/sched/core.c` 中，前者用来初始化各种调度相关的字段，而后者用来检查新创建的任务是否需要抢占当前任务，我们这里着重看一下后者的实现：

```
void wake_up_new_task(struct task_struct *p) {
struct rq_flags rf;
struct rq *rq;

p->state = TASK_RUNNING;

/* 最终会调用 sched_class 中的 enqueue_task 将该任务放入队列 */
activate_task(rq, p, ENQUEUE_NOCLOCK);

/* 调用 sched_class.check_preempt_curr 函数 */
check_preempt_curr(rq, p, WF_FORK);

#ifdef CONFIG_SMP
if (p->sched_class->task_woken) {
p->sched_class->task_woken(rq, p);
}
#endif
}
```

函数体中的 `activate_task` 最终会将新任务放入运行队列中，而 `check_preempt_curr` 就是最终判断抢占逻辑的函数。CFS 中实现该方法的函数是 `check_preempt_wakeup`, 仅保留主干逻辑，代码如下：

```
/* file: kernel/sched/fair.c */
static void check_preempt_wakeup(struct rq *rq, struct task_struct *p,
                            int wake_flags) {
struct task_struct *curr = rq->curr;
struct sched_entity *se = &curr->se, *pse = &p->se;
struct cfs_rq *cfs_rq = task_cfs_rq(curr);
int scale = cfs_rq->nr_running >= sched_nr_latency;

if (unlikely(se == pse))
return;

if (test_tsk_need_resched(curr))
return;

/* 如果当前CPU 上正在运行 idle 任务，则任何非 idle 任务都应该发生抢占 */
if (unlikely(task_has_idle_policy(curr)) && likely(!task_has_idle_policy(p)))
goto preempt;

/* Batch and idle tasks do not preempt non-idle tasks (their preemption, is
* driven by the tick) */
if (unlikely(p->policy != SCHED_NORMAL) || !sched_feat(WAKEUP_PREEMPTION))
return;

/* 不考虑组调度的情况下，该函数为空，因此 se 为当前任务的调度实体 */
find_matching_se(&se, &pse);
/* 更新当前任务的各种时间信息 */
update_curr(cfs_rq_of(se));
BUG_ON(!pse);

/* 判断需不需要用 pse 抢占 se */
if (wakeup_preempt_entity(se, pse) == 1) {
goto preempt;
}

return;

preempt:
/* 设置调度的标记位 */
resched_curr(rq);
}

static int wakeup_preempt_entity(struct sched_entity *curr,
                            struct sched_entity *se) {
s64 gran, vdiff = curr->vruntime - se->vruntime;

/* curr.vruntime 依然小于 se.vruntime, 不抢占 */
if (vdiff <= 0)
return -1;

gran = wakeup_gran(se);
/* 只有se.vruntime相对于curr.vruntime大出一定的范围之后，才发生抢占。gran实际上是1ms对应到se的vruntime,
* 也就是说如果se已经比curr多出了1ms的墙上时间, 那么就可以发生抢占 */
if (vdiff > gran)
return 1;

return 0;
}

static unsigned long wakeup_gran(struct sched_entity *se) {
/* sysctl_sched_wakeup_granularity: 1ms */
unsigned long gran = sysctl_sched_wakeup_granularity;

/* 返回结果就是 1ms 相当于 se 的虚拟时间 */
return calc_delta_fair(gran, se);
}
```

`TIF_NEED_RESCHED` 位被设置之后，调度器在下一次调度发生时就会将该任务换下，并从runqueue中挑选出下一个合适的任务来执行，下一节中我们将探讨调度时机的问题。


# 2.6.2.6 调度逻辑 - 调度时机

从调度器的角度来看，真正的调度（即调度器完成上下文切换，正儿八经地换一个任务来执行）仅发生在函数 `schedule()` 中，剔除额外代码，我们来看一下该函数的主要流程：

```
/* file: kernel/sched/core.c */
asmlinkage __visible void __sched schedule(void) {
struct task_struct *tsk = current;

do {
preempt_disable();
/* 调用函数 __schedule 来做具体的工作 */
__schedule(false);
sched_preempt_enable_no_resched();
/* need_resched用来判断当前任务是否应该被抢占，此时的当前任务就是函数__schedule最新选择的任务，如果是的话那么继续调用函数__schedule以便调用下一个合适的任务。*/
} while (need_resched());
}

static void __sched notrace __schedule(bool preempt) {
struct task_struct *prev, *next;
unsigned long *switch_count;
unsigned long prev_state;
struct rq_flags rf;
struct rq *rq;
int cpu;

/* 获取到当前CPU序号，进而获取到其runqueue */
cpu = smp_processor_id();
rq = cpu_rq(cpu);
/* rq->curr 是当前正在执行的任务 */
prev = rq->curr;

prev_state = prev->state;
if (!preempt && prev_state) {
if (signal_pending_state(prev_state, prev)) {
    prev->state = TASK_RUNNING;
} else {
    /* preempt 如果为false,
        则说明此次调度不是由于任务抢占导致的，那么导致调度发生的原因就是任务主动要求让出CPU,
        对于由于IO事件进入睡眠的任务而言，需要先将其从运行队列中踢出去。该函数最终会调用调度类（sched_class）的
        dequeue_task 方法完成具体工作。*/
    deactivate_task(rq, prev, DEQUEUE_SLEEP | DEQUEUE_NOCLOCK);
}
}

/* 从队列中选择下一个任务，该函数最终会调用调度类（sched_class的函数pick_next_task方法。对于CFS而言，就是选择vruntime最小的任务
*/
next = pick_next_task(rq, prev, &rf);
/* 清除 prev 任务的TIF_NEED_RESCHED标记，因为此时它已经被抢占了 */
clear_tsk_need_resched(prev);

if (likely(prev != next)) {
/* 完成上下文切换，CPU将开始执行刚刚挑出来的任务next了 */
rq = context_switch(rq, prev, next, &rf);
}
}
```

关于选择下一个任务的语句 `next = pick_next_task(rq, prev, &rf);`, 其中函数 `pick_next_task()` 就是我们在调度类中讨论的那个方法，CFS 的实现是函数 `pick_next_task_fair`, 他的主要逻辑就是从红黑树中选择最左侧的任务，我们在这里不再深入细节讨论。

函数 `schedule()` 最后调用 `context_switch()` 完成上下文切换，至此，整个调度工作就完成了！


# 2.6.3 组调度

前面讨论CFS时我们忽略了组调度的逻辑，本章我们将详细讨论一下这个话题。

> 实时进程（RT）与CFS都支持组调度，本节只讨论CFS中组调度的实现。编译内核时如果设置了 `CONFIG_FAIR_GROUP_SCHED=y`, 则CFS将使能组调度的功能。

组调度是随着CFS加入到内核中的，在此之前没有任何一个调度器支持该功能。组调度让CFS能够更灵活地给任务分配时间，也是[cgroups](https://en.wikipedia.org/wiki/Cgroups)的基础，而cgroups又是当前大火的容器技术的基础，因此从根本上理解组调度的运行机制是很有必要的。


# 2.6.3.1 组调度 - 数据结构

我们先将任务组的总体数据结构放在一边，再来回顾一下CFS的调度逻辑：调度是每个CPU单独进行的，每个CPU有自己的runqueue, 对CFS而言，所有的调度实体都存放在一个cfsrq中，当调度发生时，调度器从该cfsrq中选择vruntime最小的调度实体对应的任务来执行。

如果调度器选择到的调度实体表示一个任务组的话，该怎么办呢？那么调度器需要从该任务组中继续选择vruntime最小的任务来执行。

这里存在一个问题：在多核系统上，一个任务组的所有任务可以分布在多个CPU上，如下图所示：

![](/files/R2Bw1lOSNGSLarVD8LVn)

> 该图只是一个示意图，用来帮助我们理解调度组的概念，请不要将图中结构与任何内核的数据结构做直接的对应。

红色的5个任务隶属于同一个调度组，其中 `SE_G0, SE_G1, SE_G2` 这个 sub-group 被分配到了 CPU0 上，而 `SE_G3, SE_G4` 这个 sub-group 被分配到了 CPU1 上，`SER0` 与 `SE_R1` 分别是用来管理两边 sub-group 的根节点，也就是说当CPU0发生调度并选择到 `SE_R0` 时，会发现它其实代表一个任务组，此时需要进一步从该调度组中挑出最合适的任务来执行，这里应该从 `SE_G0, SE_G1, SE_G2` 中再选一个出来。

这里有两个问题：

1. 如何区分一个 se 是任务还是任务组
2. 如何管理在同一个CPU上并隶属于同一个任务组的多个任务

要回答这些问题，我们又需要回到数据结构 `sched_entity` 中来。在讨论调度实体时我们提到， `sched_entity` 存在的原因就是设计者需要一个既可以表示单个任务、又可以封装一个任务组的数据结构。现在我们来看一下该结构体的完整定义：

```
/* file: include/linux/sched.h */
struct sched_entity {
/* For load-balancing: */
/* 权重，权重由进程的 nice 值进行计算 */
struct load_weight load;
/* 红黑树节点 */
struct rb_node run_node;
struct list_head group_node;
/* 是否在 runqueue 上，1 则表示在 rq 中 */
unsigned int on_rq;

/* 记录该进程在 CPU 上开始执行的时间 */
u64 exec_start;
/* 记录总运行时间 */
u64 sum_exec_runtime;
/* 该进程的虚拟运行时间，该值是红黑树中的key, CFS 依据该值来保证公平调度 */
u64 vruntime;
/* 截止该调度周期开始时，进程的总运行时间，在check_preempt_tick中会使用到 */
u64 prev_sum_exec_runtime;

/* scheduler 做负载均衡时，对该进程的迁移次数 */
u64 nr_migrations;

/* 统计数据 */
struct sched_statistics statistics;

#ifdef CONFIG_FAIR_GROUP_SCHED
int depth;
/* parent 如果非空的话，那么一定指向一个代表 task_group 的 sched_entity, 即
* my_q 非空 */
struct sched_entity *parent;
/* rq on which this entity is (to be) queued: */
struct cfs_rq *cfs_rq;
/* rq "owned" by this entity/group: */
/* 用来判断该 se 是否是一个 task, 如果 my_q 为null, 则是task, 否则则表示是一个
* task_group 参考宏 entity_is_task  */
struct cfs_rq *my_q;
/* cached value of my_q->h_nr_running */
unsigned long runnable_weight;
#endif

#ifdef CONFIG_SMP
/*
* Per entity load average tracking.
*
* Put into separate cache line so it does not
* collide with read-mostly values above.
*/
struct sched_avg avg;
#endif
};
```

预编译指令 `#ifdef CONFIG_FAIR_GROUP_SCHED` 中间的几个字段都是用来封装任务组的。判断一个 se 是否是任务组的字段是 `my_q`: 如果该字段为 NULL, 则表示一个任务，否则就是一个任务组。而 `my_q` 是我们熟悉的结构体 `cfs_rq`, 也就是说同一个CPU上并且隶属于同一个组的任务又通过另一个cfsrq组织了起来，放在另一棵红黑树中。与之前讲解过的结构全部结合起来，单个CPU 的运行队列结构如下：

![](/files/tWcPSe3dNltZ8qeoOCvg)

弄清楚了单个CPU上的结构之后，我们接下来看一下任务组的总体结构。系统专门定义了一个结构体来封装任务组的信息：

```
/* file: kernel/sched/sched.h */
struct task_group {
/* 以下字段的初始化在函数alloc_fair_sched_group()中，位于文件fair.c。在cgroup
* 初始化时调用 */
#ifdef CONFIG_FAIR_GROUP_SCHED
/* schedulable entities of this group on each CPU */
/* se[i] 表示该 task_group 中在第 i 个 CPU 上的 sched_entity, 该 se
* 代表的是一个任务组，即 sched_entity->my_q 指向该结构体的 cfs_rq[cpu] */
struct sched_entity **se;
/* cfs_rq[i] 表示该 task_group 中在第 i 个 CPU 上的 cfs_rq. 在函数
* alloc_fair_sched_group 中初始化 */
struct cfs_rq **cfs_rq;
/* 该 task_group 的 cpu.shares, 表示该 task_group 的权重 */
unsigned long shares;
#endif

struct rcu_head rcu;
struct list_head list;

struct task_group *parent;
struct list_head siblings;
struct list_head children;

struct cfs_bandwidth cfs_bandwidth;
};
```

这里我们暂时只关心两个字段：

* `struct sched_entity **se`
* `struct cfs_rq **cfs_rq`

通过前文我们知道，系统将一个任务组分布到各个CPU上时，同一个CPU上所分配到的所有任务会形成一个子组（sub-group）并通过一个cfsrq来管理；而CPU本身的cfsrq中会有一个代表任务组 se 指向该子组； `task_group` 的这两个字段分别用来保存这两方面的内容：se\[i]用来保存CPU\[i]队列上代表任务组的se，而cfsrq\[i]用来保存CPU\[i]所分配到的任务子组，其中 `i` 表示CPU的索引下标。总体结构的示意图如下：&#x20;

![](/files/FoaSpMkua3vKd7ZxN7n8)

除了 `sched_entity` 与 `task_group` 之外，`cfs_rq`与 `rq` 中也有与组调度相关的字段，由于并不影响我们从总体上理解任务组的结构，因此在这里不再细究。


# 2.6.3.2 组调度 - 调度逻辑

其实从总体思路上讲，引入任务组并不会对CFS的调度模型产生根本性的改变，只是在时间分配与任务挑选时增加了递归层级：如果目标se代表一个任务组，则需要下层到该任务组的cfsrq中去，把对应的操作再做一遍；如此循环直到最终拿到一个代表任务的se为止。

我们先来看CFS 在挑选下一个任务时如何处理任务组的：

```
/* file: kernel/sched/fair.c */
struct task_struct *pick_next_task_fair(struct rq *rq, struct task_struct *prev,
                                struct rq_flags *rf) {
struct cfs_rq *cfs_rq = &rq->cfs;
struct sched_entity *se;
struct task_struct *p;

do {
se = pick_next_entity(cfs_rq, NULL);
set_next_entity(cfs_rq, se);
cfs_rq = group_cfs_rq(se);
} while (cfs_rq);

p = task_of(se);
}

#ifdef CONFIG_FAIR_GROUP_SCHED
static inline struct cfs_rq *group_cfs_rq(struct sched_entity *grp) {
return grp->my_q;
}
#endif
```

这里仅留下了挑选下一个se的逻辑，对任务组的处理通过一个while循环搞定：如果没有开启组调度，则函数 `group_cfs_rq` 返回NULL, 只会遍历一次；如果开启了组调度，则会返回se指向的cfsrq继续遍历，直到找到最终代表一个具体任务的se为止。


# 2.6.3.3 组调度 - 时间分配

如果se代表的是任务组，那么该se在当前cfsrq中根据权重分配到的时间还需要在自己的任务组内进行二次分配。为se计算时间份额的函数是 `sched_slice`, 该函数的逻辑在调度周期中已经分析过，这里我们再看一下该函数对任务组的处理方式：

```
/* file: kernel/sched/fair.c */
static u64 sched_slice(struct cfs_rq *cfs_rq, struct sched_entity *se) {
unsigned int nr_running = cfs_rq->nr_running;
u64 slice;

/* 调度周期 */
slice = __sched_period(nr_running + !se->on_rq);

/* 处理任务组的循环，颜色se的parent一路往上爬升 */
for_each_sched_entity(se) {
struct load_weight *load;
struct load_weight lw;

cfs_rq = cfs_rq_of(se);
/* 整个运行队列 cfs_rq 的总权重 */
load = &cfs_rq->load;

/* 从时间总额slice中为se分配其应得的份额，根据se在整个队列中的权重比例进行分配 */
slice = __calc_delta(slice, se->load.weight, load);
}

if (sched_feat(BASE_SLICE))
slice = max(slice, (u64)sysctl_sched_min_granularity);

return slice;
}

#define for_each_sched_entity(se) for (; se; se = se->parent)
```

任务组内部对时间做二次分配的算法思路也是一样的，即根据目标 `se` 与任务组的总体权重比例进行分配。我们知道一个任务组的所有调度实体都在一个cfsrq中，代码通过 `se->parent` 一路往上走，依次计算出每个任务组中的时间配额，循环结束后即得到 `se` 最终的时间配额。我们通过一个示意图来理解总体流程：&#x20;

![](/files/UmfVe735Btr5hcADmnAC)

图中目标的调度实体为 `SE2`, 其最终的时间配额结果是 `slice0 = slice * r2 * r1 * r0`, 其中 `slice` 是总时间，可以看出不管任务组嵌套多少层，目标调度实体不管处在哪一层，最终都可以正确计算出其应该分配到的时间配额。


# 2.6.3.4 组调度 - 任务组权重

上一节在讲任务组中 `se` 的时间计算时，有一个点我们没有深究：即 cfsrq 的总权重如何计算？在介绍 `struct task_group` 时，有个字段叫 `shares`:

```
/* file: kernel/sched/sched.h */
struct task_group {
#ifdef CONFIG_FAIR_GROUP_SCHED
unsigned long shares;
#endif
};
```

正常来说，任务组的总体权重确定后，各个cfsrq按照各自的比例进行二次分配即可。如下图所示：&#x20;

![](/files/jNxrATfjGuwjrNu9YOJX)

图中任务组的权重为1024,`SE_R0`中三个se的权重总计为3072,而`SE_R1`中两个se的权重也为3072,因此`SE_R0`与`SE_R1`平分任务组的权重，各占512. 由此可得计算公式：设tg表示整个任务组，grq表示任务组中分配到某个CPU上的cfsrq, ge表示指向该cfsrq的se, 则 `se.weight = tg.shares * grq.weight/sum(grq.weight)`, 其中分母 `sum(grq.weight)` 表示任务组中所有 cfsrq 的权重之和。

计算权重的函数是 `calc_group_shares()`:

```
/* file: kernsl/sched/fair.c */
static long calc_group_shares(struct cfs_rq *cfs_rq) {
long tg_weight, tg_shares, load, shares;
struct task_group *tg = cfs_rq->tg;

tg_shares = READ_ONCE(tg->shares);

load = max(scale_load_down(cfs_rq->load.weight), cfs_rq->avg.load_avg);

tg_weight = atomic_long_read(&tg->load_avg);

/* Ensure tg_weight >= load */
tg_weight -= cfs_rq->tg_load_avg_contrib;
tg_weight += load;

shares = (tg_shares * load);
if (tg_weight)
shares /= tg_weight;

return clamp_t(long, shares, MIN_SHARES, tg_shares);
}
```

函数中并没有使用前面我们总结出的公式来进行计算，因为同时访问所有CPU上的cfsrq是很昂贵的操作，需要对数据操作进行同步，因此这里使用的是任务组的平均负载（tg->loadavg）来替代权重。后续文章会详细讲解负载的计算方法，通常情况下平均负载与任务组的权重几乎相同，因此读者无需对该方法的具体实现感到困扰，内核的工程师们只是使用了一个更高效的方式来对前文中我们讨论的思路进行实现。

用户可以对任务组的权重进行设置，系统最终会调用如下方法来完成操作：

```
/* file: kernel/sched/fair.c */
int sched_group_set_shares(struct task_group *tg, unsigned long shares) {
int i;

/* 设置任务组的 shares */
tg->shares = shares;
/* 设置好任务组的总体 shares 之后，更新任务组在每个CPU上的se的权重信息 */
for_each_possible_cpu(i) {
struct rq *rq = cpu_rq(i);
struct sched_entity *se = tg->se[i];

/* 从当前se开始，沿着parent路径一路更新所有上层任务组的权重信息 */
for_each_sched_entity(se) {
    update_load_avg(cfs_rq_of(se), se, UPDATE_TG);
    update_cfs_group(se);
}
}
}
```

设置了任务组的权重之后，系统需要更新所有涉及到的任务组的权重信息，函数 `update_cfs_group` 会调用 `calc_group_shares` 计算 se 应该得到的配额，这里就不深入探讨了。


# 2.6.4 带宽控制

系统引入任务组的基本目的之一，就是实现资源的分配与控制，对CPU时间的限制通过带宽控制来实现，本节我们就来一探究竟。实时任务和普通任务都有带宽控制的逻辑，我们这里只讨论CFS的带宽控制。

带宽控制的总体思路如下：系统为任务组设置带宽额度，任务组中的各个cfs\_rq首先需要向任务组（task\_group）申请时间，如果申请成功，则cfs\_rq中的任务可以被CFS调度运行，如果申请失败（例如当前周期内任务组的时间配额已经用完），则调度器会将cfs\_rq挂起（throttle）；系统通过定时器周期性地为任务组重新分配时间额度，并将挂起的cfs\_rq解挂（unthrottle）。

下面我们深入代码，从各个方面详细分析带宽控制的实现方式。


# 2.6.4.1 带宽控制 - 数据结构

如果需要开启CFS的带宽控制功能，编译内核时需要设置 `CONFIG_CFS_BANDWIDTH=y`, 用于CFS带宽控制的数据结构为:

```
/* file: kernel/sched/sched.h */
struct cfs_bandwidth {
#ifdef CONFIG_CFS_BANDWIDTH
    raw_spinlock_t lock;
    /* 一个周期的时长 */
    ktime_t period;
    /* 一个周期内的时间限额 */
    u64 quota;
    /* 本周期内剩下的可用时间 */
    u64 runtime;
    s64 hierarchical_quota;

    u8 idle;
    u8 period_active;
    u8 slack_started;
    /* 高精度定时器，每个period内定时更新runtime */
    struct hrtimer period_timer;
    /* 回收时间的定时器 */
    struct hrtimer slack_timer;
    /* 所有throttled的cfs_rq挂到该链表上，在定时器的回调函数中遍历该链表执行unthrottle操作
     */
    struct list_head throttled_cfs_rq;

    /* Statistics: */
    int nr_periods;
    int nr_throttled;
    u64 throttled_time;
#endif
};
```

与带宽控制相关的所有信息都封装在该结构中，系统主要通过两个量来对带宽进行控制：

* period: 代表一个周期，带宽控制以周期为单位展开
* quota: 一个周期内的时间限额

带宽控制是以任务组为单位进行的，因此我们可以在任务组的结构体中看到如下字段：

```
/* file: kernel/sched/sched.h */
struct task_group {
    /* 该任务组的带宽控制字段 */
    struct cfs_bandwidth cfs_bandwidth;
};
```

实际的带宽控制将会下沉到任务组的各个cfsrq中去落实，因此cfsrq也包含了与带宽控制相关的字段：

```
/* file: kernel/sched/sched.h */
struct cfs_rq {
#ifdef CONFIG_FAIR_GROUP_SCHED
    struct rq *rq; /* CPU runqueue to which this cfs_rq is attached */

    /* 该 cfs_rq 所属的任务组 */
    struct task_group *tg; /* group that "owns" this runqueue */

    /* 用于带宽控制的字段 */
#ifdef CONFIG_CFS_BANDWIDTH
    /* 是否开启带宽限制 */
    int runtime_enabled;
    /* 当前cfs_rq从task_group中分配到的时间配额的剩余量，如果该时间小于等于0,
     * 则需要重新从task_group中申请时间 */
    s64 runtime_remaining;

    /* 记录cfs_rq被throttle时的时间点，用于统计被throttle的时间 */
    u64 throttled_clock;
    u64 throttled_clock_task;
    u64 throttled_clock_task_time;
    /* 标记当前 cfs_rq 是否被 throttle */
    int throttled;
    /* 记录当前cfs_rq被throttle的次数，如果上层task_group被throttle时，该数字也会增加
     */
    int throttle_count;
    /* 被throttle时挂入cfs_bandwidth->throttled_cfs_rq链表 */
    struct list_head throttled_list;
#endif /* CONFIG_CFS_BANDWIDTH */
#endif /* CONFIG_FAIR_GROUP_SCHED */
};
```

这里主要的字段是 `runtime_enabled` 与 `runtime_remaining`, 调度器主要通过这两个字段来控制cfsrq的带宽，我们在下一节将详细讨论实现细节。


# 2.6.4.2 带宽控制 - 带宽时间

前文提到实际的带宽控制是在任务组下属的`cfs_rq`队列中进行的，而`cfs_rq` 对带宽时间的操作归总起来就两点：更新与申请。

cfsrq申请到的时间保存在字段 `runtime_remaining` 中，每当更新任务的时间时，系统也会更新该字段。前面讨论vruntime时我们提到系统通过函数 `update_curr` 来更新与任务相关的时间信息，实际上该函数也会更新与带宽控制相关的时间：

```
/* file: kernel/sched/fair.c */
static void update_curr(struct cfs_rq *cfs_rq) {
    struct sched_entity *curr = cfs_rq->curr;
    u64 now = rq_clock_task(rq_of(cfs_rq));
    u64 delta_exec;

    /* 本次更新 vruntime 与上次更新 vruntime 之间的差值 */
    delta_exec = now - curr->exec_start;

    /* 省略其他更新时间的代码，这部分在讨论vruntime时已经讨论过 */

    /* 更新与带宽控制相关的字段 */
    account_cfs_rq_runtime(cfs_rq, delta_exec);
}

/* 判断是否启用了带宽控制，如果是的话调用函数__account_cfs_rq_runtime更新对应字段
 */
static __always_inline void account_cfs_rq_runtime(struct cfs_rq *cfs_rq,
                                                   u64 delta_exec) {
    if (!cfs_bandwidth_used() || !cfs_rq->runtime_enabled)
        return;

    __account_cfs_rq_runtime(cfs_rq, delta_exec);
}

static void __account_cfs_rq_runtime(struct cfs_rq *cfs_rq, u64 delta_exec) {
    /* 从剩余时间中减去这次消耗掉的部分，注意这里可能导致cfs_rq->runtime_remaining为负数
     */
    cfs_rq->runtime_remaining -= delta_exec;

    /* 如果还有剩余时间，则函数返回 */
    if (likely(cfs_rq->runtime_remaining > 0))
        return;

    /* 接下来要向所在的任务组申请时间，但如果当前cfs_rq已经被挂起了的话，就不用麻烦了
     */
    if (cfs_rq->throttled)
        return;
    /*
     * 此时我们调用函数assign_cfs_rq_runtime向任务组申请时间。如果申请时间失败，则cfs_rq应该被挂起。
     * 这里调用resched_curr标记cfs_rq->curr的TIF_NEED_RESCHED位，以便随后将其调度出去
     */
    if (!assign_cfs_rq_runtime(cfs_rq) && likely(cfs_rq->curr))
        resched_curr(rq_of(cfs_rq));
}
```

更新带宽时间的逻辑其实很简单，就是从 `cfs->runtime_remaining` 减去本次执行的物理时间。如果此时调度器发现时间余额已经耗尽，则会立即尝试从任务组中申请，如果申请失败，说明在本周期内整个任务组的时间都已经耗尽了，而如果当前正在执行的任务在本cfsrq中的话，则需要将其调度出去。从任务组中申请时间的函数是 `assign_cfs_rq_runtime`:

```
/* file: kernel/sched/fair.c */
static int assign_cfs_rq_runtime(struct cfs_rq *cfs_rq) {
    /* 返回task_group的cfs_bandwidth字段，该字段封装着整个任务组的带宽数据 */
    struct cfs_bandwidth *cfs_b = tg_cfs_bandwidth(cfs_rq->tg);
    int ret;

    raw_spin_lock(&cfs_b->lock);
    /* 实际的申请逻辑，第三个参数是期望申请的时间额度，默认是5ms */
    ret = __assign_cfs_rq_runtime(cfs_b, cfs_rq, sched_cfs_bandwidth_slice());
    raw_spin_unlock(&cfs_b->lock);

    return ret;
}

static int __assign_cfs_rq_runtime(struct cfs_bandwidth *cfs_b,
                                   struct cfs_rq *cfs_rq, u64 target_runtime) {
    u64 min_amount, amount = 0;

    lockdep_assert_held(&cfs_b->lock);

    /* 前文聊到更新带宽时间时，提到 cfs_rq->runtime_remaining
     * 可能为负，这说明上次运行时已经透支了部分时间，这里补回来。
     */
    min_amount = target_runtime - cfs_rq->runtime_remaining;

    /* 没有限制，则要多少分配多少 */
    if (cfs_b->quota == RUNTIME_INF)
        amount = min_amount;
    else {
        /* 保证定时器是打开的，保证周期性地为任务组重置带宽时间 */
        start_cfs_bandwidth(cfs_b);

        /* 如果本周期内还有时间，则可以分配 */
        if (cfs_b->runtime > 0) {
            /* 确保不要透支 */
            amount = min(cfs_b->runtime, min_amount);
            cfs_b->runtime -= amount;
            cfs_b->idle = 0;
        }
    }

    /* 将新申请到的时间补充给cfs_rq */
    cfs_rq->runtime_remaining += amount;

    /* 是否成功地从task_group中申请到了时间 */
    return cfs_rq->runtime_remaining > 0;
}
```


# 2.6.4.3 带宽控制 - 挂起与解挂

当需要挂起一个`cfs_rq`时，系统调用函数 `throttle_cfs_rq` 来实现，挂起的意思就是不要让CFS再调度到该`cfs_rq`中的任何任务，也就是说，我们需要将整个`cfs_rq`从该CPU的rq中移除，实际上就是移除上层`cfs_rq`中指向当前`cfs_rq`的那个调度实体。整个流程我们可以参考下图：&#x20;

![](/files/Vdt3fBMzwdpDzXRbVUfd)

这里系统将要挂起CPU0队列里面的 `cfs_rq_2`, 此时我们需要将 `cfs_rq_1` 中指向它的调度实体 `SE_R1` 从 `cfs_rq_1` 中删除，而将 `SE_R1` 删除之后， `cfs_rq_1` 就是一个空队列了，这时又需要将指向它的调度实体 `SE_R0` 从 `cfs_rq_0` 中删除。该过程需要一直沿着cfsrq的parent属性往上走，直到遇上不需要删除的`cfs_rq`为止，在上图中，该`cfs_rq`就是 `cfs_rq_0`. 整个挂起操作完成后，CPU0的队列示意图为：&#x20;

![](/files/e9WucMbF5tUjvDU1LP8y)

当然挂起 `cfs_rq` 时，系统还需要更新所有受影响的 `cfs_rq` 中的对应字段。函数 `throttle_cfs_rq` 的实现如下：

```
/* file: kernel/sched/fair.c */
static bool throttle_cfs_rq(struct cfs_rq *cfs_rq) {
    struct rq *rq = rq_of(cfs_rq);
    struct cfs_bandwidth *cfs_b = tg_cfs_bandwidth(cfs_rq->tg);
    struct sched_entity *se;
    long task_delta, idle_task_delta, dequeue = 1;

    raw_spin_lock(&cfs_b->lock);
    /* 再最后尝试一把，如果定时器在此刻之前已经更新了任务组的时间了的话，那我们就不用挂起了
     */
    if (__assign_cfs_rq_runtime(cfs_b, cfs_rq, 1)) {
        dequeue = 0;
    } else {
        /* 确实需要挂起，将cfs_rq加入到cfs_bandwidth的挂起列表中，以便后期定时器为其重置时间
         */
        list_add_tail_rcu(&cfs_rq->throttled_list, &cfs_b->throttled_cfs_rq);
    }
    raw_spin_unlock(&cfs_b->lock);

    if (!dequeue)
        return false; /* Throttle no longer required. */

    /* 通过task_group->se拿到对应CPU队列中指向cfs_rq的那个se */
    se = cfs_rq->tg->se[cpu_of(rq_of(cfs_rq))];

    /* freeze hierarchy runnable averages while throttled */
    rcu_read_lock();
    /* 更新以cfs_rq->tg为顶点的所有任务组中cfs_rq的throttle_count字段，将其加一 */
    walk_tg_tree_from(cfs_rq->tg, tg_throttle_down, tg_nop, (void *)rq);
    rcu_read_unlock();

    /* cfs_rq 及其所有子孙 cfs_rq 队列中的可运行任务的总数 */
    task_delta = cfs_rq->h_nr_running;
    idle_task_delta = cfs_rq->idle_h_nr_running;
    /* 删除上层队列中对应的 se, 如上图中所示 */
    for_each_sched_entity(se) {
        struct cfs_rq *qcfs_rq = cfs_rq_of(se);
        /* throttled entity or throttle-on-deactivate */
        if (!se->on_rq)
            goto done;

        /* 从上层 cfs_rq 中删除对应的se */
        dequeue_entity(qcfs_rq, se, DEQUEUE_SLEEP);

        /* 更新上层 cfs_rq 中的对应字段 */
        qcfs_rq->h_nr_running -= task_delta;
        qcfs_rq->idle_h_nr_running -= idle_task_delta;

        /* qcfs_rq->load.weight == 0 的话，说明 qcfs_rq 删除 se
         * 后就是空队列了，否则就可以退出循环了 */
        if (qcfs_rq->load.weight) {
            /* Avoid re-evaluating load for this entity: */
            se = parent_entity(se);
            break;
        }
    }

    /* 经过上面的循环之后，虽然此时se及其父节点都不需要dequeue了，但仍然需要更新对应的字段，因此这里再循环处理一次
     */
    for_each_sched_entity(se) {
        struct cfs_rq *qcfs_rq = cfs_rq_of(se);
        /* throttled entity or throttle-on-deactivate */
        if (!se->on_rq)
            goto done;

        update_load_avg(qcfs_rq, se, 0);
        se_update_runnable(se);

        qcfs_rq->h_nr_running -= task_delta;
        qcfs_rq->idle_h_nr_running -= idle_task_delta;
    }

    /* At this point se is NULL and we are at root level*/
    sub_nr_running(rq, task_delta);

done:
    /*
     * 设置 throttle 的标记位，并记录时间
     */
    cfs_rq->throttled = 1;
    cfs_rq->throttled_clock = rq_clock(rq);
    return true;
}
```

最后 `cfs_rq->throttled` 被设置为1后，整个`cfs_rq`就被挂起了，被挂起的`cfs_rq`已经不在CPU的运行队列中，因此其中的任务就不会被调度到了。

上面是挂起操作，解挂的逻辑由函数 `unthrottle_cfs_rq` 完成，解挂是挂起的逆操作，代码结构几乎都可以对应上，主要就是将`cfs_rq`入队、更新对应se节点的信息、最后有必要的话调用 `resched_curr` 触发调度，感兴趣的读者可以自行阅读源码，这里不再深究。


# 2.6.4.3 带宽控制 - 定时器

带宽控制器中有两个定时器，分别是 `period_timer` 与 `slack_timer`, 前者用来周期性地更新带宽时间并对`cfs_rq`解挂，后者用来酌情回收已经分配给下属`cfs_rq`的时间，本节我们将详细探讨他们的实现原理。

定时器的初始化发生在整个带宽控制器的初始化阶段：

```
/* file: kernel/sched/fair.c */
void init_cfs_bandwidth(struct cfs_bandwidth *cfs_b) {
    raw_spin_lock_init(&cfs_b->lock);
    cfs_b->runtime = 0;
    /* 没有限额 */
    cfs_b->quota = RUNTIME_INF;
    /* 初始化周期为100ms */
    cfs_b->period = ns_to_ktime(default_cfs_period());

    /* 初始化 throttled 列表 */
    INIT_LIST_HEAD(&cfs_b->throttled_cfs_rq);
    /* 初始化 period timer */
    hrtimer_init(&cfs_b->period_timer, CLOCK_MONOTONIC, HRTIMER_MODE_ABS_PINNED);
    cfs_b->period_timer.function = sched_cfs_period_timer;

    /* 初始化slack timer */
    hrtimer_init(&cfs_b->slack_timer, CLOCK_MONOTONIC, HRTIMER_MODE_REL);
    cfs_b->slack_timer.function = sched_cfs_slack_timer;
    cfs_b->slack_started = false;
}
```

该函数在初始化任务组时被调用，可以看到两个定时器的回调函数分别是 `sched_cfs_period_timer` 与 `sched_cfs_slack_timer`, 这里我们先看前者。

```
/* file: kernel/sched/fair.c */

/* period
 * timer的回调函数，在cfs_bandwidth的初始化函数中注册，然后被定时器周期性调用 */
static enum hrtimer_restart sched_cfs_period_timer(struct hrtimer *timer) {
    struct cfs_bandwidth *cfs_b =
        container_of(timer, struct cfs_bandwidth, period_timer);
    unsigned long flags;
    int overrun;
    int idle = 0;
    int count = 0;

    raw_spin_lock_irqsave(&cfs_b->lock, flags);
    /* 函数的主体逻辑需要完成两件事情：
     * 1. 将定时器的过期时间往后推cfs_b->period这么长的时间，因为这里已经在处理当前周期的时间分配了，推移之后的过期时间就是下一个周期的结束时间。该动作通过函数hrtimer_forward_now完成；
     * 2. 为任务组分配带宽时间，并且解挂当前任务组中所有挂起的队列。该部分逻辑通过函数do_sched_cfs_period_timer完成；
     *
     * 第一件事还好，但第二件事可能需要花费一定时间。而任务组的period与quota是用户可以设置的量，如果用户将period设置的太小，那么极有可能在某些情况下导致上述两件事情办完之后，当前周期又已经过完了。
     *
     * 这里通过一个for循环来检测这种情况，如果连续3次都没有在当前周期内完成操作，则系统就对period与quota进行扩充，将二者都scale为原来的两倍。
     *
     * 注意：period有一个上限，就是常量max_cfs_quota_period所定义的1s
     * */
    for (;;) {
        /* 函数hrtimer_forward_now将定时器的过期时间重置为当前时间加上cfs_b->period的那个时间点。返回值overrun表示从上个过期时间点开始算，需要多少个period的时间才能再次将过期时间推到当前时间以后。如下图所示：
         * last expired               now
              |                        |
         * ---|------|------|------|------|---> time
         *    |-->p<-| p: period
         *    |-------->overrun = 4<------|
         *
         * 如果定时器上一次的到期时间还没有到，则说明当前周期还没有结束，函数不会做任何更新，返回值overrun=0.
         * */
        overrun = hrtimer_forward_now(timer, cfs_b->period);

        /* overrun==0表示当前周期还没有结束，退出循环。
         * 如果period的时长合理，则通常应该在第二次循环时发生这种情况，表示第一次循环时已经顺利完成工作了。
         */
        if (!overrun)
            break;

        /* 为任务组重置带宽时间并解挂被挂起的列表。返回1表示当前进程组还没有throttle任何cfs_rq
         */
        idle = do_sched_cfs_period_timer(cfs_b, overrun, flags);

        /* 如果经过多次循环都还没有成功地为任务组配置好带宽时间，则说明当前周期太短了，对period与quota进行scale
         */
        if (++count > 3) {
            u64 new, old = ktime_to_ns(cfs_b->period);

            new = old * 2;
            /* 如果没有超出period的最大限制（1s），则将period与quota的时长都加倍。
             * 加倍的原因见：https://github.com/torvalds/linux/commit/4929a4e6faa0f13289a67cae98139e727f0d4a97
             */
            if (new < max_cfs_quota_period) {
                cfs_b->period = ns_to_ktime(new);
                cfs_b->quota *= 2;
            }

            /* 扩容之后将重置计数器 */
            count = 0;
        }
    }
    if (idle)
        cfs_b->period_active = 0;
    raw_spin_unlock_irqrestore(&cfs_b->lock, flags);

    return idle ? HRTIMER_NORESTART : HRTIMER_RESTART;
}
```

该函数相当于是一个控制层，保证带宽的period与quota的大小合适，并且确保实际工作顺利完成，函数 `do_sched_cfs_period_timer` 可以精简为如下逻辑：

```
/* file: kernel/sched/fair.c */

static int do_sched_cfs_period_timer(struct cfs_bandwidth *cfs_b, int overrun,
                                     unsigned long flags) {
    int throttled;

    throttled = !list_empty(&cfs_b->throttled_cfs_rq);

    /* 重置带宽时间：cfs_b->runtime = cfs_b->quota */
    __refill_cfs_bandwidth_runtime(cfs_b);

    /* 没有cfs_rq被挂起，已经重置了带宽时间，返回即可 */
    if (!throttled) {
        cfs_b->idle = 1;
        return 0;
    }

    /*
     * 一直循环到所有被挂起的cfs_rq都解挂为止
     */
    while (throttled && cfs_b->runtime > 0) {
        /* 实际完成解挂的函数 */
        distribute_cfs_runtime(cfs_b);

        throttled = !list_empty(&cfs_b->throttled_cfs_rq);
    }
}
```

重置好带宽的时间之后，最后调用函数 `distribute_cfs_runtime` 来解挂所有的被挂起的队列：

```
/* file: kernel/sched/fair.c */

/* 该函数主要目的是将 cfs_b 的挂起列表中的队列解挂，因此其为每个 cfs_rq
 * 分配时间时，在补上其上周期透支时间后只象征性地加了1ns, 因此每个 cfs_rq
 * 具体申请时间的操作还是通过函数 assign_cfs_rq_runtime 来完成的 */
static void distribute_cfs_runtime(struct cfs_bandwidth *cfs_b) {
    struct cfs_rq *cfs_rq;
    u64 runtime, remaining = 1;

    rcu_read_lock();
    /* 遍历带宽控制器的挂起列表 */
    list_for_each_entry_rcu(cfs_rq, &cfs_b->throttled_cfs_rq, throttled_list) {
        struct rq *rq = rq_of(cfs_rq);
        struct rq_flags rf;

        rq_lock_irqsave(rq, &rf);
        /* 如果当前的 cfs_rq 已经被解挂了，则直接跳到循环体末尾继续下一次循环 */
        if (!cfs_rq_throttled(cfs_rq))
            goto next;

        /* 接下来操作带宽控制器的时间，需要先加锁 */
        raw_spin_lock(&cfs_b->lock);
        /* 补充上个周期内透支的时间（cfs_rq->runtime_remaining为负），另外再分配1ns,
         * 在这里保证其时间大于0即可 */
        runtime = -cfs_rq->runtime_remaining + 1;
        /* 当然分配给 cfs_rq 的时间不能超过带宽控制器这周期的剩余时间 */
        if (runtime > cfs_b->runtime)
            runtime = cfs_b->runtime;
        /* 从带宽控制器中减掉分配给 cfs_rq 的时间 */
        cfs_b->runtime -= runtime;
        remaining = cfs_b->runtime;
        raw_spin_unlock(&cfs_b->lock);

        /* 将分配给 cfs_rq 的时间加上去 */
        cfs_rq->runtime_remaining += runtime;

        /* 如果分配到了时间，则解挂该cfs_rq */
        if (cfs_rq->runtime_remaining > 0)
            /* 解挂函数在前面章节已经介绍过 */
            unthrottle_cfs_rq(cfs_rq);

    next:
        rq_unlock_irqrestore(rq, &rf);

        /* 如果带宽控制器的时间已经耗尽，则退出 */
        if (!remaining)
            break;
    }
    rcu_read_unlock();
}
```

至此，整个 `period_timer` 就介绍完毕了，接下来我们看一下 `slack_timer` 是做什么的。

我们知道`cfs_rq`每次向任务组申请5ms的时间，对于CPU而言这个时间也不少了，但如果该`cfs_rq`中的任务刚好在申请完时间额度之后就进入了睡眠状态，并且还一睡不醒，那么让它在睡眠过程中一直持有这么多时间是不合理的，这完全可能导致任务组在其他CPU上的`cfs_rq`在本周期内分配不到时间而被挂起。时间是个宝贵的资源，我们必须保证它的利用率，如果这种情况下我们能将`cfs_rq`的时间返回一部分给任务组的话，其它有需要的`cfs_rq`就可以使用了。

返回时间的操作发生在调度器对任务进行dequeue操作时：

```
/* file: kernel/sched/fair.c */

static void dequeue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se,
                           int flags) {
    /* 当开启带宽控制时，该函数会酌情返回一部分时间给任务组 */
    return_cfs_rq_runtime(cfs_rq);
}

static __always_inline void return_cfs_rq_runtime(struct cfs_rq *cfs_rq) {
    if (!cfs_bandwidth_used())
        return;

    /* cfs_rq->nr_running用来检查该队列是否还有可运行的任务，如果没有的话才考虑返回时间
     */
    if (!cfs_rq->runtime_enabled || cfs_rq->nr_running)
        return;

    /* 实际返回时间的函数 */
    __return_cfs_rq_runtime(cfs_rq);
}

static void __return_cfs_rq_runtime(struct cfs_rq *cfs_rq) {
    struct cfs_bandwidth *cfs_b = tg_cfs_bandwidth(cfs_rq->tg);
    /* min_cfs_rq_runtime为1ms, 为自己保留1ms, 剩下的还给组织 */
    s64 slack_runtime = cfs_rq->runtime_remaining - min_cfs_rq_runtime;

    /* 自己的预留时间都不够的话就算了 */
    if (slack_runtime <= 0)
        return;

    raw_spin_lock(&cfs_b->lock);
    /* 确实有限额，那么返回时间才有意义。cfs_b->quota==RUNTIME_INF的话任何时候cfs_rq都可以申请到时间
     */
    if (cfs_b->quota != RUNTIME_INF) {
        /* 将时间还给组织 */
        cfs_b->runtime += slack_runtime;

        /* we are under rq->lock, defer unthrottling using a timer */
        /* 如果归还了时间之后发现任务组有了足够的时间以供申请，并且此时已经有了被挂起的队列，那么可以对其解挂。
         * 函数start_cfs_slack_bandwidth用来开启slack定时器，因为此时我们还持有着rq->lock,
         * 因此通过定时器来完成解挂操作
         */
        if (cfs_b->runtime > sched_cfs_bandwidth_slice() &&
            !list_empty(&cfs_b->throttled_cfs_rq))
            start_cfs_slack_bandwidth(cfs_b);
    }
    raw_spin_unlock(&cfs_b->lock);

    /* 即便不用返回时间这里也从 cfs_rq 中减去，避免下次再尝试返回时间 */
    cfs_rq->runtime_remaining -= slack_runtime;
}
```

函数 `start_cfs_slack_bandwidth` 用来开启 slack 定时器，该定时器的回调函数是 `sched_cfs_slack_timer`, 该函数调用 `do_sched_cfs_slack_timer` 来完成解挂操作：

```
/* file: kernel/sched/fair.c */

static void do_sched_cfs_slack_timer(struct cfs_bandwidth *cfs_b) {
    /* slice 是5ms, 也是cfs_rq一次申请的时间片大小 */
    u64 runtime = 0, slice = sched_cfs_bandwidth_slice();
    unsigned long flags;

    /* confirm we're still not at a refresh boundary */
    raw_spin_lock_irqsave(&cfs_b->lock, flags);
    cfs_b->slack_started = false;

    /* 判断是否period timer马上就要执行了，如果是的话，就等period
     * timer来处理工作，不必在此多此一举 */
    if (runtime_refresh_within(cfs_b, min_bandwidth_expiration)) {
        raw_spin_unlock_irqrestore(&cfs_b->lock, flags);
        return;
    }

    /* 确保runtime > 5ms, 因为cfs_rq一次申请的时间片大小就是5ms,
     * 否则解挂的队列也无法申请时间，还会再次被挂起 */
    if (cfs_b->quota != RUNTIME_INF && cfs_b->runtime > slice)
        runtime = cfs_b->runtime;

    raw_spin_unlock_irqrestore(&cfs_b->lock, flags);

    /* 带宽控制器当前周期的时间总量剩余小于5ms, 没必要去解挂队列了 */
    if (!runtime)
        return;

    /* 尝试着解挂 cfs_rq, 能解挂多少就解挂多少 */
    distribute_cfs_runtime(cfs_b);
}
```

可以看出 `slack` 定时器其实是一个辅助角色，它在`cfs_rq`归还时间后被CFS开启，然后尽可能地提前解挂其它队列，以便提升带宽时间的利用率。


# 2.7 负载追踪


# 2.7.1 负载追踪 - 简介

介绍什么是负载追踪，以及什么是PELT（Per-entity Load Tracking）

提到负载，首先会让人想到命令 `uptime` 或 `top` 输出中的系统平均负载（load average），例如 `uptime` 的输出结果如下：

```
❯ uptime
10:49:22 up  1:05,  2 users,  load average: 0.51, 0.53, 0.67
```

load average的三个值分别代表过去1分钟、5分钟、15分钟的系统平均负载，这三个值实际上来自于 `/proc/loadavg`, Linux的系统平均负载是系统中处于 `runnable` 与 `uninterruptible` 两个状态的任务总数与CPU数量的比值。

> load average 的计算逻辑在 `fs/proc/loadavg.c 中`, 这里不再详述。通常情况下，在评估负载时系统只会计算处于 `runnable` 状态的任务，Linux 将 `uninterruptible` 状态的任务也考虑在内的原因可以详见[Brendan Gregg的这篇文章](https://www.brendangregg.com/blog/2017-08-08/linux-load-averages.html)

从系统角度而言，平均负载反映了系统面临的总体压力，而如果我们将视角切换到任务的话，那么从本质上来说，系统负载代表着任务对系统资源的渴求程度。

现在我们抛开系统负载，继续回到调度器中来。调度器想要更好地完成工作，就必须知道每个任务的负载，例如调度器可以通过一个任务过去的负载来对将来的调度策略进行修正，还有调度器在SMP架构下做负载均衡时，了解每个任务的负载情况也更有利于做出合理的任务迁移决策。准确地说，调度器需要知道的是每个调度实体（sched entity）的负载，而如何对其进行量化，是我们首先需要考虑的问题。

负载代表着一个se对系统资源的渴求程度，当在调度器的语境下讨论时，系统资源仅局限在CPU上，那么我们如何量化一个se对CPU的渴求程度呢？

我们知道权重决定了一个任务的时间配额，那可以直接将权重当成任务的负载使用吗？一个权重更高的任务对CPU的需求更加强烈，这听起来是合理的，但要注意权重是一个静态量，而负载却是一个动态量，而且还是一个瞬时量，它反应的是任务在当前时刻对CPU的需求。如果一个高优先级任务此时处于睡眠状态，而低优先级的任务此时处于可运行状态，那么当前情况下低优先级的任务反而负载更高，因为此时低优先级任务对CPU有需求，而高优先级没有。

也就是说除了权重，我们还需要考虑任务的运行时间，准确地说应该是任务处于可运行状态的时间，因为这个时间才代表了任务对CPU的所有潜在需求。在总时间T中，如果任务处于可运行状态的时间总量为t, 那么 `load = weight * t/T` 看起来是一个合理的负载表达方式，如下图所示：

![](/files/5qjPfBIiaHqT7NFIiblN)

虽然上述公式中我们已经考虑了与负载相关的所有变量，但仍然存在问题，考虑这种情况：权重相同的任务A与任务B在昨天同一时刻启动，A先狂飙了半天然后进入了睡眠状态，而B先睡眠了半天然后一直狂飙到现在，很明显当前时刻B的负载 要远远高于A，但上述公式却会得到一样的结果。根本原因是上述公式实际上计算的是负载的累计值，我们可以将图中的负载拆分成 `load = (t1/T + t2/T + t3/T + t4/T) * weight`, 而在累计过程中，过去的时间与当前时间对结果的影响是等价的，也就是说 `t1/T` 与 `t4/T` 这两个分量对最终结果的贡献相同。而实际上距离当前时间越远的任务负载，对当前时刻负载的影响应该越小，也就是说，我们的公式在累计历史负载时，应该对其进行一定程度的衰减（decay）。为了修正上述公式，我们引入衰减因子y, 那么上图中更合理的负载计算公式应该长得像这样： `load = weight * (t1 * y^3 + t2 * y^2 + t3 * y + t4)/T`

顺着这个思路，我们就可以设计出最终的负载计算公式了：将时间划分为一定粒度的周期，然后将所有周期的负载按照一定的衰减比例累加起来。Linux中计算负载的公式如下： `L = L0 + L1*y + L2*y^2 + L3*y^3 + ...`, 其中时间周期为1ms(为了计算方便，系统将其约等于1024us), 衰减因子y满足 `y^32 = 0.5`, 也就是说负载的半衰期为32ms, 而每个周期的负载贡献Ln就是当前周期内任务处于runnable状态的时间（包括实际运行的时间以及在rq中等待调度的时间）。

Linux中对调度实体（sched entity）的负载追踪叫着PELT(Per-entity Load Tracking), 详细介绍可以参考文章：<https://lwn.net/Articles/531853/>


# 2.7.2 负载追踪 - 数据结构

与负载相关的字段封装在数据结构 `sched_avg` 中：

```
/* file: include/linux/sched.h */
struct sched_avg {
    /* 上一次更新 load 的时间点，用来计算时间间隔 */
    u64 last_update_time;
    /* 基于可运行时间（runnable）的负载总和。
     * runnable状态指任务处于可运行状态的时间，其中包括在rq里等待的时间，以及在CPU上执行的时间
     */
    u64 load_sum;
    /* 在rq里等待的时间计算出来的负载总和 */
    u64 runnable_sum;
    /* 在cpu上执行的时间计算出来的负载总和 */
    u32 util_sum;
    /* 表示最近一次更新负载时，最近一个周期内的那部分时间，
     * 即accumulate_sum()函数上注释中标记为d3的时间
     */
    u32 period_contrib;
    /* 以下字段是平均值，同样基于上述的几个维度分别计算 */
    unsigned long load_avg;
    unsigned long runnable_avg;
    unsigned long util_avg;
    struct util_est util_est;
} ____cacheline_aligned;
```

当内核开启 `CONFIG_SMP=y` 时，cfsrq 与 schedentity 中都会包含跟踪负载的字段：

```
/* file: include/linux/sched.h */
struct sched_entity {
#ifdef CONFIG_SMP
    struct sched_avg avg;
#endif
};

/* file: kernel/sched/sched.h */
struct cfs_rq {
#ifdef CONFIG_SMP
    /* CFS load tracking */
    struct sched_avg avg;

    struct {
        raw_spinlock_t lock ____cacheline_aligned;
        int nr;
        unsigned long load_avg;
        unsigned long util_avg;
        unsigned long runnable_avg;
    } removed;

#endif /* CONFIG_SMP */
};
```


# 2.7.3 负载追踪 - 计算负载

虽然负载的计算公式是一个无穷级数之和，但由于我们对历史负载并不感兴趣，因此计算起来还是很方便的，假设系统每个周期都会计算一次负载，那么当前负载的计算方式为 `L = L0 + L1*y`, 其中L1是上一个周期的负载，这样从第一个周期开始，每个周期在计算负载时都已经完成了对前面所有周期的负载累计。

然而问题是计算负载的时间点并没有这么精确，两次的时间差可能跨越了多个周期，如下图所示：

![](/files/hkSmd0h46SaG0OxmAT7F)

其中t1是上次系统计算负载的时间点，假设当时的负载为u, now是当前时间，每个周期为1024us, 那么根据Linux负载的计算公式，当前系统的负载为： `u' = u*y^p + d1*y^p + 1024 * (y^(p-1) + y^(p-2) + ... + y) + d3`, 我们将该公式分为两部分来分析：

1. `L2 = u*y^p` ，其中 `u+d1` 为一常数，系统需要找到高效的方式计算 `y^p`. 为了避免浮点数运算，系统将该指数运算等价于 `y^p=y^p *2^32 >> 32`, 即先乘以一个 232, 再将结果右移32位。为了提升效率，系统将 `y^p*2^32` 的值提前计算好了存放在数组 `runnable_avg_yN_inv` 中，其内容如下：

   ```
   /* file: kernel/sched/sched-pelt.h */
   static const u32 runnable_avg_yN_inv[] __maybe_unused = {
   0xffffffff, 0xfa83b2da, 0xf5257d14, 0xefe4b99a, 0xeac0c6e6, 0xe5b906e6,
   0xe0ccdeeb, 0xdbfbb796, 0xd744fcc9, 0xd2a81d91, 0xce248c14, 0xc9b9bd85,
   0xc5672a10, 0xc12c4cc9, 0xbd08a39e, 0xb8fbaf46, 0xb504f333, 0xb123f581,
   0xad583ee9, 0xa9a15ab4, 0xa5fed6a9, 0xa2704302, 0x9ef5325f, 0x9b8d39b9,
   0x9837f050, 0x94f4efa8, 0x91c3d373, 0x8ea4398a, 0x8b95c1e3, 0x88980e80,
   0x85aac367, 0x82cd8698,
   };
   ```

   该数组只有32个元素，因为 `y^32=0.5`, 所以当 p>32 时，我们可以将 `y^p*2^32` 转换为 `0.5*y^(p-32)*2^32` 进行计算。

   系统实现该部分计算的函数是 `decay_load`:

   ```
   /* file: kernel/sched/pelt.c */
   /* 计算 val*y^n, 即val衰减n个周期后的值 */
   static u64 decay_load(u64 val, u64 n)
   {
       unsigned int local_n;

       /* 规定经过32*63个周期后衰减为0 */
       if (unlikely(n > LOAD_AVG_PERIOD * 63))
           return 0;

       /* after bounds checking we can collapse to 32-bit */
       local_n = n;

       /* LOAD_AVG_PERIOD=32, 这里将结果val右移 n=local_n/32 位，就是乘以n个0.5, 也就是乘以n个y^32 */
       if (unlikely(local_n >= LOAD_AVG_PERIOD)) {
           val >>= local_n / LOAD_AVG_PERIOD;
           local_n %= LOAD_AVG_PERIOD;
       }

       /* 借助数组 runnable_avg_yN_inv 计算出最后的值 */
       val = mul_u64_u32_shr(val, runnable_avg_yN_inv[local_n], 32);
       return val;
   }
   ```
2. `L1 = d1*y^p + 1024 * (y^(p-1) + y^(p-2) + ... + y) + d3` 这里我们详细讨论 `1024 * (y^(p-1) + y^(p-2) + ... + y)` 这部分数列的计算方式，该部分的推导如下图所示：&#x20;

   等比数列的求和公式为： `S = a(1-q^n)/(1-y)`, 因此级数 yp 的和式为：(1-yn)/(1-y), 当n趋近于无穷时，结果为 1/(1-y), 我们知道 y32 = 0.5, 因此系统可以提前将 `1024/(1-y)` 的值计算出来，该值保存在宏 `LOAD_AVG_MAX` 中：

   ```
   /* file: kernel/sched/sched-pelt.h */
   #define LOAD_AVG_MAX 47742
   ```

   另外 `y^p * 1024/(1-y)` 就是将 `LOAD_AVG_MAX` 衰减p次，可以调用前文介绍过的函数 `decay_load` 来完成计算。因此整个数列的和为： `LOAD_AVG_MAX - decay_load(LOAD_AVG_MAX, periods) - 1024`.

   整个L1 的计算函数是 `__accumulate_pelt_segments`:

   ```
   /* file: kernel/sched/pelt.c */
   static u32 __accumulate_pelt_segments(u64 periods, u32 d1, u32 d3) {
       u32 c1, c2, c3 = d3; /* y^0 == 1 */

       /* c1 = d1 y^p */
       c1 = decay_load((u64)d1, periods);

       /*
        *            p-1
        * c2 = 1024 \Sum y^n
        *            n=1
        *
        *              inf        inf
        *    = 1024 ( \Sum y^n - \Sum y^n - y^0 )
        *              n=0        n=p
        *
        *                        inf
        * LOAD_AVG_MAX = 1024 * \Sum y^n, 即等比数列求和，n趋近于无穷时的情况
        *                        n=0
        *
        * LOAD_AVG_MAX 通过文件 ~Documentation/scheduler/sched-pelt.c
        中的代码在编译时生成
       */
       c2 = LOAD_AVG_MAX - decay_load(LOAD_AVG_MAX, periods) - 1024;

       return c1 + c2 + c3;
   }
   ```

   其中c1 = d1\*yp, c3 = d3, 而参数 `periods` 就是上图中的 `p=delta/1024`.

理解了上面的逻辑之后，我们再来看累计负载的函数 `accumulate_sum`:

```
/* file: kernel/sched/pelt.c */
static __always_inline u32 accumulate_sum(u64 delta, struct sched_avg *sa,
                                          unsigned long load,
                                          unsigned long runnable, int running) {
    u32 contrib = (u32)delta; /* p == 0 -> delta < 1024 */
    u64 periods;

    /* 为什么需要加上sa->period_contrib?
     * 因为在计算delta时，使用的是now - sa->last_update_time,
     *
     而sa->last_update_time记录的是上次更新负载的时间点。sa->period_contrib表示上次更新负载时，当前period中的那部分时间，即这次更新时，该时间会是上图中的d3.因此这里将其加到delta上去，以方便后续计算periods与当前的d3
     * */
    delta += sa->period_contrib;
    periods = delta / 1024; /* A period is 1024us (~1ms) */

    /*
     * Step 1: decay old *_sum if we crossed period boundaries.
     */
    if (periods) {
        /* 根据 periods
           对之前累计的负载进行衰减，之前累计的负载既上一次更新负载的那个时间点记录下来的负载，距当前可能已经过了很多个周期了
           * 一个周期就是 1ms, 这里将其换算成1024us, 以方便运算 */
        sa->load_sum = decay_load(sa->load_sum, periods);
        sa->runnable_sum = decay_load(sa->runnable_sum, periods);
        sa->util_sum = decay_load((u64)(sa->util_sum), periods);

        /*
         * Step 2
         *
         *
         delta此时为当前周期的时间量，即注释中标注的d3由于前面已经把前一次更新负载时当时的d3加到了delta中，因此这里计算本次的d3就很简单了
        */
        delta %= 1024;
        if (load) {
            contrib =
                __accumulate_pelt_segments(periods, 1024 - sa->period_contrib, delta);
        }
    }
    /* 此时的delta就是上图中的d3 */
    sa->period_contrib = delta;

    if (load)
        sa->load_sum += load * contrib;
    if (runnable)
        sa->runnable_sum += runnable * contrib << SCHED_CAPACITY_SHIFT;
    if (running)
        sa->util_sum += contrib << SCHED_CAPACITY_SHIFT;

    return periods;
}
```

该函数就是前面所讨论过的整个负载算法的实现。


# 2.7.4 负载追踪 - 更新负载

知道了负载及其计算原理之后，更新负载的逻辑就比较好理解了。系统通过函数 `update_load_avg` 来完成对调度实体与其cfsrq的负载更新，该函数的实现如下：

```
/* file: kernel/sched/fair.c */
static inline void update_load_avg(struct cfs_rq *cfs_rq,
                                   struct sched_entity *se, int flags) {
    /* 当前时间，精度为ns */
    u64 now = cfs_rq_clock_pelt(cfs_rq);
    int decayed;

    if (se->avg.last_update_time && !(flags & SKIP_AGE_LOAD))
        __update_load_avg_se(now, cfs_rq, se); /* 更新se的负载信息 */

    /* 其余代码删除 */
}
```

这里我们只关注更新 se 负载的逻辑，该部分逻辑通过 `__update_load_avg_se` 完成：

```
/* file: kernel/sched/pelt.c */
int __update_load_avg_se(u64 now, struct cfs_rq *cfs_rq,
                         struct sched_entity *se) {
    /* 先更新se 的负载总和 */
    if (___update_load_sum(now, &se->avg, !!se->on_rq, se_runnable(se),
                           cfs_rq->curr == se)) {
        /* 更新 se 的平均负载，平均负载的计算逻辑与负载总和与权重有关 */
        ___update_load_avg(&se->avg, se_weight(se));
        cfs_se_util_change(&se->avg);
        trace_pelt_se_tp(se);
        return 1;
    }

    return 0;
}
```

其中函数 `___update_load_sum` 用来更新负载的累计总和，而 `___update_load_avg` 会根据se的负载总和算出其平均负载，计算时会考虑se的负载总和与权重。前者的实现如下：

```
/* file: kernel/sched/pelt.c */
static __always_inline int ___update_load_sum(u64 now, struct sched_avg *sa,
                                              unsigned long load,
                                              unsigned long runnable,
                                              int running) {
    u64 delta;

    /* 距离上一次更新负载的时间差，单位是ns，该时间为墙上时间 */
    delta = now - sa->last_update_time;

    if ((s64)delta < 0) {
        sa->last_update_time = now;
        return 0;
    }

    /* delta的精度是ns, 所以右移10位变为us, 这里假设 1us = 1024ns 以简化计算 */
    delta >>= 10;
    if (!delta)
        return 0;

    /* 这里更新时间时，对delta的位移运算相当于抹掉了最近1024ns内的那部分时间，因为这部分时间就是记录在低十位数里面的。
     * 但末尾的ns尾数太小了可以忽略了，计算负载时精度考虑到 us 级别就行。
     * */
    sa->last_update_time += delta << 10;

    /* 调用函数accumulate_sum更新负载总和，该函数的实现上一节已经有详细分析 */
    if (!accumulate_sum(delta, sa, load, runnable, running))
        return 0;

    return 1;
}
```

该函数主要就是调用前面已经讨论过的 `accumulate_sum` 更新负载总和，并且记录一下更新的时间点。我们再看一下系统如何计算平均负载：

```
/* file: kernel/sched/pelt.c */
static __always_inline void ___update_load_avg(struct sched_avg *sa,
                                               unsigned long load) {
    u32 divider = get_pelt_divider(sa);

    /* 计算平均负载 */
    sa->load_avg = div_u64(load * sa->load_sum, divider);
    sa->runnable_avg = div_u64(sa->runnable_sum, divider);
    WRITE_ONCE(sa->util_avg, sa->util_sum / divider);
}

static inline u32 get_pelt_divider(struct sched_avg *avg) {
    /* 计算公式为：LOAD_AVG_MAX*y + sa->period_contrib,
     * LOAD_AVG_MAX就是1024(1 + y + y^2 + ... + y^n)当n趋近无穷时的值
     * 再将其乘以y就是再衰减一次，因为我们还需要加上sa->period_contrib
     * */
    return LOAD_AVG_MAX - 1024 + avg->period_contrib;
}
```

平均负载的计算公式为： `load_avg = weight * decay(runnable time) / decay(total time)`, 其中 `decay(runnable time)` 就是负载总和，而 `decay(total time)` 是按照计算负载总和的相同算法计算出来的所有时间的总和，其结果就是函数 `get_pelt_divider` 的返回值，上一节我们已经详细讨论过了如何求级数之和，这里不再详述。

通过该算法我们知道，平均负载是任务的权重与运行时间的综合考量，如果一个任务一直运行，那么其平均负载就会越来越趋近于它的权重。


# 2.8 负载均衡

本节将会探讨CFS的负载均衡，将会对其主要的数据结构、算法思路及代码实现做大致的分析。


# 2.8.1 简介

由于调度器的运行队列（runqueue）是per-cpu的，那么在一个多核系统中，就可能出现各CPU的负载不均衡的情况，如果一个CPU的队列被塞得满满当当，而另一个CPU的队列里空空如也，那肯定是不可取的。为了避免这种情况，调度器就还必须处理好多核之间的负载均衡。

通过[PELT](/linux-sched/load-trace), 系统对负载的跟踪可以精确到每个调度实体，这为调度器完成负载均衡提供了数据支持。如果我们只考虑最简单的模型，那么调度器只需要随时跟踪每个CPU队列的总负载，然后将高负载CPU队列中的任务酌情迁移一些到低负载的CPU队列中，尽量保持每个CPU队列的负载总量相等即可。

但任务的迁移是有代价的，这个代价一方面是选择目标CPU的时间开销，一方面是任务迁移带来的性能损耗。负载均衡的目的是提升系统整体的效率，但如果策略不当，有可能反而会对系统的性能产生影响。如何高效地达成这件事情，是系统设计者需要面临的挑战。在本节中，我们将从如下方面对CFS的负载均衡进行讨论：

* CPU的拓扑结构 \
  由于不同的CPU对缓存（L1, L2, L3）的共享级别不同，甚至对对内存的访问效率也不同（[NUMA](https://en.wikipedia.org/wiki/Non-uniform_memory_access)），因此会导致任务的迁移代价也不同。我们会简要介绍SMP, [NUMA](https://en.wikipedia.org/wiki/Non-uniform_memory_access)这两种拓扑结构。
* 核心数据结构 \
  内核如何根据CPU的物理拓扑结构在内核中对其进行组织管理，将会涉及到调度域（sched domain）与调度组（sched group）的概念。
* 负载均衡的核心逻辑 \
  涉及到负载均衡的核心数据结构与算法思路，包括主要的函数以及实现方式
* 负载均衡的时机 \
  不同场景下系统如何触发负载均衡


# 2.8.2 CPU的拓扑结构

CPU的拓扑结构可以理解为在硬件层面上多个CPU的排列方式, 对于运行的任务而言，拓扑结构决定了其从一个CPU迁移到另一个CPU将要付出的代价。在详细讨论拓扑结构之前，我们先介绍几个与CPU相关的概念：

* Socket: 对应主板上CPU插槽
* Core: 独立的一个执行单元，硬件上实现真正并行计算的组件，例如我们说一个CPU是“四核八线程”，其中每个核就对应一个Core
* Hyper-Threading: 逻辑上的一个执行单元，多个超线程共享一个核，例如“四核八线程”，就是每两个线程共享一个核。由于多个超线程共享一个物理执行单元，因此超线程并不是硬件层面上独立的并行计算单元，只是在OS级别的并发单元。

例如本人机器的CPU 型号是 Intel i5-8265U, 一个物理处理器，4核8线程，其拓扑结果如下：

![图一：Intel i5-8265U 拓扑结构](/files/Uh739Rki37HN0kmJb4fH)

> Linux 中通过命令 `lstopo` 即可查看CPU 的拓扑结构，通过命令 `hardinfo` 可以查看系统的各类硬件信息。

> 这里我们并没有提及CPU, CPU是最早出现的概念，早期与Socket是1:1的对应关系，但发展到现在基本已经是一个泛化和抽象的概念了，严格地说已经变成了一个日常用语。但在OS层面，CPU依然表示系统的一个执行单位，每个线程都会对应一个系统CPU. 例如上图中，系统会显示有8个CPU.
>
> Note: 系统CPU 的信息记录在 `/proc/cpuinfo` 中，可以通过 `cat` 命令查看。

通过该图可以发现：P#0与P#4就是两个超线程，并且共享Core L#0. 还可以看出同一个Core中的所有超线程共享所有的CPU缓存（L1, L2, L3），而不同的Core 只共享L3。

对多核CPU而言，如何访问主存（RAM）也是一个重要议题，主要涉及到两种拓扑结构：

* SMP(Symmetric Multiprocessing) 被译为[对称多处理](https://en.wikipedia.org/wiki/Symmetric_multiprocessing), 简单说就是每个CPU在访问主存时的代价都是一样的，主存作为全局的一个资源被所有CPU所共享。示意图如下：

![图二：SMP](/files/dNtsAC8L7Bgm9BUesblK)

* NUMA(Non-uniform Memory Access) 被译为[非均匀访存模型](https://en.wikipedia.org/wiki/Non-uniform_memory_access), 系统将CPU分为不同的集群，每个集群叫着一个节点（Node），每个节点都有自己的本地内存（Local RAM），CPU 可以访问任何节点的内存，但访问本地内存的速度要远高于访问非本地内存的速度。示意图如下：

![图三：NUMA](/files/VZlYD7x9UdGyrjmK6l51)

在图一中，所有的CPU 都处在一个NUMA节点中。

当CPU访问数据时，只有当所有的缓存（L1, L2, L3）都没有命中时才会访问主存（RAM），如果主存也没有数据则会发生缺页中断（page fault）并从磁盘加载数据。通过上面的介绍我们知道：从缓存到主存，不同的CPU能够共享的层级是不一样的。也就是说，当迁移任务时，选择不同的目标CPU 就意味着要付出不同的代价。通常而言我们认为：

1. 同一个超线程集（Hyperthreaded Set）中的CPU能够共享所有的缓存、主存，所以将任务在不同线程之间迁移可以认为没有性能损耗
2. SMP架构或者同一个NUMA节点内，不同的Core 可能只会共享部分缓存，但访问主存的时间都是一样的。因此如果对任务进行跨核迁移的话，新CPU的缓存将需要重新warm up
3. NUMA架构下，不同节点（Node）会有自己的本地主存（Local RAM），CPU访问其他节点的主存比访问本地主存需要更多时间。因此不到万不得已，调度器不会跨节点地进行任务迁移。

从同一个超线程集合，到NUMA中跨节点的CPU,我们可以认为CPU之间的“物理距离”在不断增加，这种“物理距离”形成了一种层级结构。根据上述分析可以知道，当调度器做任务迁移时，所选择的目标CPU越“近”则性能损耗就越小。因此内核需要某种方式来有效地组织CPU,这种组织方式能够根据“距离”将CPU分组，并形成合理的层级结构，使得调度器能够高效调整各个CPU的负载。


# 2.8.3 数据结构

内核引入了调度域（ `sched_domain` ）与调度组（ `sched_group` ）这两种数据结构来组织CPU, 调度域用来表示CPU物理拓扑结构中的层级关系，而调度组是负载均衡的基本单元。一个调度域包含多个调度组，系统做负载均衡时，首先需要保证的就是一个调度域中所有调度组的负载平衡，再考虑跨域的负载平衡。

#### 2.8.3.1. 调度域 - sched\_domain <a href="#orgf8d0726" id="orgf8d0726"></a>

`sched_domain` 是内核在2.6版时引入的数据结构，调度器使用调度域来组织管理CPU, 进而完成负载均衡。系统会根据CPU 的物理拓扑结构创建调度域，每个调度域都覆盖了一组CPU, 属于同一个调度域的所有CPU具有相同的调度策略与属性，所有的调度域最终形成一颗树形（Tree）结构，该结构的层级关系与CPU的物理拓扑结构相对应。例如前文提到的4核8线程的CPU，内核最终创建的调度域示意图如下：

![图一：调度域](/files/Rl4gKFa2AgzxT7CkepLB)

其中 `Domain 0` 表示 `Core 0` 的调度域，也是内核中最底层的调度域（被称为Base Domain），包含了 `Core 0` 的两个超线程（分别是CPU 0与CPU 4）；4 个Core 的调度域又构成了一个上层调度域（图中的Root Domain）。

> CPU的超线程分组可以通过命令 `lscpu -p` 进行查看。目录 `/sys/devices/system/cpu` 中包含了更详细的CPU信息。

上图的CPU结构相对比较简单，因此总共只形成了两级调度域，在更复杂的系统中调度域的层级将更加复杂。不过不管调度域有多少层级，隶属于同一个调度域内的CPU总是比同级但跨域的CPU更具“亲和性”，也就是说在同一个调度域中的CPU之间迁移任务比跨域迁移的代价更小。例如上图中，将任务从CPU 0迁移到CPU 4的代价就比迁移到CPU 1要小。

调度域的数据结构定义如下：

```
/* file: include/linux/sched/topology.h */

struct sched_domain {
  /* These fields must be setup */
  /* 父节点 */
  struct sched_domain __rcu *parent; /* top domain must be null terminated */
  /* 子节点 */
  struct sched_domain __rcu *child; /* bottom domain must be null terminated */
  /* 本调度域中的调度组，各个调度组形成一个链表，下一节将对调度组做详细介绍 */
  struct sched_group *groups; /* the balancing groups of the domain */
  /* 检查负载均衡的最小时间间隔，过于频繁的检查会造成系统产生额外开销 */
  unsigned long min_interval; /* Minimum balance interval ms */
  /* 检查负载均衡的最大时间间隔，太长时间不检查容易导致负载偏差太大 */
  unsigned long max_interval; /* Maximum balance interval ms */
  /* 反映CPU繁忙程度的参数，系统会根据运行情况动态调整负载均衡的时间间隔，该间隔时间记录在字段balance_interval中
     但如果CPU很繁忙，那么时间间隔就适当延长一些，即busy_factor*balance_interval
   */
  unsigned int busy_factor; /* less balancing by factor if busy */
  /* 调度域内的不均衡状态达到了一定的程度之后就开始进行负载均衡的操作。
     imbalance_pct这个成员定义了判定不均衡的阈值 */
  unsigned int imbalance_pct;    /* No balance until over watermark */
  unsigned int cache_nice_tries; /* Leave cache hot tasks for # tries */

  int nohz_idle; /* NOHZ IDLE status */
  int flags;     /* See SD_* */
  /* 该调度域在整个调度域层级结构中的level。Base调度域的level等于0，向上依次加一。
     可以理解为调度域在树中的高度
   */
  int level;

  /* Runtime fields. */
  /* 上次做负载均衡的时间点 */
  unsigned long last_balance; /* init to jiffies. units in jiffies */
  /* 该字段定义了均衡的时间间隔，会随着系统的运行而变化 */
  unsigned int balance_interval;  /* initialise to 1. units in ms. */
  unsigned int nr_balance_failed; /* initialise to 0 */

  /* idle_balance() stats */
  u64 max_newidle_lb_cost;
  unsigned long next_decay_max_lb_cost;

  u64 avg_scan_cost; /* select_idle_sibling */

  union {
    void *private;       /* used during construction */
    struct rcu_head rcu; /* used during destruction */
  };
  struct sched_domain_shared *shared;

  unsigned int span_weight;
  /*
   * A sched domain’s span means “balance process load among these CPUs”.
   *
   该字段用来描述当前sd覆盖了哪些CPU,父调度域的span应该是其所有子调度域的超集。
   * */
  unsigned long span[];
};
```

这里删除了一些不太重要的字段，包括调度域的名字及统计信息。总体来说，结构体 `sched_domain` 中包含了如下几类信息：

* 目标CPU, 通过span字段指定
* 负载均衡的配置信息，例如间隔时间区间，这类信息属于静态信息
* 负载均衡的运行时信息，例如负载均衡的实际间隔时间，统计字段等，这类信息属于动态信息

前面我们提到，负载均衡首先发生在单个调度域内，然后再考虑是否需要跨域。为了更好的达成这个目的，内核还引入了另一个概念来辅助完成这个任务，这就是调度组（sched\_group）。

#### 2.8.3.2. 调度组 - sched\_group <a href="#orgf791110" id="orgf791110"></a>

通过前面的描述，可以发现调度域实际上是为负载均衡划定了一个界限，该界限本质上就是目标CPU的集合，也就是 `sched_domain` 中的 span 字段。然而当调度器在单个调度域内做负载均衡时，CPU并不是调度器的工作对象，内核引入了调度组（`sched_group`）来辅助该操作，调度组才是负载均衡的基本单位。

调度域包含了一个或多个调度组，字段 `struct sched_group *groups;` 指向的便是该调度域包含的调度组，多个调度组构成一个单向链表。在一颗调度域构成的树中，父调度域的每个子调度域都是一个调度组，而Base Domain中，一个CPU会对应一个调度组. 下图是前文4x8处理器附上调度组后的示意图：

![图二：调度域与调度组](/files/6HkmP5uRqF9jf2JKk3ZK)

调度域内的负载均衡发生在调度组之间，例如上图中的 Root Domain 包含了4个调度组，那么只有当这些组之间的负载偏差超过一定阈值时，负载均衡才会发生，调度器会根据算法将任务从负载高的调度组向负载低的调度组迁移。一个调度组的负载是改组内所有CPU的负载之和。

调度组的结构体定义如下：

```
/* file: kernel/sched/sched.h */
struct sched_group {
  /* 指向下一个调度组，形成单向链表 */
  struct sched_group *next; /* Must be a circular list */
  /* 该调度组的引用计数 */
  atomic_t ref;

  unsigned int group_weight;
  /* 该调度组的算力信息 */
  struct sched_group_capacity *sgc;
  int asym_prefer_cpu; /* CPU of highest priority in group */

  /* 该调度组包含的CPU */
  unsigned long cpumask[];
};
```

当调度器做负载均衡时，除了考虑调度组的负载，还需要考虑算力，如果一个调度组的负载很低，但其本身也没什么剩余算力，那么向其迁移任务也是不合理的。算力就是组内所有CPU能够提供的计算能力的总和，每个调度组的算力保存在字段 `sched_group_capacity` 中。造成调度组算力不同的原因有很多，例如不同CPU设置的最高主频不同、大小核的区分等都会直接影响CPU的算力，另外我们这里讨论的是CFS的负载均衡，但CPU还会用来调度DL、RT这些任务，这也会消耗掉一部分算力。因此系统会充分考虑这些因素，然后计算出调度组的实际算力。


# 2.8.4 算法思路

我们先来看调度器如何在调度域内部做负载均衡，这也是整个负载均衡逻辑的核心。完成该任务的函数是 `load_balance`, 其核心代码如下：

```
/* file: kernel/sched/fair.c */

static int load_balance(int this_cpu, struct rq *this_rq,
                        struct sched_domain *sd, enum cpu_idle_type idle,
                        int *continue_balancing) {

  int ld_moved, cur_ld_moved, active_balance = 0;
  struct sched_group *group;
  struct rq *busiest;
  struct cpumask *cpus = this_cpu_cpumask_var_ptr(load_balance_mask);

  /* lb_env 用来封装负载均衡的上下文信息 */
  struct lb_env env = {
      .sd = sd,
      .dst_cpu = this_cpu,
      .dst_rq = this_rq,
      .dst_grpmask = sched_group_span(sd->groups),
      .idle = idle,
      .loop_break = sched_nr_migrate_break,
      .cpus = cpus,
      .fbq_type = all,
      .tasks = LIST_HEAD_INIT(env.tasks),
  };

  /* 1.
   * 判断是否需要在当前调度域的调度组之间做负载均衡
   *
   判断的逻辑是当前CPU(即第一个参数：this_cpu)是否空闲并需要从其它地方拉一点任务过来
   */
  if (!should_we_balance(&env)) {
    /* 如果不需要做负载均衡，则退出并将continue_balancing置0,
     * 该变量在上层函数中会用到，用以判断要不要继续遍历上层调度域 */
    , *continue_balancing = 0;
    goto out_balanced;
  }

  /* 找到当前调度域中最繁忙的调度组，主要思路是对比各个调度组的负载、算力以及CPU利用率，以此来确定哪个调度组最繁忙
   */
  group = find_busiest_group(&env);
  if (!group) {
    goto out_balanced;
  }

  /* 从最繁忙的调度组中找出最繁忙的运行队列，从该队列中拉取任务到当前CPU以平衡负载
   */
  busiest = find_busiest_queue(&env, group);
  if (!busiest) {
    goto out_balanced;
  }

  /* 设置负载均衡的源CPU及源队列 */
  env.src_cpu = busiest->cpu;
  env.src_rq = busiest;

  /* 将从源队列拉取任务 */
  cur_ld_moved = detach_tasks(&env);

  if (cur_ld_moved) {
    /* 将拉取出来的任务放置到目标队列上，目标队列即当前CPU的运行队列 */
    attach_tasks(&env);
    ld_moved += cur_ld_moved;
  }

  /* 删除了大量的代码 */
}
```

这里我们删除了绝大多数处理细节的代码，仅保留了最核心的逻辑，调度器做负载均衡的目的是为了提高CPU的总体利用率，因此在迁移任务时需要考虑各种因素，避免引入额外的性能开销。上面代码中的每一步都包含了非常多的细节，我们在这里最关心总体逻辑，不再一一深入。例如函数 `detach_tasks` 在选取目标任务时，就会判断任务是否可以被迁移到其它CPU, 一个场景是如果任务在其当前的CPU上的缓存还是热（cache-hot）的，那么将其迁移到其它CPU便会带来额外的性能开销，这就违背了做负载均衡的最初动机，因此调度器就不会对这类任务做迁移。

了解了单个调度域内的负载均衡之后，我们再来看一下整个系统层面的负载均衡过程。系统触发负载均衡的时机我们会在下一节讨论，这里我们仅通过函数 `rebalance_domains` 的实现了解总体逻辑：

```
/* file: kernel/sched/fair.c */

static void rebalance_domains(struct rq *rq, enum cpu_idle_type idle) {
  int continue_balancing = 1;
  int cpu = rq->cpu;
  int busy = idle != CPU_IDLE && !sched_idle_cpu(cpu);
  unsigned long interval;
  struct sched_domain *sd;

  for_each_domain(cpu, sd) {
    /* 调用函数load_balance时会将该变量指针传递进去，如果没有必要继续往上遍历调度域，则会将该变量置0
     */
    if (!continue_balancing) {
      break;
    }

    /* 计算出当前调度域负载均衡的时间间隔，这里会考虑CPU是否繁忙以及调度域的时间间隔区间
     */
    interval = get_sd_balance_interval(sd, busy);

    /* 如果当前时间已经超过负载均衡的时间间隔，则触发负载均衡操作 */
    if (time_after_eq(jiffies, sd->last_balance + interval)) {
      /* 调用 load_balance 对调度域 sd 进行负载均衡 */
      if (load_balance(cpu, rq, sd, idle, &continue_balancing)) {
        idle = idle_cpu(cpu) ? CPU_IDLE : CPU_NOT_IDLE;
        busy = idle != CPU_IDLE && !sched_idle_cpu(cpu);
      }
      /* 设置本次负载均衡的时间，也就是最近一次负载均衡的时间 */
      sd->last_balance = jiffies;
      /* 由于进行了负载均衡，动态计算时间间隔的各种因子可能发生了变化，因此这里重新计算一次
       */
      interval = get_sd_balance_interval(sd, busy);
    }
  }
}
```

这里我们也删除了很多处理细节的代码，主要是为了排除干扰，呈现出最根本的处理逻辑。主题逻辑包含在一个迭代中，该迭代通过宏 `for_each_domain` 来对当前CPU的调度域进行遍历，其定义如下：

```
/* file: kernel/sched/fair.c */
#define for_each_domain(cpu, __sd)                                             \
  for (__sd = rcu_dereference_check_sched_domain(cpu_rq(cpu)->sd); __sd;       \
       __sd = __sd->parent)
```

`cpu_rq(cpu)->sd` 拿到的就是CPU最底层的调度域，即前文提到的Base Domain, 该宏自底向上逐级遍历指定CPU的调度域，按照这个顺序依次对各级的调度域做负载均衡，并且在合适的时候退出循环，越上层的调度域触发负载均衡的频率越低。


# 2.8.5 触发时机

系统触发负载均衡的场景各有不同，本节将分析三个典型的触发场景。

#### 2.8.5.1. 周期性负载均衡 <a href="#orgf8efb49" id="orgf8efb49"></a>

系统会随着CPU的时钟节拍周期性地触发负载均衡，入口函数如下：

```
/* file: kernel/sched/core.c */
void scheduler_tick(void)
{
    int cpu = smp_processor_id();
    struct rq *rq = cpu_rq(cpu);

    /* 删除其他代码 */

#ifdef CONFIG_SMP
    rq->idle_balance = idle_cpu(cpu);
    trigger_load_balance(rq);
#endif
}
```

该函数在我们讨论CFS的调度节拍时曾提到过，这里仅保留触发负载均衡的代码：当系统开启 `CONFIG_SMP` 时，CPU会在每次时钟中断时尝试做负载均衡。函数 `trigger_load_balance` 的实现如下：

```
/* file: kernel/sched/fair.c */

void trigger_load_balance(struct rq *rq)
{
    /*
     * Don't need to rebalance while attached to NULL domain or
     * runqueue CPU is not active
     */
    if (unlikely(on_null_domain(rq) || !cpu_active(cpu_of(rq))))
        return;

    /* 检查负载均衡的时间，如果当前时间已经过了可以进行下次负载均衡的时间点，那么就产生 SCHED_SOFTIRQ 软中断，
     * 该中软信号的处理函数在函数 init_sched_fair_class 中注册，为 run_rebalance_domains */
    if (time_after_eq(jiffies, rq->next_balance))
        raise_softirq(SCHED_SOFTIRQ);

    /* 触发 nohz 负载均衡 */
    nohz_balancer_kick(rq);
}
```

这里我们只关心语句 `raise_softirq(SCHED_SOFTIRQ)`, 函数在这里对负载均衡的时间点进行检查，如果当前时间（jiffies）已经超过了预设好的下一次负载均衡的时间点，那么系统就产生一个 `SCHED_SOFTIRQ` 软中断。该中断信号的处理程序在初始化函数 `init_sched_fair_class` 中进行注册：

```
/* file: kernel/sched/fair.c */
__init void init_sched_fair_class(void)
{
#ifdef CONFIG_SMP
    open_softirq(SCHED_SOFTIRQ, run_rebalance_domains);
#endif /* SMP */
}
```

该中断处理函数是 `run_rebalance_domains`, 该函数的主要职责是调用合适的负载均衡函数：

```
/* file: kernel/sched/fair.c */

static __latent_entropy void run_rebalance_domains(struct softirq_action *h)
{
    struct rq *this_rq = this_rq();
    enum cpu_idle_type idle =
        this_rq->idle_balance ? CPU_IDLE : CPU_NOT_IDLE;

    /*
     * If this CPU has a pending nohz_balance_kick, then do the
     * balancing on behalf of the other idle CPUs whose ticks are
     * stopped. Do nohz_idle_balance *before* rebalance_domains to
     * give the idle CPUs a chance to load balance. Else we may
     * load balance only within the local sched_domain hierarchy
     * and abort nohz_idle_balance altogether if we pull some load.
     */
    if (nohz_idle_balance(this_rq, idle))
        return;

    /* normal load balance */
    update_blocked_averages(this_rq->cpu);
    rebalance_domains(this_rq, idle);
}
```

`nohz_idle_balance` 会在本节后面介绍，而 `rebalance_domains` 函数就是上一节介绍过的负载均衡函数。

#### 2.8.5.2. NOHZ负载均衡 <a href="#orgadaf5bd" id="orgadaf5bd"></a>

周期性负载均衡由CPU的时钟节拍来驱动，实际上系统很多周期性的工作都是由CPU的时钟节拍来驱动的。但如果CPU此时无事可做（rq为空）的话就会进入idle状态，并最终进入节能模式，此时的时钟中断会定期将CPU从节能模式唤醒，然后CPU发现自己仍然无事可做，最终再次进入节能模式。可见当CPU处于 idle 状态时，定时响应的时钟节拍此时不仅没有帮助，反而形成了干扰和能源浪费。如果有某种机制让CPU在 idle 状态下关掉定时时钟就好了。

NOHZ就是这种功能，内核通过 `CONFIG_NO_HZ_COMMON` 来控制是否启动该动能，如果启动的话当CPU在进入 idle 状态就会关闭时钟节拍。

NOHZ功能对CPU的能耗有积极的作用，但对负载均衡而言却不友好，前面我们看到负载均衡的工作机制由每个CPU的时钟节拍来驱动，并且总是尝试着从其它CPU的队列中拉取任务到本地队列，那么对于关闭了时钟节拍的 idle 状态的CPU而言，这个过程如何触发呢？调度器将涉及 idle CPU 的负载均衡逻辑叫着NOHZ负载均衡，其总体的工作方式如下：

1. 初始化CPU的IPI处理函数 \
   既然CPU进入idle状态后会关闭时钟节拍，那么当某个CPU 繁忙而此时有CPU 处在 idle 状态时，我们就需要一种机制来唤醒 idle CPU, 这种机制是 IPI.<br>

   > IPI 全称 Inter-Processor Interrupt, 是处理器之间的中断机制，不同的CPU可以通过这种机制通知对方有事件发生。关于IPI的详细介绍可以参考：<https://en.wikipedia.org/wiki/Inter-processor_interrupt>

   \
   调度器初始化时会初始化 nohz 的处理函数，代码如下：

   ```
       /* file: kernel/sched/core.c */

       void __init sched_init(void)
       {
           /* 当系统开启 nohz 时，通过如下代码对对应的字段进行初始化 */
       #ifdef CONFIG_NO_HZ_COMMON
           rq->last_blocked_load_update_tick = jiffies;
           atomic_set(&rq->nohz_flags, 0);

           /* 初始化 NOHZ 负载均衡的 IPI 处理函数，该函数会在函数 kick_ilb 中被调用 */
           INIT_CSD(&rq->nohz_csd, nohz_csd_func, rq);
       #endif
       }
   ```

   函数 `nohz_csd_func` 的内容为：

   ```
       /* file: kernel/sched/core.c */

       static void nohz_csd_func(void *info)
       {
           struct rq *rq = info;
           int cpu = cpu_of(rq);
           unsigned int flags;

           /*
           * Release the rq::nohz_csd.
           */
           flags = atomic_fetch_andnot(NOHZ_KICK_MASK, nohz_flags(cpu));
           WARN_ON(!(flags & NOHZ_KICK_MASK));

           rq->idle_balance = idle_cpu(cpu);
           if (rq->idle_balance && !need_resched()) {
               rq->nohz_idle_balance = flags;
               /* 触发 SCHED_SOFTIRQ 软中断，进行负载均衡处理 */
               raise_softirq_irqoff(SCHED_SOFTIRQ);
           }
       }
   ```

   如果需要的话，函数最后会产生 `SCHED_SOFTIRQ` 软中断，触发CPU进行负载均衡操作。产生IPI中断的逻辑在函数 `kick_ilb` 中，这在后面会有介绍。
2. 繁忙的CPU发起请求，通过IPI唤醒 idle CPU 该逻辑入口函数是 `nohz_balancer_kick`, 在函数 `trigger_load_balance` 中被繁忙的CPU随着时钟节拍周期性地调用：

   ```
       /* file: kernel/sched/fair.c */

       void trigger_load_balance(struct rq *rq)
       {
           /*
           * Don't need to rebalance while attached to NULL domain or
           * runqueue CPU is not active
           */
           if (unlikely(on_null_domain(rq) || !cpu_active(cpu_of(rq))))
               return;

           /* 检查负载均衡的时间，如果当前时间已经过了可以进行下次负载均衡的时间点，那么就产生 SCHED_SOFTIRQ 软中断，
           * 该中软信号的处理函数在函数 init_sched_fair_class 中注册，为 run_rebalance_domains */
           if (time_after_eq(jiffies, rq->next_balance))
               raise_softirq(SCHED_SOFTIRQ);

           /* 触发 nohz 负载均衡逻辑 */
           nohz_balancer_kick(rq);
       }
   ```

   函数 `nohz_balance_kick` 首先根据自己队列的任务情况判断是否需要唤醒其他 idle CPU 来为自己分担压力，如果是的话就通过 `IPI` 对 idle CPU 进行唤醒，被唤醒的CPU会从其它繁忙的CPU拉取任务。内核此处将繁忙的CPU称为 kicker, 目标 idle CPU 称为 kickee, 可以直观地理解成繁忙的CPU将在睡觉的 idle CPU 踢起来干活了。

   函数 `nohz_balance_kick` 由繁忙的CPU执行，主要逻辑是检查自己队列（rq）的情况，如果有必要的话则最终调用函数 `kick_ilb` 来唤醒 idle CPU. 我们可以简单看一下函数 `kick_ilb` 的代码：

   ```
       /* file: kernel/sched/fair.c */

       /*
       * Kick a CPU to do the nohz balancing, if it is time for it. We pick any
       * idle CPU in the HK_FLAG_MISC housekeeping set (if there is one).
       */
       static void kick_ilb(unsigned int flags)
       {
           int ilb_cpu;

           /* 找到 idle CPU */
           ilb_cpu = find_new_ilb();

           /* 通过 IPI 通知目标的 idle CPU */
           smp_call_function_single_async(ilb_cpu, &cpu_rq(ilb_cpu)->nohz_csd);
       }
   ```

   函数 `kick_ilb` 最终会触发对应CPU队列中的 `nohz_csd` 函数被异步调用，完成唤醒操作。
3. 被唤醒的 idle CPU 通过函数 `nohz_idle_balance` 完成负载均衡 通过前两步我们知道，NOHZ 负载均衡最终也是将 idle CPU 唤醒并进入 `SCHED_SOFTIRQ` 软中断的处理函数，入口函数与前一节所分析的周期性负载均衡一样，都是 `run_rebalance_domains`, 我们再看一下该函数的代码：

   ```
       /* file: kernel/sched/fair.c */

       static __latent_entropy void run_rebalance_domains(struct softirq_action *h)
       {
           struct rq *this_rq = this_rq();
           enum cpu_idle_type idle =
               this_rq->idle_balance ? CPU_IDLE : CPU_NOT_IDLE;

           /* 被唤醒的 idle CPU 会通过该函数完成负载均衡，然后整个函数直接返回 */
           if (nohz_idle_balance(this_rq, idle))
               return;

           /* normal load balance */
           update_blocked_averages(this_rq->cpu);
           rebalance_domains(this_rq, idle);
       }
   ```

   如果是被唤醒的 idle CPU, 则会通过函数 `nohz_idle_balance` 完成负载均衡，该函数会对所有的 idle CPU 进行负载均衡，负载均衡的逻辑与上一节讲的大致一样，这里不再展开。

#### 5.3. newidle balance <a href="#org0a44bd6" id="org0a44bd6"></a>

在CPU进入 idle 之前也可以主动发起负载均衡，尝试着从其它 CPU 拉取一些任务过来执行，如果拉取不到再进入 idle 状态也不迟。该逻辑的入口函数就是 `newidle_balance`, 该函数的主体逻辑为：

```
/* file: kernel/sched/fair.c */

/*
 * newidle_balance is called by schedule() if this_cpu is about to become
 * idle. Attempts to pull tasks from other CPUs.
 *
 * Returns:
 *   < 0 - we released the lock and there are !fair tasks present
 *     0 - failed, no new tasks
 *   > 0 - success, new (fair) tasks present
 */
static int newidle_balance(struct rq *this_rq, struct rq_flags *rf)
{
    int this_cpu = this_rq->cpu;
    struct sched_domain *sd;
    int pulled_task = 0;
    u64 curr_cost = 0;

    for_each_domain(this_cpu, sd)
    {
        int continue_balancing = 1;
        u64 t0, domain_cost;

        if (this_rq->avg_idle < curr_cost + sd->max_newidle_lb_cost) {
            update_next_balance(sd, &next_balance);
            break;
        }

        if (sd->flags & SD_BALANCE_NEWIDLE) {
            t0 = sched_clock_cpu(this_cpu);

            pulled_task = load_balance(this_cpu, this_rq, sd,
                                       CPU_NEWLY_IDLE,
                                       &continue_balancing);

            domain_cost = sched_clock_cpu(this_cpu) - t0;
            if (domain_cost > sd->max_newidle_lb_cost)
                sd->max_newidle_lb_cost = domain_cost;

            curr_cost += domain_cost;
        }

        update_next_balance(sd, &next_balance);

        /*
         * Stop searching for tasks to pull if there are
         * now runnable tasks on this rq.
         */
        if (pulled_task || this_rq->nr_running > 0)
            break;
    }
}
```

这里我们仅保留了核心逻辑，可以看出函数也是对CPU的调度域进行自底向上的遍历，然后依次对各级调度域做负载均衡，这与之前介绍的函数 `rebalance_domains` 思路相似。

一个调用 `newidle_balance` 的典型例子是调度器在选择下一个任务时，如果此时CPU的队列为空，则调度器便会直接调用 `newidle_balance` 从其它CPU 拉取任务，代码如下：

```
/* file: kernel/sched/fair.c */

struct task_struct *pick_next_task_fair(struct rq *rq, struct task_struct *prev,
                                        struct rq_flags *rf)
{
    struct cfs_rq *cfs_rq = &rq->cfs;
    struct sched_entity *se;
    struct task_struct *p;
    int new_tasks;

again:
    if (!sched_fair_runnable(rq))
        goto idle;

    /* 删除主要代码 */

idle:
    if (!rf)
        return NULL;

    /* 触发负载均衡，从其它CPU拉取任务 */
    new_tasks = newidle_balance(rq, rf);

    /*
     * Because newidle_balance() releases (and re-acquires) rq->lock, it is
     * possible for any higher priority task to appear. In that case we
     * must re-start the pick_next_entity() loop.
     */
    if (new_tasks < 0)
        return RETRY_TASK;

    /* 如果成功地拉取到了任务，则尝试重新选择任务来执行 */
    if (new_tasks > 0)
        goto again;

    return NULL;
}
```


# 2.8.6 总结

负载均衡是充分利用多核CPU的重要算法，本文只是粗略地介绍了算法的总体思路，意在为读者搭建一个大致框架，还有很多内容并没有提及，例如：

* 任务放置 创建新任务、唤醒新任务时如何根据各个CPU的负载情况选择合适的目标CPU来执行任务
* EAS(Energy Aware Scheduling) 系统会充分考虑CPU 能耗来做进一步的决策，详细内容可以参考：<https://docs.kernel.org/scheduler/sched-energy.html>
* 统计信息

具体实现中还涉及到很多细节，例如如何计算算力、如何最快地选择目标CPU等等，读者如果还想深入，可以参考资源列表及内核源码。

**参考资料：**

* <https://www.kernel.org/doc/html/latest/scheduler/sched-domains.html>
* <https://www.kernel.org/doc/html/latest/scheduler/sched-capacity.html>
* <https://lwn.net/Articles/80911/>
* <https://cdmana.com/2021/05/20210513160921291a.html>
* <http://www.wowotech.net/process\\_management/load\\_balance\\_detail.html>
* <http://tjpm.blog.chinaunix.net/uid-23141914-id-5767451.html>
* <https://pwl999.github.io/2017/12/16/linux\\_scheduler/>


# 3.1 寻址模式


# 3.1.1 地址

#### 1. 物理地址 - Physical Address <a href="#orga2f14b2" id="orga2f14b2"></a>

我们可以将内存想象成一排连续的存储坑位，这些坑位的最小寻址单位是字节（Byte），每个字节的编号就是该字节的地址（从0开始），CPU通过地址来定位并访问每个字节，我们将该地址称为内存的物理地址（Physical Address），因为他代表着目标字节的实际物理位置。CPU与内存通过总线（Bus）连接，总线分为三类：

1. 地址总线
2. 数据总线
3. 控制总线

总体结构如下：

![](/files/PX3sVDrWak3ogLu8x50x)

所谓总线，就是主板（Motherboard）上用于不同硬件单元通信的数据通道，可以简单理解成有很多引脚的一条链路，电子信号通过引脚进行传播，总线的带宽用位数（Bit）来表示，总线位数限制了一次能够传输的信号总量，这是非常重要的硬件指标。地址总线的位数限定了CPU的寻址空间，例如一个CPU的地址总线是20位，那么其最大的寻址范围就是 2^20 = 1M, 超过该范围的物理地址就访问不到了。

每当CPU需要从内存存取数据时，就将目标字节的地址输入地址总线，将控制信号（例如读取还是写入）输入控制总线，然后在数据总线读取或写入数据即可。这就是最简化的CPU与内存的交互方式。

内存地址的使用者是程序，如果在程序中直接使用内存的物理地址的话，会有什么效果呢？我们分别看一下这种寻址方式的优缺点：

Pros

* 思路简单，地址不需要转换
* 硬件设计简单，执行效率高

Cons

* 不够灵活
* 程序直接与内存的硬件概念联系在一起，耦合性太强，这会对程序或者硬件升级带来不变
* 缺乏安全性，程序可以访问任何物理地址，但不同程序所使用的内存很明显应该隔离开来
* 对多任务的管理复杂度上升

这种寻址方式叫着 **Real Mode Flat Model**, Real Mode 可以理解为逻辑地址就代表着真实的物理地址，而 Flat Model 代表着这种直接的映射关系。

> 寻址方式就是如何将程序中使用的地址转换成内存的物理地址

Intel 在 1974 年发布的8080处理器采用的便是这种寻址模式。8080 是一个8位处理器，地址总线为16 位，意味着其寻址空间是64KB.

> 注意：处理器位数与总线位数不一定是相同的。虽然 8080 是 8 位处理器，但其用于存放地址的寄存器 SP(Stack Pointer) 与 PC(Program Counter) 都是16位的，另外程序也可以将两个8位寄存器合并起来当着一个16位寄存器使用。8080 的寄存器架构请见[这里](https://en.wikipedia.org/wiki/Intel_8080)

使用 8080 芯片最多的操作系统是 CP/M-80, 该操作系统的一个特点是操作系统代码存在于内存顶端，这样做的目的是为了将运行程序加载到统一的位置，CP/M-80 将应用程序加载到内存底部的 0100 处，即底部256字节处（前256 字节用着该程序的IO缓存，同时也用于存放一些其他信息）。这种内存模型简单直接，但缺乏灵活性，因为操作系统与应用程序所在的内存地址都是固定的，对于后期升级非常不友好。

至此，从物理地址的视角来看，内存是一个以字节为单位、连续、一维的存储单元，地址是不同字节的唯一标识。

#### 2. 逻辑地址 - Logic Address <a href="#org53cb174" id="org53cb174"></a>

为了能够使用更多的内存地址，CPU的地址总线总是越大越好，然而CPU在内部使用寄存器（Register）来存放各种信息，包括地址信息也存放在寄存器中，寄存器也有限定的位数，如果寄存器的位数小于数据总线的位数的话，那如何存放完整的地址信息呢？

> 寄存器位数就是我们常说的CPU位数，是指CPU能一次同时存储和处理的二进制信息的位数，通常与数据总线的位数相当。

一个简单的思路是将多个寄存器合并起来使用，形成逻辑上更大的存储单元。例如如果一个16 位的处理器拥有20 位的地址总线，那么我们可以使用一个寄存器存放地址的高4位，使用另一个存放低16位，每次寻址时将二者合并起来即可。

1976年，Intel发布了16位处理器8086，拉开了x86架构的序幕。该处理器的地址总线为20位，意味着寻址空间增大到了为1M，是8080的16倍。8086此时就面临寄存器位数小于地指总线的问题，但除此之外，8086还面临另一个问题：如何让8080的程序顺利完成迁移。即之前为8080 写的各种程序如何顺利跑在新处理器上。

让程序从8080迁移到8086的关键是让8080的16位寻址策略依然有效，即程序可以将1M内存区块中的某个64KB当着独立的内存空间来使用。

8086通过分段寻址（Segmented Addressing）的方式来支持这种策略，段（Segment）是一个连续的内存区块，不同的段通过不同的起始位置来进行区分。内存地址也由此划分成了两部分：一是段的起始位置，二是段内偏移量。因此两个寄存器一个用来存放段基址（Base Address），另一个用来存放偏移量（Offset），内存地址的表示形式为： `SR:IR` ，其中 SR 表示段寄存器，而 IR 表示偏移量寄存器。由于寄存器是16位的，所以每个段内最大的偏移量为64KB, 这样一个段就刚好与8080的寻址空间大小一致。

那么如何对段进行划分呢？8086 规定每个能被16整除的地址位置都可以作为段的起始位置，这样的话 1M 的内存空间最多可以有 1M / 16 = 64K 个段，段寄存器存放的实际上是从 0 开始的段标号，因此对于地址 `SR:IR`, 将其转换为 20 位地址的算法为： `SR * 16 + IR`, 这种转换通过硬件实现也是很容易的。

8086 引入了4 个段寄存器，分别是：

* CS: Code Segment
* DS: Data Segment
* SS: Stack Segment
* ES: Extra Segment

同时还引入了索引寄存器（Index Register）用来存放偏移量，例如：

* IP: Instruction Pointer 指向下一条CPU 将要执行的指令地址，通常与CS 一起使用，即 CS:IP 代表着下一条指令地址。
* SP: Stack Pointer 指向当前栈顶，通常与 SS 一起使用

这种寻址方式不仅能够使用 16 位寄存器完成 20 位地址空间的寻址，还完美地解决了8080 程序迁移的问题（任何 8080 的程序只需要使用一个段就够了），但却限制了新程序使用内存的方式。对于在 8086 上新写的程序而言，虽然理论上其可以使用完整的 1M 内存，但其寻址策略逼迫程序只能以分段的方式来使用内存，并且每个段最大只能是64KB, 如果程序的代码区块超过了64KB, 那么就必须使用多个代码段（Code Segment），OS 就必须管理好执行程序时 CS 在不同段之间来回跳转的逻辑。

这种模式被称为 **Real Mode Segmented Model**, 其中 Segmented Model 代表通过分段的方式完成逻辑地址到物理地址的转换，而Real Mode 依旧表示转换之后的地址代表物理地址。而转换之前的地址，被称为逻辑地址（Logic Address）。

> 在讨论 x86 架构的内存模型时，该模型也被直接叫做 Real Mode, 因为整个 x86 系列的芯片都沿用了分段寻址模式。

分段本质上就是把内存劈成一小块一小块来使用，每一块就是一个段，每个段可以根据程序设计者的需要用于不同的目的，例如上文提到的代码段与数据段。逻辑地址实际上是对内存地址进行了一次编码，一维的物理地址此时被编码成了一个二维的形态：段 + 段内偏移。

#### 3. 虚拟地址 - Virtual Address <a href="#org535311b" id="org535311b"></a>

Real Mode简单直接，但基于这种机制很难构建出健壮、安全的内存管理模块。例如Real Mode存在着如下缺陷：

* 安全隐患 \
  在Real Mode下，CPU 并没有有效的机制来禁止程序访问不属于自己地址空间，例如下图中，虽然 Process A 与操作系统的代码存放在不同的段之中，但处理器并没有任何机制阻止Process A访问操作系统的代码区块。这样程序Bug或者恶意代码很容易造成灾难性的后果。特别是在多任务系统中，这种缺陷会造成不同进程相互影响。

![](/files/JEMRyRnoybWV3q54yCcQ)

* 逻辑地址空间超过物理内存容量 \
  在计算机系统中，逻辑地址空间通常远大于物理内存的容量。例如1985年Intel发布了32位的处理器80386，其寻址空间达到了4GB, 但八十年代的个人电脑几乎不可能拥有4GB的内存，而现在的64位系统的寻址空间更是大得惊人。因此我们需要某种技术来完成逻辑地址到物理地址之间的映射，这样的话我们可以通过内存加磁盘的方式来模拟出更大容量的内存。

解决安全问题的思路是引入权限级别，对内存的读取需要对应的授权，同时对不同任务的内存区域进行隔离；解决容量问题的思路是在逻辑地址与物理地址之间再引入一个抽象层，对二者进行解耦。为了应对这些问题，Intel引入了一种新的寻址方式：**保护模式**。保护模式扩展了分段机制的功能，引入了权限级别；并通过分页机制（Paging）来实现虚拟地址空间。

保护模型引入了权限级别的概念，CPU 总共有4 个权限级别，0 最高，3 最低。每个内存段都会标注自己的权限级别，当低权限级别段中的代码想要访问高权限级别段中的数据时，CPU 就会报错。Linux中内核使用的是权限0, 而用户代码使用的是权限3, 因此用户代码无法直接访问内核的地址空间中的任何数据。

如果在保护模式下并开启了分页功能，就会在逻辑地址与物理地址之间引入一个新的概念：线性地址，也叫虚拟地址。此时首先需要将逻辑地址转换为线性地址，再将线性地址转换为物理地址，如果没有开启分页功能，线性地址就是物理地址。

分页是对分段的扩展，虚拟地址到物理地址的转换需要借助页目录（Page Directory）与页表（Page Table）来完成，这部分的内容将在下一节中详细介绍。

### &#x20;<a href="#org76e1673" id="org76e1673"></a>


# 3.1.2 地址转换

上一节我们介绍了三种类型的地址，逻辑地址在程序中使用，物理地址在CPU与内存交互时使用，虚拟地址是逻辑地址到物理地址转换过程中的中间状态。CPU 使用一个专门的部件来进行地址转换，该部件叫着MMU(Memory Management Unit), MMU完成地址翻译的总体流程如下：&#x20;

![](/files/RDM1d7GoDupD64S6JDPY)

其中 Segmentation Unit 与 Paging Unit 是MMU 内部的独立部件。除了硬件支持之外，CPU还需要操作系统中的各种数据结构来辅助完成地址翻译与权限校验工作，本节我们将深入讨论这两步地址转换的各种细节。

#### 1. 转换逻辑地址 <a href="#orgfb2ca3a" id="orgfb2ca3a"></a>

段（Segment）是逻辑地址的核心，对于每个段，系统都需要一个数据结构来描述其基本信息，例如基址、大小、权限、类型等，该结构叫着 [Segment Descriptor](https://en.wikipedia.org/wiki/Segment_descriptor), 一个 `Segment Descriptor` 长度为8字节，其格式为：

![](/files/pQj3lYfXiBWVMVZpDOH1)

其中重要的字段包括：

* Base Address: 段基址，总共32位
* Limit: 段长度，总共20位
* G: Granularity, 表示段大小的单位，为0时单位为字节, 即段大小为 2^20 字节，总共1M; 为1 时单位为4kb, 段大小总共为4G
* DPL: Descriptor Privilege Level, 当前段的权限，0最高，3最低。Linux 中0 代表Kernel Mode, 3 代表User Mode

系统可以包含不同类型的段，因此也会有不同类型的Segment Descriptor, Linux 中用得比较多的段包括：

* Code Segment Descriptor, 该段包含程序代码
* Data Segment Descriptor, 该段用来保存程序数据
* Task State Segment Descriptor, 该段用来保存处理器的寄存器内容
* Local Descriptor Table Descriptor, 该段指向一个包含LDT的段，这种Segment Descriptor 只会出现在GDT中

当系统对内存段划分好了之后，需要一个表来保存所有的Segment Descriptor, 存放全局共享的Segment Descriptor的表叫着 Gobal Descriptor Table, 简称为GDT, 在多核系统中，每个CPU都会有一个GDT. 每个进程也可以根据自己的需求单独定义如何划分内存段，存放进程局部使用的Segment Descriptor的表叫着 Local Descriptor Table, 简称为LDT. GDT 与 LDT 的内容都在内存中，分别由一个寄存器指向两个表的基地址，这两个寄存器叫着 gdtr 与 ldtr.

我们知道逻辑地址由两部分组成，即：段+段内偏移，其中第一部分叫着 Descriptor Selector, 目的就是用来从GDT或者LDT中寻找目标 Segment Descriptor, 其构成如下：

![](/files/RrOGRYoIX72k0tk1J1wu)

整个 Descriptor Selector 有16 位，各字段意义如下：

* index: GDT 或 LDT 中的索引，GDT/LDT 用来保存 Segment Descriptor 的内存地址是连续的，因此我们可以将其看着是一个数组，因此Segment Selector可以通过索引查找目标Segment Descriptor
* TI: Table Indicator, 指定是从GDT(TI=0)还是LDT(TI=1)查找Segment Descriptor
* RPL: Requestor Privilege Level, 请求者的权限，MMU在做地址转换时需要将其与目标 Segment Descriptor 中的DPL做权限对比

综合上述知识，MMU已经可以完成逻辑地址到线性地址的翻译了，其工作流程如下：

1. 从逻辑地址中解析出Descriptor Selector
2. 通过Descriptor Selector 确定Descriptor Table 是GDT 还是LDT
3. 从目标Descriptor Table 找到对应的Segment Descriptor, 并作权限校验等工作
4. 将Segment Descriptor中的段基址加上逻辑地址中的偏移量，得出最终的线性地址

示意图如下：&#x20;

![](/files/vCGQDOPr2TmiBa11AN5M)

在不考虑效率的情况下，我们已经达成目标了，但如果仔细检查上述步骤并结合程序执行的实际过程，我们可以发现如下情况：

1. 上述翻译步骤中，对每个逻辑地址都会解析一遍Descriptor Selector, 但一个程序所使用的段通常是有限的，而且在类型上有明确区分（例如代码段与数据段），因此同一个程序内，这部分信息的变化频率不会太高，对每个地址都进行解析有点太低效了
2. 对CPU而言，通过总线访问内存其实已经是比较昂贵的操作了，最快访问数据的方式是直接访问寄存器（Register），而上述步骤中，每次地址翻译我们都需要通过内存查找GDT或者LDT, 这也是一个低效的过程

为了提升效率，硬件设计者们在CPU中引入了专门的段寄存器来存放Segment Selector, 例如 cs, ds, ss 等，只有当段发生改变时，段寄存器中的内容才需要更新。除此之外，为了加速对 Segment Descriptor 的访问，CPU还为每个段寄存器配套提供了一个寄存器，该寄存器用来存放与段寄存器中的Segment Selector 相对应的Segment Descriptor, 每次当CPU 加载新的Segment Selector 到段寄存器中时，对应的Segemnt Descriptor 就会从内存中加载到该寄存器中，这样MMU在翻译整个逻辑地址时就都不需要访问内存了。

> 存放Segment Descriptor 的寄存器是不可编程（non-programmable）的，即程序设计者无法直接通过汇编指令使用这些寄存器。

#### 2.2. 转换虚拟地址 <a href="#org87c0c59" id="org87c0c59"></a>

分页（Paging）是对分段（Segment）机制的拓展与深化，当开启分页功能时，物理内存被划分成了固定大小的连续区域，每个区域叫着一页（Page），页的大小可以配置，通常情况下是4kb, 此时我们还需要对线性地址做一次翻译，以便得到物理地址。

为了支持CPU对线性地址的翻译，OS需要采用相同的思路来对内存页进行管理，页是操作系统管理物理内存的基本单元，OS需要为每个页创建一个数据结构来对其各种信息进行封装，还需要一个页表（Page Table）来存放所有这些数据结构。总的来说，就是与Segment Descriptor与GDT/LDT对等的数据结构。但此时我们会遇到一个问题，系统对段的使用是按照种类来区分的，因此段的数量并不会太多，而系统对页的使用是按照大小来区分的，页的数量将会随着物理内存的增加而增加。按照默认情况下页大小为4kb来计算，一个4GB的物理内存将会被划分为1M(2^20)个物理页面，这样页表就太大了。为了解决这个问题，页表被拆分成了多个层级，中间的层级叫着页目录（Page Directory）。例如一个32位的线性地址的编码如下：

* Page Directory Index \
  页目录中每条记录（entry）都指向一个页表，线性地址中有10位用于表示页目录索引，这样一个页目录可以存放2^10=1024个页表记录。页表记录（Page Directory Entry）中记录着相应页表的各种信息，其中页表基址是最重要的一项。
* Page Table Index \
  页表中每条记录都指向一个物理页面（Physical Page），同样有10 位用于页表目录索引，因此一个页表也可以存放2^10=1024个页面记录。页面记录（Page Table Entry）中记录着相应物理页面的基本信息，例如页面基址，以及该物理页面是否已经被分配等。
* Page Offset \
  由于每页的大小是4kb, 因此线性地址中最低位的12位用来表示页内偏移，用来在目标页中定位最终的物理地址。

每个进程都需要构建自己的页表，两级页表可以极大地减少页表对内存的需求。假设页表的每条记录需要4字节的空间，如果只采用一级页表，那么完整构建一个4GB 空间的页表就需要4MB的内存，不管进程实际上会使用多少内存，页表都需要这么大的空间；而当采用二级页表之后，一级的页目录只需要4kb, 二级的页表总共有2^10个，每个也需要4kb内存，但二级页表可以按需分配，不需要一次性全部初始化，这就显著地降低了对内存的实际需求。

页目录的基地址存放在[控制寄存器cr3](https://en.wikipedia.org/wiki/Control_register)中，MMU对线性地址的翻译流程如下：

1. 通过寄存器cr3找到页目录的基地址，从线性地址中解析出 Page Directory Index, 并在页目录中找到对应的页表条目
2. 从页表条目中找到二级页表的基地址，从线性地址中解析出 Page Table Index, 并在二级页表中找到对应的页面条目
3. 从页面条目中找到物理页面的基地址，从线性地址中解析出 Page Offset, 相加得出最终的物理地址

总体流程的示意图如下：&#x20;

![](/files/9q7kLidSf3gTs9WsHiME)

这里的例子只讲解了二级页表的原理，但这种对页面的拆分方式可以嵌套任意层，不管是[PAE(Physical Address Extension](https://en.wikipedia.org/wiki/Physical_Address_Extension)) 还是64位系统都采用了多级页表，这里不再展开。同理，页面大小的改变也不会改变对页表构建的方式，只是用于各级页面的位数将会不同。

页表的层级越多，MMU在翻译线性地址时需要访问内存的次数就越多，这对于效率是不友好的。Segmentation Unit 在翻译逻辑地址时通过增加寄存器的方式解决了该问题，但页表条目实在太多了，寄存器不是一个好的解决方案。CPU的硬件缓存（L1, L2, L3）理论上会有帮助，但那是通用的缓存空间，不是专门为Paging Unit设计的，为了加速对虚拟地址的翻译，CPU引入了专门的缓存设备TLB(Translation Lookaside Buffers), TLB 用来缓存虚拟地址到物理地址的映射，这样只有第一次翻译虚拟地址时，Paging Unit才需要访问内存中的页表，后续对于相同的虚拟地址，都可以直接从TLB中获取。每个CPU都有自己的TLB, 从这一点也可以看出，将进程迁移到其它CPU将失去所有TLB 中的缓存，降低进程执行的效率。由于每个进程都有自己的独立页表，所以频繁的进程切换也会导致TLB上的缓存失效，造成性能损失。

了解了分页机制的原理之后，我们回到起点来思考一个问题：为什么需要分页？我们可以尝试着思考如下几个场景：

* 按需分配物理内存 \
  与段相比，页的粒度更小，而且大小固定，这为OS单独对物理内存进行管理提供了基础。CPU在翻译虚拟地址时，虽然到最后都会落到一个具体的物理页面上，但我们不需要在程序一启动时就为其分配物理内存，或者为其预留物理内存，而是可以将对物理内存的分配推迟到使用时再分配。这样就对系统的物理内存与虚拟地址空间进行了解耦，程序中只需要规划如何使用虚拟地址，而对应的实际物理内存位于何处由操作系统负责。
* 按需加载数据 \
  前面我们只讨论了CPU如何从内存中存取数据，但内存并不是一个持久化设备，内存中的数据需要从其它设备中读取进来，通常情况下是磁盘。采用何种策略从磁盘加载数据对程序的行为会产生重大影响。一个自然的思路是程序执行时先将整个程序的所有数据加载到内存，但这种方式非常低效，不仅会显著增加程序的启动时间，而且程序可能在本次执行过程中并不需要所有的数据，例如用户启动一个程序之后又立马将其关闭，那么该程序中任何交互性的功能代码都不会被使用到；另一个思路是完全不做预加载，仅仅将内存当成一个缓存来使用，每次访问数据时如果内存中还没有的话就从磁盘获取，但这又会导致过多的磁盘I/O, 也不是最高效的利用磁盘的方式。<br>

  内存页的设计使得OS能够以合适的粒度来完成内存与磁盘之间的数据交换，OS可以每次从磁盘读取一页数据，或者向磁盘写入一页数据，这种整块整块地对数据的读取才是高效利用磁盘的方式。
* 模拟更大的物理内存 \
  当虚拟地址空间大于实际物理内存时，如果程序设计时使用了全部的虚拟地址空间，那么势必会有多个虚拟页面会被映射到相同的物理页面上（[鸽巢原理](https://en.wikipedia.org/wiki/Pigeonhole_principle)），如何解决这种冲突呢？此时我们需要借助于磁盘这种二级存储设备来模拟出更大的物理内存，具体做法是将冲突物理页面上的内容换出到磁盘，等实际使用时再换入内存，例如如果虚拟页面A与B都映射到了物理页面K上，那么当程序使用A 时，如果此时物理页面K里面不是A 的内容，那么就将K 的内容换出到磁盘上，将A的内容换入，当程序使用B时同理。换出换入叫着 Swap Out/In, 这里不做深入讲解。

从系统的总体架构上来看，分页（Paging）的最大作用是引入了一个抽象层（Abstraction Layer），从而将物理内存与程序使用的地址空间彻底解耦。内存分配、释放、规整等本身是个非常复杂的问题，这样系统对物理内存的管理就完全推给了操作系统，线性地址所提供的地址空间完全变成了一个抽象概念，或者说是抽象的接口。


# 3.1.3 Linux的地址空间

从对内存的使用上来看，分段与分页在思路上是一脉相承的，分段使程序可以将相同的逻辑地址映射到不同的线性地址，而分页可以让程序将相同的线性地址映射到不同的物理地址，二者都提供了为程序隔离物理内存的功能。但二者的设计目的却稍有不同：

* 分段的目的在于鼓励程序设计者们将程序划分成彼此关联的逻辑区块，例如将代码与数据分开
* 分页的目的在于提供对物理内存使用的虚拟化手段，将物理内存与内存地址空间进行解耦

Linux对分段的使用非常有限，主要使用了分页功能。重要的段有4个：Linux中所有的应用程序都使用相同的段，叫着用户代码段（user code segment）与用户数据段（user data segment）；内核的所有程序（kernel thread）也使用相同的段，叫着内核代码段（kernel code segment）与内核数据段（kernel data segment）。

> Linux中对段的完整划分可参见[这里](https://github.com/torvalds/linux/blob/9195e5e0adbb8a9a5ee9ef0f9dedf6340d827405/arch/x86/include/asm/segment.h#L58), 我们在这里只关心重要的这4个段，其余的不再详细展开。

这4个段的Segment Descriptor初始化代码如下：

```
/* file: arch/x86/kernel/cpu/common.c */

DEFINE_PER_CPU_PAGE_ALIGNED(struct gdt_page, gdt_page) = { .gdt = {
    [GDT_ENTRY_KERNEL_CS]       = GDT_ENTRY_INIT(0xc09a, 0, 0xfffff),
    [GDT_ENTRY_KERNEL_DS]       = GDT_ENTRY_INIT(0xc092, 0, 0xfffff),
    [GDT_ENTRY_DEFAULT_USER_CS] = GDT_ENTRY_INIT(0xc0fa, 0, 0xfffff),
    [GDT_ENTRY_DEFAULT_USER_DS] = GDT_ENTRY_INIT(0xc0f2, 0, 0xfffff),
} };
    EXPORT_PER_CPU_SYMBOL_GPL(gdt_page);
```

宏 `GDT_ENTRY_INIT` 用来初始化GDT中的Segment Descriptor, 定义如下：

```
/* file: arch/x86/include/asm/desc_defs.h */

#define GDT_ENTRY_INIT(flags, base, limit)      \
    {                                           \
    .limit0     = (u16) (limit),                \
    .limit1     = ((limit) >> 16) & 0x0F,       \
    .base0      = (u16) (base),                 \
    .base1      = ((base) >> 16) & 0xFF,        \
    .base2      = ((base) >> 24) & 0xFF,        \
    .type       = (flags & 0x0f),               \
    .s      = (flags >> 4) & 0x01,              \
    .dpl        = (flags >> 5) & 0x03,          \
    .p      = (flags >> 7) & 0x01,              \
    .avl        = (flags >> 12) & 0x01,         \
    .l      = (flags >> 13) & 0x01,             \
    .d      = (flags >> 14) & 0x01,             \
    .g      = (flags >> 15) & 0x01,             \
}
```

这里我们只关心这四个段的几个重要属性：

| Segment   | Flags Hex | Flags Binary     | Base | Limit   | DPL |
| --------- | --------- | ---------------- | ---- | ------- | --- |
| Kernel CS | 0xc09a    | 1100000010011010 | 0    | 0xFFFFF | 0   |
| Kernel DS | 0xc092    | 1100000010010010 | 0    | 0xFFFFF | 0   |
| User CS   | 0xc0fa    | 1100000011111010 | 0    | 0xFFFFF | 3   |
| User DS   | 0xc0f2    | 1100000011110010 | 0    | 0xFFFFF | 3   |

可以看出，4个段的基址与大小都是一样的，也就是说Linux中所有的代码使用的都是相同的逻辑地址空间。其中基址为0, 这意味着逻辑地址中的段偏移量恰好就是虚拟地址，进而说明逻辑地址也恰好就是虚拟地址。综合起来看其实相当于整个系统就只有一个段，所以我们可以理解为Linux几乎跳过了分段功能。这意味着在Linux中，不管是内核还是用户程序都拥有相同的虚拟地址空间，只是权限（DPL）不同。也就是说Linux段寄存器中的内容不会随着进程的切换而改变，系统只会在User Mode 与 Kernel Mode 之间切换时更新段寄存器的内容。

内核为这4个段的Segment Selector分别定义了一个宏：

```
/* arch/x86/include/asm/segment.h */
#define GDT_ENTRY_KERNEL_CS     12
#define GDT_ENTRY_KERNEL_DS     13
#define GDT_ENTRY_DEFAULT_USER_CS   14
#define GDT_ENTRY_DEFAULT_USER_DS   15

#define __KERNEL_CS         (GDT_ENTRY_KERNEL_CS*8)
#define __KERNEL_DS         (GDT_ENTRY_KERNEL_DS*8)
#define __USER_DS           (GDT_ENTRY_DEFAULT_USER_DS*8 + 3)
#define __USER_CS           (GDT_ENTRY_DEFAULT_USER_CS*8 + 3)
```

当系统在User Mode 与 Kernel Mode 之间切换时，将对应宏加载到对应的段寄存器即可。例如系统进入Kernel Mode 时，就需要将 `__KERNEL_CS` 加载到寄存器 `cs` 中。

> Linux 不喜欢分段主要有如下两个原因：
>
> 1. 所有程序使用相同的地址空间方便管理
> 2. 跨平台的兼容性，Linux会运行在各种不同的设备上，并不是所有的硬件体系都能够很好地支持分段功能。例如RISC架构的CPU就会分段支持不是很好
>
> 另外，内核与用户程序使用相同的虚拟地址空间也可以最大程度保证TLB内容的有效性

由于内核与用户程序使用的是相同的虚拟地址空间，系统需要将整个虚拟地址空间划分成不同的区域，并规定好每个区域的用途。首先Linux区分了内核空间与用户空间，一个32位系统中二者通常以1:3的比例进行划分，其中高位的1G用于内核，低位的3G给用户程序使用。示意图如下：&#x20;

![](/files/s0GDnnUzDxUCZFeFmZCY)

系统通过权限来对不同区域的访问进行控制，内核程序可以访问整个虚拟地址空间，而用户程序无法直接访问内核的地址空间。

虽然Linux没怎么使用CPU本身提供的分段功能，但一个可执行程序本身还是由多个逻辑上独立开来的部分（Section）组成的，Linux上可执行文件的格式是 [ELF(Executable and Linkable Format)](https://en.wikipedia.org/wiki/Executable_and_Linkable_Format), 一个ELF文件会包含如下Section：

* .text: 用于存放编译后的程序代码，即指令部分
* .data: 用于存放已经初始化了的变量
* .rodata: 用于存放只读数据，例如程序中的字符串
* .bss: 用于存放未初始化的变量

既然没法为ELF中的不同Section分配独立的Segment, 系统就需要约定一个内存布局，以便在同一个物理的内存段中合理地存放ELF的不同Section. 32位Linux用户空间的内存布局如下：&#x20;

![](/files/x0ywkkDDczvDXu3jZzdn)

至此，我们就弄清楚了Linux对内存地址的使用方式，我们几乎可以忽略段的存在，只考虑分页功能，因此当我们说地址空间时，指的都是虚拟地址空间，并且所有的程序都使用统一的虚拟地址空间。

Linux通过约定的内存布局来划分内存空间，不同硬件建构的内存布局不一样，使用的页表级数也不一样，但总体设计思路都是一致的，我们不再这里一一深入讨论。


# 3.2 物理内存

本节我们将主要介绍内核如何管理物理内存，首先我们将介绍与之相关的重要数据结构，这些数据结构的关系体现了内核管理物理内存的基本思路。组织管理好物理内存的目的是为了更高效、合理地使用内存，然而在内存不断分配与释放的过程中我们将面临很多问题，最大的问题之一是内存的碎片化，本节我们将介绍内核如何使用Buddy System算法有效降低内存使用过程中的碎片率。另外，效率是内核永恒的话题，我们还将介绍内核如何通过 slab/slob/slub 来提升对内存的使用效率。

内存管理模块非常复杂，本节的目的是梳理出内核在内存管理上的总体设计思路，因此不会深入每个场景的细节之中（例如内存的初始化流程），但我们会对关键部分的主要函数进行分析，以便为读者搭建起总体的知识框架。


# 3.2.1 数据结构

## 3.2.1.1 页表 <a href="#org6dd0108" id="org6dd0108"></a>

分页功能需要页表的来支持，页表的级数与OS版本与硬件架构有关，Linux当前版本最高支持5级页表，可以通过编译配置参数 `CONFIG_PGTABLE_LEVELS` 来控制开启页表的级数。各级页表分别是：

* Page Global Directory(PGD)
* Page 4 Directory(P4D)
* Page Upper Directory(PUD)
* Page Middle Directory(PMD)
* Page Table Entry Directory(PTE)

内核为每级页表的表项都专门定义了一个数据类型：

```
/* file: arch/x86/include/asm/pgtable_64_types.h */

typedef unsigned long   pteval_t;
typedef unsigned long   pmdval_t;
typedef unsigned long   pudval_t;
typedef unsigned long   p4dval_t;
typedef unsigned long   pgdval_t;
typedef unsigned long   pgprotval_t;

typedef struct { pteval_t pte; } pte_t;
```

本质上这些类型都是无符号长整型，特意声明成单独的类型是为了让编译器做类型检查。各页表项的类型定义如下：

```
/* file: arch/x86/include/asm/pgtable_types.h */

typedef struct { pgdval_t pgd; } pgd_t;

#if CONFIG_PGTABLE_LEVELS > 4
typedef struct { p4dval_t p4d; } p4d_t;

#else
#include <asm-generic/pgtable-nop4d.h>
#endif

#if CONFIG_PGTABLE_LEVELS > 3
typedef struct { pudval_t pud; } pud_t;
#else
#include <asm-generic/pgtable-nopud.h>
#endif

#if CONFIG_PGTABLE_LEVELS > 2
typedef struct { pmdval_t pmd; } pmd_t;

#else
#include <asm-generic/pgtable-nopmd.h>
#endif
```

各个 `pgtable-nop[4um]d.h` 文件内包含着未开启对应级别页表时的一些声明，这里不展开。

各级页表中的表项（entry）的内容都是一样的，都是关于目标页的一些标记（Flags），典型的标记包括：

* Present flag 目标页是否在主存中
* Dirty flag 目标页面的内容是否发生了改变
* Read/Write flag 标记目标页面的读写权限
* User/Supervisor flag 目标页面的权限，例如用户进程就无权访问内核的虚拟地址空间

其他的各种标记不在这里一一列出，总之，无符号整型的位数足够表示所有的状态。

## 3.2.1.2 Page <a href="#org54e9b1e" id="org54e9b1e"></a>

页是OS管理物理内存的基本单元，每个物理内存页被称为一个页帧（Page Frame），每个页帧都会有一个编号，被称为PFN(Page Frame Number). 对于每个物理页帧，内核都会创建一个叫 `page` 的数据结构来追踪该页的各种信息与状态， `page` 是内存管理的核心，我们这里将其完整的代码贴出：

```
/* file: include/linux/mm_types.h */

#ifdef CONFIG_HAVE_ALIGNED_STRUCT_PAGE
#define _struct_page_alignment __aligned(2 * sizeof(unsigned long))
#else
#define _struct_page_alignment
#endif

struct page {
  unsigned long flags; /* Atomic flags, some possibly
                        * updated asynchronously */
  /*
   * Five words (20/40 bytes) are available in this union.
   * WARNING: bit 0 of the first word is used for PageTail(). That
   * means the other users of this union MUST NOT use the bit to
   * avoid collision and false-positive PageTail().
   */
  union {
    struct { /* Page cache and anonymous pages */
      /**
       * @lru: Pageout list, eg. active_list protected by
       * lruvec->lru_lock.  Sometimes used as a generic list
       * by the page owner.
       */
      struct list_head lru;
      /* See page-flags.h for PAGE_MAPPING_FLAGS */
      struct address_space *mapping;
      pgoff_t index; /* Our offset within mapping. */
      /**
       * @private: Mapping-private opaque data.
       * Usually used for buffer_heads if PagePrivate.
       * Used for swp_entry_t if PageSwapCache.
       * Indicates order in the buddy system if PageBuddy.
       */
      unsigned long private;
    };
    struct { /* page_pool used by netstack */
      /**
       * @dma_addr: might require a 64-bit value on
       * 32-bit architectures.
       */
      unsigned long dma_addr[2];
    };
    struct { /* slab, slob and slub */
      union {
        /* slab 列表，slab 可能在 partial */
        struct list_head slab_list;
        struct { /* Partial pages */
          struct page *next;
#ifdef CONFIG_64BIT
          int pages; /* Nr of pages left */
          int pobjects; /* Approximate count */
#else
          short int pages;
          short int pobjects;
#endif
        };
      };
      /* 使用该页作为 slab 的 kmem_cache, 通过文件 slub.c 中的函数 allocate_slab() 设置 */
      struct kmem_cache *slab_cache; /* not slob */
      /* Double-word boundary */
      /* 当页面用于 slab 缓存时，slab 的首页对应的 page 的该字段会指向整个 slab 的空闲对象列表。
       * 但当 slab 当前正在被 kmem_cache_cpu 使用时，page 的该字段会设置为 NULL, 而 kmem_cache_cpu 中的 freelist 字段会指向 slab 的空闲对象列表 */
      void *freelist; /* first free object */
      union {
        void *s_mem; /* slab: first object */
        /* 因为 counters 与下面包含 inuse, objects, frozen 字段的结构体是 union 关系，所以很多时候需要新建 page 然后对后面三个字段赋值时，直接将 counters 的值付过去就 OK 了 */
        unsigned long counters; /* SLUB */
        struct { /* SLUB */
          /* 记录被使用的对象，但是初始值与 objects 相同 */
          unsigned inuse : 16;
          /* 记录 slab 中包含 object 的总数，即为 kmem_cache 中 kmem_cache_order_objects 中低 15 位表示的值。该值在 slub.c 中的函数 allocate_slab 中设置 */
          unsigned objects : 15;
          /* 标记该 slab 是否被某个 cpu “锁定”，如果处于 frozen 状态，那么只有对应的CPU 能够从该slab 中分配对象，其他CPU 只能往该页面释放对象。初始值设置为 1 */
          unsigned frozen : 1;
        };
      };
    };
    struct { /* Tail pages of compound page */
      unsigned long compound_head; /* Bit zero is set */

      /* First tail page only */
      unsigned char compound_dtor;
      unsigned char compound_order;
      atomic_t compound_mapcount;
      unsigned int compound_nr; /* 1 << compound_order */
    };
    struct { /* Second tail page of compound page */
      unsigned long _compound_pad_1; /* compound_head */
      atomic_t hpage_pinned_refcount;
      /* For both global and memcg */
      struct list_head deferred_list;
    };
    struct { /* Page table pages */
      unsigned long _pt_pad_1; /* compound_head */
      pgtable_t pmd_huge_pte; /* protected by page->ptl */
      unsigned long _pt_pad_2; /* mapping */
      union {
        struct mm_struct *pt_mm; /* x86 pgds only */
        atomic_t pt_frag_refcount; /* powerpc */
      };
#if ALLOC_SPLIT_PTLOCKS
      spinlock_t *ptl;
#else
      spinlock_t ptl;
#endif
    };
    struct { /* ZONE_DEVICE pages */
      /** @pgmap: Points to the hosting device page map. */
      struct dev_pagemap *pgmap;
      void *zone_device_data;
      /*
       * ZONE_DEVICE private pages are counted as being
       * mapped so the next 3 words hold the mapping, index,
       * and private fields from the source anonymous or
       * page cache page while the page is migrated to device
       * private memory.
       * ZONE_DEVICE MEMORY_DEVICE_FS_DAX pages also
       * use the mapping, index, and private fields when
       * pmem backed DAX files are mapped.
       */
    };

    /** @rcu_head: You can use this to free a page by RCU. */
    struct rcu_head rcu_head;
  };

  union { /* This union is 4 bytes in size. */
    /*
     * If the page can be mapped to userspace, encodes the number
     * of times this page is referenced by a page table.
     */
    atomic_t _mapcount;

    /*
     * If the page is neither PageSlab nor mappable to userspace,
     * the value stored here may help determine what this page
     * is used for.  See page-flags.h for a list of page types
     * which are currently stored here.
     */
    unsigned int page_type;

    unsigned int active; /* SLAB */
    int units; /* SLOB */
  };

  /* Usage count. *DO NOT USE DIRECTLY*. See page_ref.h */
  atomic_t _refcount;

#ifdef CONFIG_MEMCG
  unsigned long memcg_data;
#endif

  /*
   * On machines where all RAM is mapped into kernel address space,
   * we can simply calculate the virtual address. On machines with
   * highmem some memory is mapped into kernel virtual memory
   * dynamically, so we need a place to store that address.
   * Note that this field could be 16 bits on x86 ... ;)
   *
   * Architectures with slow multiplication can define
   * WANT_PAGE_VIRTUAL in asm/page.h
   */
#if defined(WANT_PAGE_VIRTUAL)
  void *virtual; /* Kernel virtual address (NULL if
                    not kmapped, ie. highmem) */
#endif /* WANT_PAGE_VIRTUAL */

#ifdef LAST_CPUPID_NOT_IN_PAGE_FLAGS
  int _last_cpupid;
#endif
} _struct_page_alignment;
```

`page` 与物理页帧是一一对应关系，OS在初始化时会根据物理内存创建出所有的 `page` 实例，因此我们必须要控制该结构体的大小，以避免过多的消耗内存。但一个内存页可能被用于各种目的， `page` 中就需要封装各种状态信息，因此内核工程师们精心设计了该数据结构，可以看到很多属性之间都是 `union` 关系，可以理解为虽然系统在方方面面都需要使用内存，但一个物理内存页一次只能用于一种目的，用于不同目的的字段之间可以是“或”的关系，这样就显著地降低了整个数据结构的大小。

我们在这里先不关心每个字段的意义，后续章节讲到具体的应用场合时会针对性地做详细介绍，不过我们可以先看一下比较重要的字段：

* flags 该字段是一个无符号长整型，用来存放各种类型的页标记，内核有个专门的文件来定义各种标记位，叫着 `include/linux/page-flags.h`, 例如标记 `PG_locked` 表示当前页被加锁了。

  flags字段是一个寸土寸金的地方，只有最重要的标记才有资格在该字段中占有一席之地。
* \_refcount 引用计数，如果是 -1 的话说明没有该页没有被使用，可以重新分配给需要的进程。

## 3.2.1.3 Zone <a href="#org1dd2f0e" id="org1dd2f0e"></a>

理想情况下，内存中的所有页面从功能上讲都是等价的，都可以用于任何目的，但现实却并非如此，例如一些DMA处理器只能访问固定范围内的地址空间（见[这里](https://en.wikipedia.org/wiki/Direct_memory_access#:~:text=In%20cases%20where%20an%20original%208237s%20or%20direct%20compatibles%20were%20still%20used%2C%20transfer%20to%20or%20from%20these%20devices%20may%20still%20be%20limited%20to%20the%20first%2016%C2%A0MB%20of%20main%20RAM%20regardless%20of%20the%20system%27s%20actual%20address%20space%20or%20amount%20of%20installed%20memory.)）。因此内核将整个内存地址空间划分成了不同的区，每个区叫着一个 `Zone`, 每个 `Zone` 都有自己的用途。

Zone 的划分与系统架构与位数相关，典型x86的32位系统拥有如下分区：

* ZONEDMA: 用于DMA, 包含16M以内的内存页
* ZONENORMAL: 包含16M到896M的内存页，这部分内存直接映射到了内核的虚拟地址空间
* ZONEHIGHMEM: 包含大于896M的内存页，内核通过这部分地址空间来动态映射额外的内存或者IO

> 什么是ZONEHIGHMEM及为什么需要它？
>
> 我们知道32位系统的虚拟地址空间为4G, 并且内核与用户使用相同的虚拟地址空间（详见上一节），内核使用的地址范围是 `0xC0000000` 到 `0xFFFFFFFF`, 即高位的1G. 也就是说，能够映射到内核地址空间的物理内存只有1G, 如果系统有更大的物理内存，内核也无法使用。为了能够灵活地映射到更多的内存，Linux 将内核的地址空间划分成两块，小于896M 的范围称为低端内存（Low Memory），高于该部分的称为高端内存（High Memory）。
>
> 我们知道虚拟地址到物理地址的转化需要页表的支持与MMU的参与，但内核为了效率，将低端内存与物理内存直接关联了起来，具体方法是将 0 到896M 的物理地址直接加上偏移量 `0xC0000000` 映射到低端内存的地址空间，这样内核在使用低端内存时，物理地址与虚拟地址的转换就非常简单。而128M 的高端内存地址则通过页表动态映射其它的物理内存，操作系统有多种方式来进行这种映射，这里不展开。
>
> 除了协助访问更大的物理地址，内核还需要预留一部分地址空间用于其他用途，例如映射IO地址等。
>
> 在64位系统中，由于寻址范围的增加，用于内核的虚拟地址空间已经足够大了，因此就不再需要ZONEHIGHMEM了。

内核对 `Zone` 的划分是可配置的，申明代码如下：

```
/* file: include/linux/mmzone.h */

enum zone_type {
  /*
   * ZONE_DMA and ZONE_DMA32 are used when there are peripherals not able
   * to DMA to all of the addressable memory (ZONE_NORMAL).
   * On architectures where this area covers the whole 32 bit address
   * space ZONE_DMA32 is used. ZONE_DMA is left for the ones with smaller
   * DMA addressing constraints. This distinction is important as a 32bit
   * DMA mask is assumed when ZONE_DMA32 is defined. Some 64-bit
   * platforms may need both zones as they support peripherals with
   * different DMA addressing limitations.
   */
#ifdef CONFIG_ZONE_DMA
  ZONE_DMA,
#endif
#ifdef CONFIG_ZONE_DMA32
  ZONE_DMA32,
#endif
  /*
   * Normal addressable memory is in ZONE_NORMAL. DMA operations can be
   * performed on pages in ZONE_NORMAL if the DMA devices support
   * transfers to all addressable memory.
   */
  ZONE_NORMAL,
#ifdef CONFIG_HIGHMEM
  /*
   * A memory area that is only addressable by the kernel through
   * mapping portions into its own address space. This is for example
   * used by i386 to allow the kernel to address the memory beyond
   * 900MB. The kernel will set up special mappings (page
   * table entries on i386) for each page that the kernel needs to
   * access.
   */
  ZONE_HIGHMEM,
#endif
  /*
   * ZONE_MOVABLE is similar to ZONE_NORMAL, except that it contains
   * movable pages with few exceptional cases described below. Main use
   * cases for ZONE_MOVABLE are to make memory offlining/unplug more
   * likely to succeed, and to locally limit unmovable allocations - e.g.,
   * to increase the number of THP/huge pages. Notable special cases are:
   *
   * 1. Pinned pages: (long-term) pinning of movable pages might
   *    essentially turn such pages unmovable. Memory offlining might
   *    retry a long time.
   * 2. memblock allocations: kernelcore/movablecore setups might create
   *    situations where ZONE_MOVABLE contains unmovable allocations
   *    after boot. Memory offlining and allocations fail early.
   * 3. Memory holes: kernelcore/movablecore setups might create very rare
   *    situations where ZONE_MOVABLE contains memory holes after boot,
   *    for example, if we have sections that are only partially
   *    populated. Memory offlining and allocations fail early.
   * 4. PG_hwpoison pages: while poisoned pages can be skipped during
   *    memory offlining, such pages cannot be allocated.
   * 5. Unmovable PG_offline pages: in paravirtualized environments,
   *    hotplugged memory blocks might only partially be managed by the
   *    buddy (e.g., via XEN-balloon, Hyper-V balloon, virtio-mem). The
   *    parts not manged by the buddy are unmovable PG_offline pages. In
   *    some cases (virtio-mem), such pages can be skipped during
   *    memory offlining, however, cannot be moved/allocated. These
   *    techniques might use alloc_contig_range() to hide previously
   *    exposed pages from the buddy again (e.g., to implement some sort
   *    of memory unplug in virtio-mem).
   *
   * In general, no unmovable allocations that degrade memory offlining
   * should end up in ZONE_MOVABLE. Allocators (like alloc_contig_range())
   * have to expect that migrating pages in ZONE_MOVABLE can fail (even
   * if has_unmovable_pages() states that there are no unmovable pages,
   * there can be false negatives).
   */
  ZONE_MOVABLE,
#ifdef CONFIG_ZONE_DEVICE
  ZONE_DEVICE,
#endif
  __MAX_NR_ZONES

};
```

可以看到一定存在的分区是 `ZONE_NORMAL` 与 `ZONE_MOVABLE`, 其他的Zone都是可选项。分区 `ZONE_MOVABLE` 是个特殊的分区，该分区内的内存页都必须是可迁移的，主要用途包括实现内存的热插拔与内存规整（以减少内存碎片，这里指页这个粒度的内存碎片）。

> 在Linux 中，可以通过 proc 文件系统来查看当前系统的内存分区情况，具体命令是： `cat /proc/zoneinfo`

内核中用来封装 `Zone` 的数据结构定义如下：

```
/* file: include/linux/mmzone.h */

struct zone {
  /* Read-mostly fields */

  /* zone watermarks, access with *_wmark_pages(zone) macros */
  unsigned long _watermark[NR_WMARK];
  unsigned long watermark_boost;

  unsigned long nr_reserved_highatomic;

  /*
   * We don't know if the memory that we're going to allocate will be
   * freeable or/and it will be released eventually, so to avoid totally
   * wasting several GB of ram we must reserve some of the lower zone
   * memory (otherwise we risk to run OOM on the lower zones despite
   * there being tons of freeable ram on the higher zones).  This array is
   * recalculated at runtime if the sysctl_lowmem_reserve_ratio sysctl
   * changes.
   */
  long lowmem_reserve[MAX_NR_ZONES];

#ifdef CONFIG_NUMA
  int node;
#endif
  struct pglist_data *zone_pgdat;
  struct per_cpu_pageset __percpu *pageset;
  /*
   * the high and batch values are copied to individual pagesets for
   * faster access
   */
  int pageset_high;
  int pageset_batch;

#ifndef CONFIG_SPARSEMEM
  /*
   * Flags for a pageblock_nr_pages block. See pageblock-flags.h.
   * In SPARSEMEM, this map is stored in struct mem_section
   */
  unsigned long *pageblock_flags;
#endif /* CONFIG_SPARSEMEM */

  /* zone_start_pfn == zone_start_paddr >> PAGE_SHIFT */
  unsigned long zone_start_pfn;

  /*
   * spanned_pages is the total pages spanned by the zone, including
   * holes, which is calculated as:
   *    spanned_pages = zone_end_pfn - zone_start_pfn;
   *
   * present_pages is physical pages existing within the zone, which
   * is calculated as:
   *    present_pages = spanned_pages - absent_pages(pages in holes);
   *
   * managed_pages is present pages managed by the buddy system, which
   * is calculated as (reserved_pages includes pages allocated by the
   * bootmem allocator):
   *    managed_pages = present_pages - reserved_pages;
   *
   * cma pages is present pages that are assigned for CMA use
   * (MIGRATE_CMA).
   *
   * So present_pages may be used by memory hotplug or memory power
   * management logic to figure out unmanaged pages by checking
   * (present_pages - managed_pages). And managed_pages should be used
   * by page allocator and vm scanner to calculate all kinds of watermarks
   * and thresholds.
   *
   * Locking rules:
   *
   * zone_start_pfn and spanned_pages are protected by span_seqlock.
   * It is a seqlock because it has to be read outside of zone->lock,
   * and it is done in the main allocator path.  But, it is written
   * quite infrequently.
   *
   * The span_seq lock is declared along with zone->lock because it is
   * frequently read in proximity to zone->lock.  It's good to
   * give them a chance of being in the same cacheline.
   *
   * Write access to present_pages at runtime should be protected by
   * mem_hotplug_begin/end(). Any reader who can't tolerant drift of
   * present_pages should get_online_mems() to get a stable value.
   */
  atomic_long_t managed_pages;
  unsigned long spanned_pages;
  unsigned long present_pages;
#ifdef CONFIG_CMA
  unsigned long cma_pages;
#endif

  const char *name;

#ifdef CONFIG_MEMORY_ISOLATION
  /*
   * Number of isolated pageblock. It is used to solve incorrect
   * freepage counting problem due to racy retrieving migratetype
   * of pageblock. Protected by zone->lock.
   */
  unsigned long nr_isolate_pageblock;
#endif

#ifdef CONFIG_MEMORY_HOTPLUG
  /* see spanned/present_pages for more description */
  seqlock_t span_seqlock;
#endif

  int initialized;

  /* Write-intensive fields used from the page allocator */
  ZONE_PADDING(_pad1_)

  /* free areas of different sizes */
    struct free_area free_area[MAX_ORDER];

  /* zone flags, see below */
  unsigned long flags;

  /* Primarily protects free_area */
  spinlock_t lock;

  /* Write-intensive fields used by compaction and vmstats. */
  ZONE_PADDING(_pad2_)

  /*
   * When free pages are below this point, additional steps are taken
   * when reading the number of free pages to avoid per-cpu counter
   * drift allowing watermarks to be breached
   */
    unsigned long percpu_drift_mark;

#if defined CONFIG_COMPACTION || defined CONFIG_CMA
  /* pfn where compaction free scanner should start */
  unsigned long compact_cached_free_pfn;
  /* pfn where compaction migration scanner should start */
  unsigned long compact_cached_migrate_pfn[ASYNC_AND_SYNC];
  unsigned long compact_init_migrate_pfn;
  unsigned long compact_init_free_pfn;
#endif

#ifdef CONFIG_COMPACTION
  /*
   * On compaction failure, 1<<compact_defer_shift compactions
   * are skipped before trying again. The number attempted since
   * last failure is tracked with compact_considered.
   * compact_order_failed is the minimum compaction failed order.
   */
  unsigned int compact_considered;
  unsigned int compact_defer_shift;
  int compact_order_failed;
#endif

#if defined CONFIG_COMPACTION || defined CONFIG_CMA
  /* Set to true when the PG_migrate_skip bits should be cleared */
  bool compact_blockskip_flush;
#endif

  bool contiguous;

  ZONE_PADDING(_pad3_)
  /* Zone statistics */
    atomic_long_t vm_stat[NR_VM_ZONE_STAT_ITEMS];
  atomic_long_t vm_numa_stat[NR_VM_NUMA_STAT_ITEMS];
} ____cacheline_internodealigned_in_smp;
```

其中重要的字段如下：

* `_watermark` \
  水位线用来表示 Zone 中内存的使用情况，用来触发内存回收或 swap 等行为。定义如下：

  ```
     /* file: include/linux/mmzone.h */

     enum zone_watermarks {
       WMARK_MIN,                /* 最低水位，表示内存严重不够用了 */
       WMARK_LOW,                /* 低水位，内存已经开始有一定有压力了 */
       WMARK_HIGH,               /* 高水位，表示内存充足 */
       NR_WMARK                  /* 水位线个数，用作 zone 中的水位线数组长度 */
     };
  ```
* `struct pglist_data *zone_pgdat` \
  本Zone所在的Node, Node的概念在下一节介绍
* `struct per_cpu_pageset __percpu *pageset` \
  Zone 是一个全局性的变量，多个CPU在对 Zone 中的内存进行分配和释放的过程中会面临严重的竞争问题，于是系统在 Zone 中为每个 CPU 设置了一个本地缓存，该字段就是用来管理每个CPU的缓存页面的。
* `zone_start_pfn` \
  Zone 的起始页帧号
* `managed_pages`, `spanned_pages`, `present_pages` \
  记录被 Zone 管理的内存页数量，每个字段的含义及计算方式见注释
* `name` \
  Zone 的名称，例如 “DMA”, “NORMAL”等
* `struct free_area free_area[MAX_ORDER];` \
  用于 Buddy System, 后续讲 Buddy System 时会详细介绍
* `flags` \
  用来标记 Zone 的各种信息

因为 `Zone` 被CPU访问地非常频繁，为了提升访问效率，整个数据结构要求对齐CPU的L1缓存。同时整个数据结构被 `ZONE_PADDING` 分割成了几部分，目的是为了将用于同一目的的字段聚合在同一个缓存 Line 中。

由于 Zone 是根据内存的用途进行划分的，所以 Zone 也是系统进行内存分配的直接来源，内核最底层的内存管理系统Buddy System就是基于Zone来对内存进行分配与释放的。

## 3.2.1.4 Node <a href="#orgdade8e9" id="orgdade8e9"></a>

在介绍调度器的负载均衡时我们讨论过不同的CPU拓扑结构，其主要区别体现在不同CPU对内存的访问方式上，在NUMA(Non-uniform Memory Access) 架构下，每个CPU集群都有一个自己的本地内存，每个这样的本地内存在内核中被叫着一个Node, 使用数据结构 `pglist_data` 来表示，该数据结构定义如下：

```
/* file: include/linux/mmzone.h */

/*
 * On NUMA machines, each NUMA node would have a pg_data_t to describe
 * it's memory layout. On UMA machines there is a single pglist_data which
 * describes the whole memory.
 *
 * Memory statistics and page replacement data structures are maintained on a
 * per-zone basis.
 */
typedef struct pglist_data {
  /*
   * node_zones contains just the zones for THIS node. Not all of the
   * zones may be populated, but it is the full list. It is referenced by
   * this node's node_zonelists as well as other node's node_zonelists.
   */
  struct zone node_zones[MAX_NR_ZONES];

  /*
   * node_zonelists contains references to all zones in all nodes.
   * Generally the first zones will be references to this node's
   * node_zones.
   */
  struct zonelist node_zonelists[MAX_ZONELISTS];

  int nr_zones; /* number of populated zones in this node */
#ifdef CONFIG_FLAT_NODE_MEM_MAP /* means !SPARSEMEM */
  struct page *node_mem_map;
#ifdef CONFIG_PAGE_EXTENSION
  struct page_ext *node_page_ext;
#endif
#endif
#if defined(CONFIG_MEMORY_HOTPLUG) || defined(CONFIG_DEFERRED_STRUCT_PAGE_INIT)
  /*
   * Must be held any time you expect node_start_pfn,
   * node_present_pages, node_spanned_pages or nr_zones to stay constant.
   * Also synchronizes pgdat->first_deferred_pfn during deferred page
   * init.
   *
   * pgdat_resize_lock() and pgdat_resize_unlock() are provided to
   * manipulate node_size_lock without checking for CONFIG_MEMORY_HOTPLUG
   * or CONFIG_DEFERRED_STRUCT_PAGE_INIT.
   *
   * Nests above zone->lock and zone->span_seqlock
   */
  spinlock_t node_size_lock;
#endif
  unsigned long node_start_pfn;
  unsigned long node_present_pages; /* total number of physical pages */
  unsigned long node_spanned_pages; /* total size of physical page
                                       range, including holes */
  int node_id;
  wait_queue_head_t kswapd_wait;
  wait_queue_head_t pfmemalloc_wait;
  struct task_struct *kswapd; /* Protected by
                                 mem_hotplug_begin/end() */
  int kswapd_order;
  enum zone_type kswapd_highest_zoneidx;

  int kswapd_failures; /* Number of 'reclaimed == 0' runs */

#ifdef CONFIG_COMPACTION
  int kcompactd_max_order;
  enum zone_type kcompactd_highest_zoneidx;
  wait_queue_head_t kcompactd_wait;
  struct task_struct *kcompactd;
#endif
  /*
   * This is a per-node reserve of pages that are not available
   * to userspace allocations.
   */
  unsigned long totalreserve_pages;

#ifdef CONFIG_NUMA
  /*
   * node reclaim becomes active if more unmapped pages exist.
   */
  unsigned long min_unmapped_pages;
  unsigned long min_slab_pages;
#endif /* CONFIG_NUMA */

  /* Write-intensive fields used by page reclaim */
  ZONE_PADDING(_pad1_)

#ifdef CONFIG_DEFERRED_STRUCT_PAGE_INIT
  /*
   * If memory initialisation on large machines is deferred then this
   * is the first PFN that needs to be initialised.
   */
    unsigned long first_deferred_pfn;
#endif /* CONFIG_DEFERRED_STRUCT_PAGE_INIT */

#ifdef CONFIG_TRANSPARENT_HUGEPAGE
  struct deferred_split deferred_split_queue;
#endif

  /* Fields commonly accessed by the page reclaim scanner */

  /*
   * NOTE: THIS IS UNUSED IF MEMCG IS ENABLED.
   *
   * Use mem_cgroup_lruvec() to look up lruvecs.
   */
  struct lruvec __lruvec;

  unsigned long flags;

  ZONE_PADDING(_pad2_)

  /* Per-node vmstats */
    struct per_cpu_nodestat __percpu *per_cpu_nodestats;
  atomic_long_t vm_stat[NR_VM_NODE_STAT_ITEMS];
} pg_data_t;
```

重要的字段包括：

* `struct zone node_zones[MAX_NR_ZONES];` \
  该 node 划分出来的分区
* `struct zonelist node_zonelists[MAX_ZONELISTS];` \
  用来串起所有 node 中所有 zone, 由于 Zone 是内存分配时直接打交道的对象，当当前node没有足够的内存时，系统需要从其他 zone 中去请求内存，这个列表就是用来方便遍历用的
* `struct page *node_mem_map;` \
  用来记录本 node 的所有 page, 后续介绍物理内存模型时会涉及
* `node_start_pfn` \
  本 node 的起始 PFN(Page Frame Number)
* `node_present_pages`, `node_spanned_pages` \
  该 node 中对应的 presentpages 与 spannedpages, 具体意义参见 `Zone` 中的对应字段
* `flags` \
  记录 node 的各种标记

在 UMA(Uniform Memory Access)架构下，系统所有的内存都使用一个 node 来表示。node, zone, page 是内存管理模块最核心的三个数据结构，三者的基本机构如下：&#x20;

![](/files/Ckl7AfHsOgLsRuZDVnho)


# 3.2.2 初始化

内存管理模块本身也需要使用内存，例如页表、page、zone、node等数据结构的创建过程本身就需要分配内存，但此时系统的内存管理模块还没有完成初始化，又如何完成内存分配呢？这是一个鸡生蛋、蛋生鸡的问题。内存的初始化流程就是用来解决该问题的，本节我们简单介绍一下该议题，但这部分内容对我们理解内存管理模块的设计思想帮助不大，因此不做深入讲解，我们会给出一些相关的思路、涉及到的代码及参考资料，有兴趣的同学可以自行探索。

从总体思路上来讲，内存的初始化流程可以归纳为如下几个阶段：

1. 探测物理内存 - E820 OS要管理内存，首先需要知道系统有多少可用的物理内存，该部分信息通过一个叫着[E820](https://en.wikipedia.org/wiki/E820)的机制来获取，在系统加电时， [BIOS](https://en.wikipedia.org/wiki/BIOS)通过E820这种方式获取到物理内存的大小与布局信息，以供后续的 boot loader 和操作系统使用。

   内和中涉及到E820 的文件有：

   * arch/x86/include/asm/e820/types.h
   * arch/x86/include/asm/e820/api.h
   * arch/x86/kernel/e820.c

   E820探测到的物理内存信息会打印在启动日志中，可以通过命令 `dmesg ｜ grep 'e820'` 查看。有关E820的详细实现可以参考如下资料：

   * <https://biscuitos.github.io/blog/MMU-E820/>
   * <https://wiki.osdev.org/Detecting_Memory_(x86)#BIOS_Function:_INT_0x15.2C_EAX_.3D_0xE820>
2. 初始化时内存管理 - bootmem/memblock 在系统启动阶段，Buddy System并没有初始化，系统使用的是专门的内存管理器来管理物理内存的分配与释放，Linux 早期使用的是 bootmem, 后来逐渐演化成了现在使用的 memblock. 内核中与 memblock 相关的代码集中在如下文件中：

   * include/linux/memblock.h
   * mm/memblock.c

   内存的 boot allocator 的实现逻辑并不会对系统正常运行时的内存管理有太大影响，因此我们在这里不对其做深入讨论，感兴趣的读者可以参见如下资料：

   * <https://lwn.net/Articles/761215/>
   * <https://www.kernel.org/doc/gorman/html/understand/understand008.html>
   * <https://www.kernel.org/doc/html/v4.19/core-api/boot-time-mm.html>
   * <https://biscuitos.github.io/blog/HISTORY-bootmem/>
   * <https://biscuitos.github.io/blog/MMU-ARM32-MEMBLOCK-index/>
3. 运行时内存管理 - Buddy System 系统正常运行起来之后，负责物理内存页面的分配与释放的子系统叫 Buddy System, 我们将在后面详细介绍；另外内核还通过 slab/slob/slub 系统来提供对象缓存机制。


# 3.2.3 物理内存模型

前面提到，系统管理物理内存的基本单位是页（Page），物理内存被划分成一个个大小固定的页帧（Page Frame），每个页帧都有一个唯一的编号，叫着PFN(Page Frame Number); 内核中使用数据结构 `page` 来封装每个物理页帧的状态，因此物理页帧与 `page` 之间存在着1:1的关系。系统必须提供两者之间相互转换的方式，即系统可以通过PFN 拿到对应的 `page`, 也可以通过 `page` 得到对应的 PFN, 内核中提供了两个宏来完成该转换，分别是 `page_to_pfn` 与 `pfn_to_page` 。如何组织内存页帧，使得这种转换能够高效便捷地实现，是内核设计者需要解决的问题。

我们将组织管理物理页帧的方式叫物理内存模型，内存模型的结构与物理内存本身的结构息息相关，本节我们将简要介绍一下内核物理内存模型的演进过程，以及每个内存模型所适用的场景。

## 3.2.3.1 FLATMEM <a href="#orgdf37a45" id="orgdf37a45"></a>

理想情况下，物理内存是一块地址连续的存储空间，这样物理页帧的PFN也是连续的，因此最简单直接地方式是将所有 `page` 放在一个一维数组中，每个 `page` 的索引就是对应物理页帧的PFN. 这种内存模型叫着平坦内存模型（Flat Memory Model），Linux早期使用的就是这种内存模型，所有的 `page` 保存在全局变量 `mem_map` 中，PFN 与 `page` 相互转换的逻辑实现也很直接，参考如下代码：

```
/* file: include/asm-generic/memory_model.h */

#if defined(CONFIG_FLATMEM)
#define __pfn_to_page(pfn) (mem_map + ((pfn)-ARCH_PFN_OFFSET))
#define __page_to_pfn(page) ((unsigned long)((page)-mem_map) + ARCH_PFN_OFFSET)
#endif
```

`ARCH_PFN_OFFSET` 是页帧号的起始偏移量，总体来说两个宏的逻辑都是基于 `mem_map` 做地址运算，效率非常高。

## 3.2.3.2 DISCONTIGMEM <a href="#org3c6fbec" id="org3c6fbec"></a>

FLATMEM很适合用来管理连续的物理内存，但对于内存不连续的情况就不太友好，如果物理内存存在大块的不连续区间，由于 `mem_map` 使用PFN作为 `page` 的索引，那么这些不连续区间对应的PFN就也会占用 `mem_map` 中的位置，形成空洞（hole），造成大量的内存空间浪费。另外就是在NUMA架构下，每个 Node 都有自己单独的内存区域，使用全局的变量来追踪所有的物理内存也不合理。

为了解决该问题，内核提供了新的内存模型叫着 `DISCONTIGMEM`, 意在消除内存空洞对 `mem_map` 的资源浪费。为了简化，内核在实现时仅根据 NUMA 的内存节点进行了划分，依然将每个 node 的内存看着是连续的，FLATMEM 中的全局变量 `mem_map` 变成了 `pglist_data` 中的一个变量：

```
/* file: include/linux/mmzone.h */

typedef struct pglist_data {
#ifdef CONFIG_FLAT_NODE_MEM_MAP /* means !SPARSEMEM */
    struct page *node_mem_map;
#endif
```

该模型实际上是 FLATMEM 的扩展，PFN与 `page` 之间的转换比FLATMEM多了一步：通过PFN或者 `page` 确定所在的node. 其余逻辑就与FLATMEM 一样了。具体代码如下：

```
/* file: include/asm-generic/memory_model.h */

#if defined(CONFIG_DISCONTIGMEM)

#define __pfn_to_page(pfn)                              \
    ({                                                  \
        unsigned long __pfn = (pfn);                    \
        unsigned long __nid = arch_pfn_to_nid(__pfn);   \
        NODE_DATA(__nid)->node_mem_map +                \
            arch_local_page_offset(__pfn, __nid);       \
    })

#define __page_to_pfn(pg)                                           \
    ({                                                              \
        const struct page *__pg = (pg);                             \
        struct pglist_data *__pgdat = NODE_DATA(page_to_nid(__pg)); \
        (unsigned long)(__pg - __pgdat->node_mem_map) +             \
            __pgdat->node_start_pfn;                                \
    })
#endif
```

## 3.2.3.3 SPARSEMEM <a href="#org8c32168" id="org8c32168"></a>

DISCONTIGMEM 的本意是应对非连续的物理内存，但其又将NUMA架构下的每个 node 看着是连续的，这其实并不合理，特别是在支持内存热插拔的系统当中。系统需要一种机制能够更灵活地管理粒度更小的连续内存区块，这种内存模型叫着 `SPARSEMEM`, 即稀疏内存模型。

SPARSEMEM模型的核心思想是将每个连续的内存块都单独管理，每个连续的内存块叫着一个 `mem_section`, 该数据结构中的字段 `section_mem_map` 指向连续的 `page` 对象，所有的 memsection 存放在一个全局的数组中，并且每个 `mem_section` 都可以在系统运行时改变 offline/online 状态，以便支持内存的热插拔（hotplug）功能。此时，PFN 与 page 之间的相互转换需要先找到对应的块，然后在通过 `section_mem_map` 属性进行查找。

很明显SPARSEMEM已经完全覆盖了前两个内存模型的所有功能，特别是可以完全替代不够灵活的DISCONTIGMEM.

## 3.2.3.4 Resources

这里我们仅简单讨论了一下物理内存模型的设计思想，想要深入研究的读者可以参考如下资料：

* <https://docs.kernel.org/vm/memory-model.html>
* <https://lwn.net/Articles/789304/>
* <http://www.wowotech.net/memory\\_management/memory\\_model.html>


# 3.2.4 Buddy System(伙伴系统)

## 3.2.4.1 算法思路 <a href="#orgd066805" id="orgd066805"></a>

对于内存管理，一个最基础的需求就是对连续内存页面的分配与释放，而在对内存页面的分配与释放过程中，需要解决的一个重要问题就是如何避免内存出现大量的碎片。例如对于一个有4个页面的系统，系统发起了两次请求，每次申请一个页面，系统可以有如下两种分配策略：

![](/files/Cq4JmwkmfQnG8z0rPeaS)

在方案一中，剩下的两个页面被隔离开了，如果系统接下来想要申请两个连续页面，那么请求就会失败；而方案二中，被分配的两个页面是挨着的，系统还可以成功地申请两个连续的页面。不管实际上物理内存页面的数量如何，一个优秀的内存分配器都应该采用方案二这种方式来工作，尽可能地将可用页面保留成连续的区域。

> 这种碎片被称为外部碎片（External Fragmentation），与之相对应的是内部碎片（Internal Fragmentation），指在页内分配对象时造成的浪费，这在后续介绍Slab Allocator时会提及。

Linux内核使用Buddy System来负责连续页面的分配与释放，内核将连续的内存页面按照固定的数量进行分组，对于任何一个分组，合法的页面数量是 `2^n` 且 `0<=n<=10`, 大小相等且相邻的两个分组是彼此的Buddy, 例如下图中，当 `n=1` 时，组的页面数量是2, 此时 Group 1 与 Group 2 互为Buddy, 但Group 1 与 Group 3 由于隔开了，就不是Buddy.

![](/files/OLf53jTP8Pw2nDq8AJT9)

我们定义Buddy这个概念的目的是在内存分配与释放的过程中，能够动态地维护尽可能长的连续内存。例如上图中，如果 Group1 与 Group 2 都被释放回来后，系统就将会对两个Buddy进行合并，形成一个数量更大的组Group 4, 这也是两个Buddy必须相邻的原因。在分配内存时，Buddy System 会首先根据请求的页面数量确定目标分组，如果请求的连续内存数量是k, 那么Buddy System 将选择满足 `2^n >= k` 的最小的数字n 对应的那个分组，如果Buddy System 中没有对应的分组，那么系统将会将更大的分组劈开形成一对Buddy, 然后看劈开后的分组是否适合，如果不行的话就继续劈开，直到到达合适的大小为止。下图演示了一个Buddy System 从64页连续内存中分配8页的过程：

![](/files/E2D0ATl001ZXf1lWB7tA)

系统本来只有一个64 页的分组，完成此次分配之后，系统中可用的分组如下：一个8页分组、一个16页分组、以及一个32页的分组，即图中红色方框的部分。由于每个分组的页数都满足 `2^n`, 为了避免分配页面时出现碎片，Buddy System要求分配时请求的页面数量也必须满足 `2^n` 的数字大小，如果实际请求的数字 k 不满足2的n次方的话，Buddy System会分配大于等于 k 的最小的2的某次方的那个数字进行分配，例如如果系统请求的页面数量是7, 那么系统实际上会分配8个页面。

当释放内存时，Buddy System 会查看释放分组的Buddy 是否空闲，如果空闲的话便会合并两个分组形成一个更大的分组，并递归该操作直到当前分组的Buddy 繁忙（指被分配出去还未释放）或已经形成最大数量的分组为止。例如上图中，如果系统释放刚刚分配回去的8 个页面的话，Buddy System 又会自底向上地合并所有的分组，最终又形成一个64页的分组。

> 由此可知，图2中的Group 2与 Group 3虽然数量相等且相邻，但他们并不是Buddy, 根据Buddy System 中对于分组数量的规定，两个分组如何可合并的话，那么第一个分组的首地址必然是合并后组内页面数量的整数倍。如果将Group 2与Group 3合并，将得到一个页数是4的分组，因此Group 2的起始地址应该是4的整数倍，但Group 2的起始地址是2, 因此Group 2与Group 3不会有合并的机会。

Buddy System是内存管理的基础，其分配的对象的连续的物理页面，这是一个比较大的粒度，内核会在Buddy System 之上构建更加精细的内存分配机制提供给程序使用，这就是后续将要介绍的Slab系统。

## 3.2.4.2 数据结构 <a href="#org32c6612" id="org32c6612"></a>

Buddy System从 `zone` 中分配内存，zone 中使用一个数组来管理不同大小的Buddy, 该字段定义如下：

```
/* file: include/linux/mmzone.h */

struct zone {
    /* free areas of different sizes */
    struct free_area free_area[MAX_ORDER];
}
```

`MAX_ORDER` 的值是 11, 也就是说，Buddy System 一次能够分配的最大连续页面是 210=1024 个，即4M的连续内存。数组 `free_area` 每个索引 i 存放的就是大小为 2i 页面数量的分组，这样可以提升Buddy System分配内存时查找分组的效率。数据结构 `free_area` 的定义如下：

```
/* file: include/linux/mmzone.h */

struct free_area {
    struct list_head free_list[MIGRATE_TYPES];
    unsigned long nr_free;
};
```

`free_area` 中的空闲分组按照 `MIGRATE_TYPES` 进行了分组，每种类型都保存在一个双向链表中，这里我们将关注点放在Buddy System算法的实现上，不展开讨论 `MIGRATE_TYPES`.

分配内存时还需要很多标记类参数，例如指定目标 `zone` （是从 DMA 请求内存还是 NORMAL)、分配到的内存是否要全部置零、如果内存不足，是否可以触发内存回收机制等等，这些标记都定义在 `include/linux/gfp.h` 中，例如：

```
/* file: include/linux/gfp.h */

#define ___GFP_DMA      0x01u
#define ___GFP_HIGHMEM      0x02u
#define ___GFP_DMA32        0x04u
#define ___GFP_MOVABLE      0x08u
#define ___GFP_RECLAIMABLE  0x10u
#define ___GFP_HIGH     0x20u
#define ___GFP_IO       0x40u
#define ___GFP_FS       0x80u
#define ___GFP_ZERO     0x100u
#define ___GFP_ATOMIC       0x200u

/* 通过位运算将不同的标记组合起来 */
#define GFP_ATOMIC  (__GFP_HIGH|__GFP_ATOMIC|__GFP_KSWAPD_RECLAIM)
#define GFP_KERNEL  (__GFP_RECLAIM | __GFP_IO | __GFP_FS)
#define GFP_KERNEL_ACCOUNT (GFP_KERNEL | __GFP_ACCOUNT)
#define GFP_NOWAIT  (__GFP_KSWAPD_RECLAIM)
```

源文件中对每个标记位的作用都有详细的说明，这里不再一一讨论。特别指出的是，GFP是Get Free Page的简称，这些标记叫着GFP Flags, 他们控制着Buddy System分配内存时的行为。

## 3.2.4.3 分配内存 <a href="#org3e3dc1b" id="org3e3dc1b"></a>

### **入口函数**

> 涉及物理内存分配的主要代码在文件 `include/linux/gpf.h` 与 `mm/page_alloc.c` 中，本节的代码也都是摘自这两个文件。

内存分配的入口函数有很多，我们来看一个典型的入口函数申明：

```
/* file: include/linux/gfp.h */

static inline struct page *alloc_pages(gfp_t gfp_mask, unsigned int order)
{
    return alloc_pages_node(numa_node_id(), gfp_mask, order);
}
```

函数接收两个参数，一个是 GFP Mask, 另一个用来指定页面数量：参数 `order` 表示需要申请 `2^order` 个连续页面。该函数是NUMA架构下的入口函数之一，其首先通过函数 `numa_node_id` 找到当前CPU所在的NUMA Node, 即定位目标 `pglist_data` 对象，然后调用 `alloc_pages_node` 函数进行内存分配。不管入口函数是哪个，最终都会调用到 `__alloc_pages`:

```
/* file: include/linux/gfp.h */

static inline struct page *
__alloc_pages(gfp_t gfp_mask, unsigned int order, int preferred_nid)
{
    return __alloc_pages_nodemask(gfp_mask, order, preferred_nid, NULL);
}
```

总而言之，内核在向Buddy System申请内存时需要提供如下信息：

* 如果在NUMA架构下，需要指定目标Node
* 页面数量
* GFP Flags, 用来控制Buddy System的各种行为

函数 `__alloc_pages_nodemask` 是Buddy System 的核心，包含三部分：

1. 准备内存分配的上下文，即初始化各种参数并将其封装到数据结构 `alloc_context` 中
2. 尝试通过快速路径（fastpath）分配内存，快速路径表示系统能够直接满足内存分配请求
3. 尝试通过慢速路径（slowpath）分配内存，表示当前系统无法满足内存分配请求，系统会暂时挂起内存分配操作，并通过回收内存、规整（compact）内存、或者通过 [OOM](https://en.wikipedia.org/wiki/Out_of_memory) Killer 杀死某些进程回首内存之后，再尝试内存分配操作

函数的总体逻辑如下：

```
/* file: mm/page_alloc.c */

struct page *__alloc_pages_nodemask(gfp_t gfp_mask, unsigned int order,
                                    int preferred_nid, nodemask_t *nodemask)
{
  struct page *page;
  unsigned int alloc_flags = ALLOC_WMARK_LOW;
  gfp_t alloc_mask; /* The gfp_t that was actually used for allocation */
  struct alloc_context ac = {};

  /* 1. 第一步，准备内存分配的上下文 */
  if (!prepare_alloc_pages(gfp_mask, order, preferred_nid, nodemask, &ac,
                           &alloc_mask, &alloc_flags))
    return NULL;


  /* 2. 快速路径分配内存 */
  page = get_page_from_freelist(alloc_mask, order, alloc_flags, &ac);
  if (likely(page))
    /* 如果快速路径分配成功，则直接返回 */
    goto out;


  /* 3. 慢速路径分配内存 */
  page = __alloc_pages_slowpath(alloc_mask, order, &ac);

 out:

  return page;
}
```

后续我们将主要关注快速路径的分配逻辑。

### **定位目标zone**

想要完成内存分配操作，首先需要找到合适的 zone. 细心的读者可以已经发现，函数 `__alloc_pages` 中用于指定目标 node 的参数名叫 `preferred_nid`, 之所以叫 `preferred` 的原因是当前所选择的目标 node 可能无法满足程序的内存申请，此时系统可能会尝试着从其它 node 进行分配。在讨论 [2.4](broken://pages/bMTqTlFBUUs5tpuZ1t5Q) 的数据结构时，我们提到过每个 node 中都保存着所有 node 的所有 zone，相关字段定义如下：

```
/* file: include/linux/mmzone.h */

typedef struct pglist_data {
  /*
   * node_zonelists contains references to all zones in all nodes.
   * Generally the first zones will be references to this node's
   * node_zones.
   */
  struct zonelist node_zonelists[MAX_ZONELISTS];
}

/*
 * One allocation request operates on a zonelist. A zonelist
 * is a list of zones, the first one is the 'goal' of the
 * allocation, the other zones are fallback zones, in decreasing
 * priority.
 *
 * To speed the reading of the zonelist, the zonerefs contain the zone index
 * of the entry being read. Helper functions to access information given
 * a struct zoneref are
 *
 * zonelist_zone()  - Return the struct zone * for an entry in _zonerefs
 * zonelist_zone_idx()  - Return the index of the zone for an entry
 * zonelist_node_idx()  - Return the index of the node for an entry
 */
  struct zonelist {
    struct zoneref _zonerefs[MAX_ZONES_PER_ZONELIST + 1];
  };

/*
 * This struct contains information about a zone in a zonelist. It is stored
 * here to avoid dereferences into large structures and lookups of tables
 */
struct zoneref {
  struct zone *zone; /* Pointer to actual zone */
  int zone_idx; /* zone_idx(zoneref->zone) */
};
```

数组 `node_zonelists` 长度通过 `MAX_ZONELISTS` 确定：

```
/* file: include/linux/mmzone.h */

enum {
  ZONELIST_FALLBACK, /* zonelist with fallback */
#ifdef CONFIG_NUMA
  /*
   * The NUMA zonelists are doubled because we need zonelists that
   * restrict the allocations to a single node for __GFP_THISNODE.
   */
  ZONELIST_NOFALLBACK, /* zonelist without fallback (__GFP_THISNODE) */
#endif
  MAX_ZONELISTS
};
```

在NUMA架构下，该数组包含着两个列表，一个 `fallback` 列表一个 `nofallback` 列表，前者包含着系统中所有的 zone, 列表的排列顺序就是Buddy System 分配内存时选择 zone 的优先级顺序，后者仅包含当前 node 的 zone。fallback 的意思是当前 node 的 zone 无法满足要求时，是否需要使用其它 node 中的 zone 来顶替。 `zonelist` 就是一个 zone 的数组，Buddy System分配内存时就会遍历整个数组，从第一个合适的 zone 中分配内存，zonelist 的长度定义如下：

```
/* file: include/linux/mmzone.h */

/* zonelist 的最大长度，node 数量乘以每个 node 的分区数量，用于存放整个 fallback 列表 */
#define MAX_ZONES_PER_ZONELIST (MAX_NUMNODES * MAX_NR_ZONES)
```

两个 zonelist 在系统的启动阶段进行初始化，NUMA 架构下 fallback 列表的构建逻辑是先将所有的 node 按照到当前 node 的“远近程度”排序，然后再将每个 node 的 zone 按照一定顺序排列起来。核心代码如下：

```
/* file: mm/page_alloc.c */

/*
 * Build zonelists ordered by zone and nodes within zones.
 * This results in conserving DMA zone[s] until all Normal memory is
 * exhausted, but results in overflowing to remote node while memory
 * may still exist in local DMA zone.
 */

static void build_zonelists(pg_data_t *pgdat)
{
    static int node_order[MAX_NUMNODES];
    int node, load, nr_nodes = 0;
    nodemask_t used_mask = NODE_MASK_NONE;
    int local_node, prev_node;

    /* NUMA-aware ordering of nodes */
    local_node = pgdat->node_id;
    load = nr_online_nodes;
    prev_node = local_node;

    memset(node_order, 0, sizeof(node_order));
    /* find_next_best_node 会寻找下一个距离 local_node 最近的 node */
    while ((node = find_next_best_node(local_node, &used_mask)) >= 0) {
        /*
         * We don't want to pressure a particular node.
         * So adding penalty to the first node in same
         * distance group to make it round-robin.
         */
        if (node_distance(local_node, node) !=
            node_distance(local_node, prev_node))
            node_load[node] = load;

        node_order[nr_nodes++] = node;
        prev_node = node;
        load--;
    }

    build_zonelists_in_node_order(pgdat, node_order, nr_nodes);
    build_thisnode_zonelists(pgdat);
}
```

函数 `find_next_best_node` 会寻找下一个距离 localnode 最近的 node, 这样通过 while 循环就可以将所有的 node 按照“距离（Distance）”排好序。“距离（Distance）”是NUMA架构下CPU访问其它节点的内存耗时的一个度量单位，两个CPU 节点距离越远，访问起来越耗时，因此构建 fallback 列表时，需要按照距离由近到远对所有的 node 进行排序。例如下图中，三个CPU 的 fallback 顺序依次是：

* Node 1: 1 -> 2 -> 3
* Node 2: 2 -> 1 -> 3
* Node 3: 3 -> 1 -> 2

![](/files/QLYgFsYd43ROPqSWecLi)

对 node 排好序之后，通过函数 `build_zonelists_in_node_order` 来构建当前 node 的 fallback 列表：

```
/* file: mm/page_alloc.c */

static void build_zonelists_in_node_order(pg_data_t *pgdat, int *node_order,
                                          unsigned nr_nodes)
{
    struct zoneref *zonerefs;
    int i;

    /* 拿到当前 node 的 fallback 列表 */
    zonerefs = pgdat->node_zonelists[ZONELIST_FALLBACK]._zonerefs;

    for (i = 0; i < nr_nodes; i++) {
        int nr_zones;

        /* 获取到对应的 node */
        pg_data_t *node = NODE_DATA(node_order[i]);

        /* 将 node 中的各个分区按照由高到低的顺序放入 zonerefs 中，放入顺序为：Movable -> Normal -> DMA */
        nr_zones = build_zonerefs_node(node, zonerefs);
        zonerefs += nr_zones;
    }
    zonerefs->zone = NULL;
    zonerefs->zone_idx = 0;
}
```

分配内存时的遍历逻辑在函数 `get_page_from_freelist` 中（该函数也是快速路径分配的入口函数），通过宏 `for_next_zone_zonelist_nodemask` 来完成，找到合适的 `zone` 了之后，系统通过函数 `rmqueue` 来完成内存分配。

### **内存分配**

快速路径（fastpath）下分配内存的核心逻辑在函数 `rmqueue` 中，函数的主体代码如下：

```
/* file: mm/page_alloc.c */

static inline struct page *rmqueue(struct zone *preferred_zone,
                                   struct zone *zone, unsigned int order,
                                   gfp_t gfp_flags, unsigned int alloc_flags,
                                   int migratetype)
{
  unsigned long flags;
  struct page *page;

  if (likely(order == 0)) {
    /* 对于 order=0 的情况，尝试从 per_cpu_pageset 中分配内存 */
    if (!IS_ENABLED(CONFIG_CMA) || alloc_flags & ALLOC_CMA ||
        migratetype != MIGRATE_MOVABLE) {
      page = rmqueue_pcplist(preferred_zone, zone, gfp_flags,
                             migratetype, alloc_flags);
      goto out;
    }
  }

  /* 访问 zone 时需要加锁 */
  spin_lock_irqsave(&zone->lock, flags);

  do {
    page = NULL;
    if (order > 0 && alloc_flags & ALLOC_HARDER) {
      /* 从 zone 的free area中分配内存，free area 就是Buddy System 中用来存放各种 order 的内存页面的地方 */
      page = __rmqueue_smallest(zone, order,
                                MIGRATE_HIGHATOMIC);
    }
    /* 如果还没有分配到内存，则尝试通过 fallback 列表分配内存 */
    if (!page)
      page = __rmqueue(zone, order, migratetype, alloc_flags);
  } while (page && check_new_pages(page, order));
  /* 释放锁 */
  spin_unlock(&zone->lock);
  if (!page)
    goto failed;
  local_irq_restore(flags);

 out:
  return page;

 failed:
  local_irq_restore(flags);
  return NULL;
}
```

总体来说，函数的逻辑根据 order 的大小分为两部分：

1. 当order = 0, 即申请的内存页数等于1时，系统会从每个CPU的缓存页面中分配内存
2. 当order > 0, 即申请的内存页数大于1时，系统会从 zone 中分配内存

之所以这样区别对待，是因为 `zone` 是共享的数据结构，在多核系统中会面临巨大的竞争，通过代码也可以发现，当通过 zone 分配内存时需要对 zone->lock 加锁。而根据实际经验，人们发现系统对于 order=0 的内存分配请求是出现频次最高的，为了提升性能，系统就为每个CPU 建立了一个“缓存池”，也就是前文讨论 `zone` 的数据结构时提到的字段 `struct per_cpu_pageset __percpu *pageset;`, 内核将其称为 pcplist, 即 per cpu pages list, 从 pcplist 分配内存的函数是 `rmqueue_pcplist`:

```
/* file: mm/page_alloc.c */

static struct page *rmqueue_pcplist(struct zone *preferred_zone,
                                    struct zone *zone, gfp_t gfp_flags,
                                    int migratetype, unsigned int alloc_flags)
{
  struct per_cpu_pages *pcp;
  struct list_head *list;
  struct page *page;
  unsigned long flags;

  local_irq_save(flags);
  /* 获取到当前CPU 的 pageset */
  pcp = &this_cpu_ptr(zone->pageset)->pcp;
  /* 拿到 pageset 中对应类型的内存列表 */
  list = &pcp->lists[migratetype];
  page = __rmqueue_pcplist(zone, migratetype, alloc_flags, pcp, list);
  if (page) {
    __count_zid_vm_events(PGALLOC, page_zonenum(page), 1);
    zone_statistics(preferred_zone, zone);
  }
  local_irq_restore(flags);
  return page;
}

static struct page *__rmqueue_pcplist(struct zone *zone, int migratetype,
                                      unsigned int alloc_flags,
                                      struct per_cpu_pages *pcp,
                                      struct list_head *list)
{
  struct page *page;

  do {
    /* 如果目标列表为空，则通过 Buddy System 为该列表补充内存，然后再进行分配 */
    if (list_empty(list)) {
      pcp->count +=
        rmqueue_bulk(zone, 0, READ_ONCE(pcp->batch),
                     list, migratetype, alloc_flags);
      if (unlikely(list_empty(list)))
        return NULL;
    }

    /* 将对应内存列表的第一个内存页作为结果返回 */
    page = list_first_entry(list, struct page, lru);
    list_del(&page->lru);
    pcp->count--;
  } while (check_new_pcp(page));

  return page;
}
```

整个逻辑非常直观，就是找到当前CPU 的内存“缓存池”，然后从取出一页 migratetype 对应的内存即可。值得一提的是如果 migratetype 对应的内存列表为空，那么系统会通过 Buddy System 为该列表补充内存，该逻辑通过函数 `rmqueue_bulk` 完成，这里不再深入。

如果 order>0, 则通过函数 `__rmqueue_smallest` 从 zone 中分配内存，Buddy System 的算法实现也包含在该函数中：

```
/* file: mm/page_alloc.c */

static __always_inline struct page *
__rmqueue_smallest(struct zone *zone, unsigned int order, int migratetype)
{
  unsigned int current_order;
  struct free_area *area;
  struct page *page;

  /* Find a page of the appropriate size in the preferred list */
  for (current_order = order; current_order < MAX_ORDER;
       ++current_order) {
    area = &(zone->free_area[current_order]);
    /* include/linux/mmzone.h: 作用是从 area->free_list[migratetype] 列表中拿到第一个元素，也就是一个连续 2^order 个物理页面 */
    page = get_page_from_free_area(area, migratetype);
    /* 如果没有对应大小的free list, 则尝试下一个 order 的列表 */
    if (!page)
      continue;
    del_page_from_free_list(page, zone, current_order);
    /* 如果 order ！= current_order, 则需要按照Buddy Algorithm将更大的连续内存劈开成更小的Buddy并插入到对应的 free area 列表中，该逻辑在 expand 函数中完成 */
    expand(zone, page, order, current_order, migratetype);
    set_pcppage_migratetype(page, migratetype);
    return page;
  }

  return NULL;
}
```

函数通过 for 循环遍历 `zone->free_area`, 找到能够满足请求的最小的Buddy并从中分配内存，如果实际的Buddy 列表大小大于请求的 order, 则通过 `expand` 对其进行拆分并将未分配的内存存入对应 order 的Buddy列表中，整个算法思路与前文讨论的一样。


# 3.2.5 SLAB/SLUB/SLOB

上一节我们讨论了使用Buddy System进行连续内存页面的分配，但对于使用内存的程序而言，Buddy System 还存在如下问题：

* 粒度太大：Buddy System一次最少也要分配一页内存，通常情况下是4KB, 这对于程序而言还是太大了，我们需要一种更加细致的方式来对内存进行分配与释放。
* 缺乏语义：程序在使用内存时考虑的通常也不会是“物理内存页”这种底层概念，而是程序中定义的各种具备业务意义的数据结构与对象；不仅如此，对象的初始化与释放的逻辑有时比内存分配更加耗时。
* 效率偏低：Buddy System在分配与释放内存时会对Buddy进行拆分与合并，在频繁的内存申请与释放的场景下这将非常影响性能。

为了解决这些问题，内核基于Buddy System构建了一个“对象分配系统”，该系统叫着SLAB Allocator, SLAB以对象为基本单位进行分配与释放，并且为对象提供了缓存机制，从而一举解决了上述的各种问题。

> SLAB详细思路可以参考论文：[The Slab Allocator: An Object-Caching Kernel Memory Allotor](https://people.eecs.berkeley.edu/~kubitron/courses/cs194-24-S13/hand-outs/bonwick_slab.pdf)
>
> SLUB是SLAB的改进版本，本节后续内容我们将基于SLUB的代码来讨论具体实现，但依然使用SLAB来描述对应的算法和概念。
>
> SLOB是用于嵌入式等内存容量不大的场景下的对象分配算法，这里我们不做介绍。

## 3.2.5.1 对象（Object） <a href="#org9a182bb" id="org9a182bb"></a>

> 对象（Object）一词常用于面向对象编程语言之中，从技术上讲，一个对象是一组数据（属性）与行为（方法）的封装；从业务上讲，对象用来对业务概念进行建模，以便更好地通过模块化地方式构建系统。但这里我们所说的对象确不是这个概念，此处的对象指满足内核内存申请时的任意大小的一块连续内存。

SLAB通过两个维度来区分对象：

* 用途，例如表示对象是用于通用的目的（例如用于内核的各种数据结构），还是用于特定目的（例如用于DMA）；
* 大小，从内存的视角上来看，所谓对象就是固定大小的一块连续内存，SLAB将对象通过大小进行区分，并将相同大小的对象组织在一起进行管理；

从逻辑上讲，一个SLAB Allocator管理的就是一个 <用途，大小> 形成的对象集合，Linux中所有的SLAB信息记录在 `/proc/slabinfo` 文件中，我们可以看一下其中的内容：

```
❯ sudo cat /proc/slabinfo
slabinfo - version: 2.1
# name            <active_objs> <num_objs> <objsize> <objperslab> <pagesperslab> : tunables <limit> <batchcount> <sharedfactor> : slabdata <active_slabs> <num_slabs> <sharedavail>
kmalloc-8k           204    208   8192    4    8 : tunables    0    0    0 : slabdata     52     52      0
kmalloc-4k           709    752   4096    8    8 : tunables    0    0    0 : slabdata     94     94      0
kmalloc-2k          1266   1312   2048   16    8 : tunables    0    0    0 : slabdata     82     82      0
kmalloc-1k          2827   3008   1024   32    8 : tunables    0    0    0 : slabdata     94     94      0
kmalloc-512        54469  57856    512   32    4 : tunables    0    0    0 : slabdata   1808   1808      0
kmalloc-256        21418  21536    256   32    2 : tunables    0    0    0 : slabdata    673    673      0
kmalloc-192        43876  48867    192   21    1 : tunables    0    0    0 : slabdata   2327   2327      0
kmalloc-128         2157   2240    128   32    1 : tunables    0    0    0 : slabdata     70     70      0
kmalloc-96          2745   2772     96   42    1 : tunables    0    0    0 : slabdata     66     66      0
kmalloc-64         14032  14720     64   64    1 : tunables    0    0    0 : slabdata    230    230      0
kmalloc-32         22733  23040     32  128    1 : tunables    0    0    0 : slabdata    180    180      0
kmalloc-16         14485  14848     16  256    1 : tunables    0    0    0 : slabdata     58     58      0
kmalloc-8          10691  10752      8  512    1 : tunables    0    0    0 : slabdata     21     21      0
kmem_cache_node      576    576     64   64    1 : tunables    0    0    0 : slabdata      9      9      0
kmem_cache           352    352    256   32    2 : tunables    0    0    0 : slabdata     11     11      0
```

这里展示了系统中部分的 slab 信息，其中 `kmalloc` 是内核运行时申请内存的通用slab, 内核可能申请任意大小的内存，为了满足各种应用场景，内核预备了各种大小的 kmalloc slab, 从最小的8Byte一直到最大的8KB, 大小呈几何级数分布，并且各个 slab 的大小都满足 2n。内核申请内存时需要指定内存大小，然后系统找到满足要求的最小的 `kmalloc slab` 来进行分配。

为何 kmalloc slab 的大小呈几何级数增长呢？原因是这样的设定能够尽可能地减少内存的内部碎片（Internal Fragmentation），因为不管 slab 使用了多少个物理内存页，都能够被 slab 的对象大小所整除；另外由于相邻 slab 的大小相差两倍，所以任何分配出去的对象的使用率都会超过50%。例如一个内核的结构体刚好是17字节，满足该大小的最小的 slab 是 kmalloc-32, 这样分配出去的对象最终使用了17 字节，浪费了15字节，浪费率低于50%.

## 3.2.5.2 Slab <a href="#orge722230" id="orge722230"></a>

一个Slab包含一个或多个连续的物理页面，然后将这些页面均分成固定的大小的各等份，每一等份就是一个对象，各对象通过链表串起来。有关slab 的信息封装在 `page` 中，对应的字段如下：

```
/* file: include/linux/mm_types.h */

struct page {
    union {
        struct { /* slab, slob and slub */
            union {
                /* slab 列表，slab 可能在 partial */
                struct list_head slab_list;
                struct { /* Partial pages */
                    struct page *next;
#ifdef CONFIG_64BIT
                    int pages; /* Nr of pages left */
                    int pobjects; /* Approximate count */
#else
                    short int pages;
                    short int pobjects;
#endif
                };
            };
            /* 使用该页作为 slab 的 kmem_cache, 通过文件 slub.c 中的函数 allocate_slab() 设置 */
            struct kmem_cache *slab_cache; /* not slob */
            /* 当页面用于 slab 缓存时，slab 的首页对应的 page 的该字段会指向整个 slab 的空闲对象列表。
             * 但当 slab 当前正在被 kmem_cache_cpu 使用时，page 的该字段会设置为 NULL, 而 kmem_cache_cpu 中的 freelist 字段会指向 slab 的空闲对象列表 */
            void *freelist; /* first free object */
            union {
                void *s_mem; /* slab: first object */
                /* 因为 counters 与下面包含 inuse, objects, frozen 字段的结构体是 union 关系，所以很多时候需要新建 page 然后对后面三个字段赋值时，直接将 counters 的值付过去就 OK 了 */
                unsigned long counters; /* SLUB */
                struct { /* SLUB */
                    /* 记录被使用的对象，但是初始值与 objects 相同 */
                    unsigned inuse : 16;
                    /* 记录 slab 中包含 object 的总数，即为 kmem_cache 中 kmem_cache_order_objects 中低 15 位表示的值。该值在 slub.c 中的函数 allocate_slab 中设置 */
                    unsigned objects : 15;
                    /* 标记该 slab 是否被某个 cpu “锁定”，如果处于 frozen 状态，那么只有对应的CPU 能够从该slab中分配对象，其他CPU 只能往该页面释放对象。初始值设置为 1 */
                    unsigned frozen : 1;
                };
            };
        };
} _struct_page_alignment;
```

如果页面被分配用于 slab, 那么这些字段就会被设置。其中 `kmem_cache` 与注释中提到的 `kmem_cache_cpu` 等结构会在下一节介绍。内核创建一个slab的逻辑也很直观：首先向Buddy System申请一定数量的连续页面，然后初始化对象并构建好对象列表。代码如下：

```
/* file: mm/slub.c */

static struct page *allocate_slab(struct kmem_cache *s, gfp_t flags, int node)
{
    struct page *page;
    /* 结构 kmem_cache_order_objects 中记录着一个 slab 应该申请的页面数量与总的对象数量，两个量封装在一个字段 unsigned int x 中，其中低16位表示对象总数，高位表示连续页面的阶，即需要分配2^(oo.x >> 16)个连续页面 */
    struct kmem_cache_order_objects oo = s->oo;
    /* 传给Buddy System的各类Flag, 这里我们删掉了对该参数的初始化逻辑 */
    gfp_t alloc_gfp;
    /* 初始化对象列表时使用的指针变量 */
    void *start, *p, *next;
    int idx;
    bool shuffle;

    /* 分配连续 2^(oo.x>>OO_SHIFT) 个连续页面，其中 OO_SHIFT 为 16  */
    page = alloc_slab_page(s, alloc_gfp, node, oo);
    if (unlikely(!page)) {
        /* 如果 buddy system 没有足够的连续物理页，则减少 oo 的数值再尝试一次 */
        oo = s->min;
        alloc_gfp = flags;
        page = alloc_slab_page(s, alloc_gfp, node, oo);
        /* s->min 是实例化一个 slab 所需要的最小的内存页数量，如果依旧无法满足的话就直接退出了 */
        if (unlikely(!page))
            goto out;
        stat(s, ORDER_FALLBACK);
    }

    /* oo.x 的低 15 位表示该 slab 中可以存放的 object 总数 */
    /* 函数oo_objects 就是取出 oo.x 的低16位的数字，即 oo.x&(1<<16 -1) 的值*/
    page->objects = oo_objects(oo);

    /* 将 kmem_cache 记录到第一个内存页中，kmem_cache 在下一节介绍 */
    page->slab_cache = s;
    /* 设置 page 的标记位，标记该页面用于 slab */
    __SetPageSlab(page);

    start = page_address(page);

    shuffle = shuffle_freelist(s, page);

    /* if block 中构建对象列表 */
    if (!shuffle) {
        /* 初始化第一个对象，因为 for 循环中处理的都是下一个对象 */
        start = fixup_red_left(s, start);
        /* 如果为 slab 配置了对象的初始化函数，则会在函数 setup_object 调用，对每个对象进行初始化  */
        start = setup_object(s, page, start);
        /* slab 中空闲对象列表的地址为第一个对象 */
        page->freelist = start;
        /* 初始化 slab 中的空闲对象列表 */
        for (idx = 0, p = start; idx < page->objects - 1; idx++) {
            /* s->size 表示每个对象的大小，这里计算出下一个对象的地址 */
            next = p + s->size;
            /* 初始化下一个对象 */
            next = setup_object(s, page, next);
            /* 建立空闲对象列表的单链表，其中 p 为当前对象，next 为下一个对象，将 next 的地址写入 p+s.offset 位置处 */
            set_freepointer(s, p, next);
            p = next;
        }
        /* 链表最后一个元素的next属性指向 NULL */
        set_freepointer(s, p, NULL);
    }

    page->inuse = page->objects;
    page->frozen = 1;

out:
    if (!page)
        return NULL;

    return page;
}
```

在前文中我们提到过slab中的对象就是一个固定大小的连续内存块，通过对象列表的构建逻辑我们可以对此有更深刻的理解，内核并没有单独定义一个结构体来封装对象信息，在构建对象列表时，指向下一个空闲对象的指针也是直接存放在该对象的内存地址内部，因为对象还没有被分配出去时内存是不会被使用的，而被分配之后也不需要该指针了，这是一个比较精巧的设计。一个初始化完毕的 slab 的示意图如下所示：

![](/files/MVOIxW1ulYn9uZ7e6mGt)

freelist 总是指向slab 中第一个空闲对象，也是 slab 分配与释放对象的入口点，这在后续章节会详细介绍。

## 3.2.5.3 缓存（Cache） <a href="#orgdec88b5" id="orgdec88b5"></a>

前面讨论了 slab 如何组织管理对象，但我们还面临如下问题：

* 性能问题：多核系统中，如果多个CPU都使用共享的slab, 那么在分配与释放对象时会面临强烈的竞争问题，从而极大影响系统性能；
* 如何管理多个slab: 上一节只讲到了内核如何初始化一个slab, slab 从Buddy System中分配的内存页面是固定的，当slab中的对象耗尽之后内核需要重新创建新的slab, 如何管理这些相同类型的 slab 也是一个问题；

内核引入了缓存的概念来解决这些问题。一个缓存使用一个 `kmem_cache` 来表示，该结构体的定义如下：

```
/* file: include/linux/slub_def.h */

struct kmem_cache {
    /* 为每个CPU单独维护的slab, 避免竞争带来的冲突，这也是分配对象时的快速通道 */
    struct kmem_cache_cpu __percpu *cpu_slab;
    /* 包含了 metadata 的对象大小 */
    unsigned int size; /* The size of an object including metadata */
    /* 不包含 metadata 的对象大小 */
    unsigned int object_size; /* The size of an object without metadata */
    /* 空闲对象中保存 next 指针的偏移 */
    unsigned int offset; /* Free pointer offset */
#ifdef CONFIG_SLUB_CPU_PARTIAL
    /* Number of per cpu partial objects to keep around */
    unsigned int cpu_partial;
#endif
    /* kmem_cache_order_objects 用来存放一个 slab 中的实际物理页数，以及对象的总数。*/
    struct kmem_cache_order_objects oo;
    struct kmem_cache_order_objects max;
    struct kmem_cache_order_objects min;
    /* 对象的构造函数，如果非空的话，slab 在初始化时就会通过该函数初始化每个对象 */
    void (*ctor)(void *);
    unsigned int inuse; /* Offset to metadata */
    unsigned int align; /* Alignment */
    const char *name; /* Name (only for display!) */
    struct list_head list; /* List of slab caches */

    /* 存放缓存中的备用 slab,  */
    struct kmem_cache_node *node[MAX_NUMNODES];
};
```

我们在前文讨论 slab 初始化时已经见过该结构体，并且已经使用过其中的某些字段，例如用来确定 slab 页面数量与对象数量的字段 `struct kmem_cache_order_objects oo;`

内核使用 `kmem_cache` 来管理同一类对象的所有 slab, 其中最重要的两个字段是 `struct kmem_cache_cpu __percpu * cpu_slab` 与 `struct kmem_cache_node * node[MAX_NUMNODES]`. 为了避免分配对象时的竞争问题， `kmem_cache` 为每个CPU都单独维护了一个slab 缓存，变量 `cpu_slab` 的修饰符 `__percpu` 就是告诉编译器，该字段是 per cpu 的。该结构体定义如下：

```
/* file: include/linux/slub_def.h  */

struct kmem_cache_cpu {
    /* 指向下一个空闲对象 */
    void **freelist; /* Pointer to next available object */
    /* 事务 ID, 用来做同步。kmem_cache_cpu 是分配对象的快速路径，因此性能是首要考虑因素，所以此处没有考虑使用加锁的方式来进行同步 */
    unsigned long tid; /* Globally unique transaction id */
    /*
     * 指向当前 slab 的首个物理页面
     */
    struct page *page; /* The slab from which we are allocating */
};
```

内核申请对象时，CPU都会首先尝试从自己的缓存对象 `kmem_cache_cpu` 中进行分配，这是效率最高的分配通道。

既然CPU使用的是自己独占的缓存对象了，那么为什么还需要字段 `tid` 来做同步呢？因为虽然CPU不用与其它CPU竞争资源，但在对象分配过程中调度器可能切入触发任务切换，当前任务在下次被调度时可能就跑到了其它CPU上去执行了；或者当前CPU可能发生中断，而中断处理程序可能会从同一个Slab缓存中申请对象，因此我们需要一种机制来保证对象分配发生在同一个CPU上，并且分配过程中不会被干扰，slab 通过 tid 来实现这一点。tid 是一个全局递增的数字，slab 在每次开始分配对象前会读取到当前的tid数值，完成分配后将 tid 递增，然后通过原子操作CAS(Compare And Set) 来同时更新 freelist 与 tid 两个值，这样如果中途有其它的分配操作乱入的话，CAS 操作就会失败，slab 就会重头开始重新进行分配，直到成功为止。该段逻辑在函数 `slab_alloc_node` 中，这里我们在不深入具体分配逻辑的情况下看一下同步策略：

```
/* file: mm/slub.c */

static __always_inline void *slab_alloc_node(struct kmem_cache *s,
                                            gfp_t gfpflags, int node,
                                            unsigned long addr,
                                            size_t orig_size)
{
    void *object;
    struct kmem_cache_cpu *c;
    struct page *page;
    unsigned long tid;

redo:
    /* 1. 分配逻辑开始时获取到当前 tid */
    do {
        tid = this_cpu_read(s->cpu_slab->tid);
        c = raw_cpu_ptr(s->cpu_slab);
    } while (IS_ENABLED(CONFIG_PREEMPTION) &&
            unlikely(tid != READ_ONCE(c->tid)));

    /* 从当前 kmem_cache_cpu 的 free list 中拿到第一个对象 */
    /* 2. 分配对象并计算下一个空闲对象的地址，即freelist 的新值*/
    object = c->freelist;
    void *next_object = get_freepointer_safe(s, object);

    /* 3. 通过 CMPXCHG 指令设置 freelist 与 tid 的新值, 如果此时的 tid 与 s->cpu_slab->tid 不同，则说明发生了干扰，代码跳转到 redo 重新开始分配逻辑 */
    if (unlikely(!this_cpu_cmpxchg_double(
                        s->cpu_slab->freelist, s->cpu_slab->tid, object,
                        tid, next_object, next_tid(tid)))) {
        goto redo;
    }

out:
    return object;
}
```

如果CPU的本地缓存没有空闲对象，那么就需要从其它地方拿一个可用的slab过来，这个地方就是 `kmem_cache_node`, 这相当于 `kmem_cache` 的一个中心化仓库，用来管理暂时没有被 `kmem_cache_cpu` 使用到的slab. 前文介绍NUMA架构时提到过内存会根据CPU的拓扑结果划分成不同的Node, 为了区分从不同Node 分配过来的 slab, kmemcache 也根据Node对 `kmem_cache_node` 进行了区分，该字段是一个与Node数量相等的数组。

`kmem_cache_node` 的定义如下：

```
/* file: mm/slab.h */

struct kmem_cache_node {
    spinlock_t list_lock;

#ifdef CONFIG_SLUB
    unsigned long nr_partial;
    /* 指向 partial slab 的链表，partial 的意思是 slab 中还有剩余的空闲对象可用 */
    struct list_head partial;
#ifdef CONFIG_SLUB_DEBUG
    atomic_long_t nr_slabs;
    atomic_long_t total_objects;
    /* 指向 full slab 的链表，full 的意思是 slab 中所有的对象都已经被分配出去了，没有可用的空闲对象 */
    struct list_head full;
#endif
#endif
};
```

这里我们只留下了与 `SLUB` 算法相关的部分，一个 `kmem_cache_node` 实际上就包含了两个列表，一个是partial slab列表，一个是full slab列表。示意图如下：&#x20;

![](/files/nAUkB9DtIQHdJ0ubOl2A)

综合起来看，一个缓存 `kmem_cache` 管理着一个特定类型的所有slab, `kmem_cache_cpu` 中包含着当前CPU正在使用的slab, 任何对象分配的请求都直接从该slab中进行分配；而 `kmem_cache_node` 用来充当 `kmem_cache_cpu` 与Buddy System之间的缓冲区，当 `kmem_cache_cpu` 中的空闲对象分配完了之后，会将 slab 放入 `kmem_cache_node` 的full 列表，并从 partial 列表获取新的 slab 来使用，而当 `kmem_cache_node` 也没有多余的 slab 时，便会从Buddy System 中分配新的 slab 进行补充。同时，如果系统对该类对象的使用量下降，导致 `kmem_cache_node` 有很多完全空闲的 slab 时，系统也会酌情返回一些 slab 给Buddy System, 以缓解系统的总体内存压力。

所有的 `kmem_cache` 保留在全局变量 `kmalloc_caches` 中，代码如下：

```
/* file: mm/slab_common.c */

struct kmem_cache *kmalloc_caches[NR_KMALLOC_TYPES][KMALLOC_SHIFT_HIGH +
                                                    1] __ro_after_init = {
/* initialization for https://bugs.llvm.org/show_bug.cgi?id=42570 */
};


/* file: include/linux/slab.h */
/* 通过内存用途对 kmem_cache 进行分类 */
enum kmalloc_cache_type {
KMALLOC_NORMAL = 0,
KMALLOC_RECLAIM,
#ifdef CONFIG_ZONE_DMA
KMALLOC_DMA,
#endif
NR_KMALLOC_TYPES
};
```

总结起来，系统所有 kmemcache 的示意图如下：&#x20;

![](/files/tp0Mj3futUuQVUAqLtwK)

## 3.2.5.4 分配（Allocation) 与释放（Free） <a href="#org8b92d2a" id="org8b92d2a"></a>

通过前文对几个核心概念的分析，我们可以总结出关于SLAB的一些关键设计思想：

* 懒加载（Lazy Loading）：缓存 `kmem_cache` 初始化时只会创建出 `kmem_cache_cpu` 与 `kmem_cache_node`, 但对slab的初始化会推迟到对象分配时才会发生，这可以降低系统总体的内存压力，因为除了SLAB 系统，整个内核在很多其它地方还需要使用内存；
* 本地化（Locality）：为了减少竞争，每个CPU都持有一个自己的 slab, 做到独立运作不冲突；

本节我们将继续深入，探讨SLAB系统分配对象的详细流程，以及如何与Buddy System进行交互的。内核申请内存的入口函数是 `kmalloc`:

```
/* file: include/linux/slab.h */

/* 函数有两个参数，size 表示连续的内存大小；flags用来指定内存种类（即内存分区）与内核分配内存时的行为（例如是否允许分配失败）。  */
static __always_inline void *kmalloc(size_t size, gfp_t flags)
{
    /* 删除部分无关代码 */

    return __kmalloc(size, flags);
}

/* file: mm/slab.c */
void *__kmalloc(size_t size, gfp_t flags)
{
    /* _RET_IP_ 是 GCC 的一个内置函数，参考 https://gcc.gnu.org/onlinedocs/gcc/Return-Address.html */
    return __do_kmalloc(size, flags, _RET_IP_);
}
```

函数简单地调用 `__kmalloc` 并最终调用到 `__do_kmalloc`, 该函数通过内存大小与 `flags` 中的内存种类找到对应的 `kmem_cache`, 然后从该缓存中分配对象：

```
/* file: mm/slab.c */

static __always_inline void *__do_kmalloc(size_t size, gfp_t flags,
                                          unsigned long caller)
{
    struct kmem_cache *cachep;
    void *ret;

    /* KMALLOC_MAX_CACHE_SIZE 为 page * 2, 超过该大小的内存分配请求需要使用 page allocator */
    if (unlikely(size > KMALLOC_MAX_CACHE_SIZE))
        return NULL;
    /* 根据 size 和内存类型找出对应的 kmem_cache, 然后从 cachep 中分配空闲对象。函数 kmalloc_slab 就是从全局变量 kmalloc_caches 找出对应的缓存 */
    cachep = kmalloc_slab(size, flags);
    if (unlikely(ZERO_OR_NULL_PTR(cachep)))
        return cachep;

    /* 从缓存中分配对象 */
    ret = slab_alloc(cachep, flags, size, caller);

    return ret;
}
```

从缓存中分配对象有两条路径：

* 快路径（fastpath）：直接从 `kmem_cache_cpu` 中的slab 中分配到对象
* 慢路径（slowpath）：无法直接从 `kmem_cache_cpu` 中分配到对象，需要先从 `kmem_cache_node` 的 partial 列表拿一个slab，甚至需要从Buddy System 中分配一个全新的 slab 来进行补充

快路径的逻辑在函数 `slab_alloc_node` 中，前文讨论 `kmem_cache_cpu` 的并发控制时已经探索过该函数，这里再着重看一下有关对象分配的逻辑：

```
/* file: mm/slub.c */

static __always_inline void *slab_alloc_node(struct kmem_cache *s,
                                             gfp_t gfpflags, int node,
                                             unsigned long addr,
                                             size_t orig_size)
{
    void *object;
    struct kmem_cache_cpu *c;
    struct page *page;
    unsigned long tid;

redo:
    do {
        tid = this_cpu_read(s->cpu_slab->tid);
        /* 拿到当前 CPU 的 kmem_cache_cpu  */
        c = raw_cpu_ptr(s->cpu_slab);
    } while (IS_ENABLED(CONFIG_PREEMPTION) &&
             unlikely(tid != READ_ONCE(c->tid)));

    /* 从 kmem_cache_cpu 的 freelist 中拿到第一个对象 */
    object = c->freelist;
    page = c->page;
    if (unlikely(!object || !page || !node_match(page, node))) {
        /* 进入慢路径进行分配。!object 表示 kmem_cache_cpu 的slab 中已经没有了空闲对象，!page 表示还没有为 kmem_cache_cpu 分配 slab, 不管哪种情况，都需要先搞定一个 slab 才能继续分配对象。  */
        object = __slab_alloc(s, gfpflags, node, addr, c);
    } else {
        /* 快路径分配成功，修改各个变量，同步原理在前文中已经讲过 */
        void *next_object = get_freepointer_safe(s, object);
        if (unlikely(!this_cpu_cmpxchg_double(
                         s->cpu_slab->freelist, s->cpu_slab->tid, object,
                         tid, next_object, next_tid(tid)))) {
            note_cmpxchg_failure("slab_alloc", s, tid);
            goto redo;
        }
    }

out:
    /* 对分配到的对象做一些边界检查等工作 */
    slab_post_alloc_hook(s, objcg, gfpflags, 1, &object);

    return object;
}
```

慢路径的逻辑在函数 `___slab_alloc` 中，其主要思想是先尝试着从 `kmem_cache_node` 的partial 列表获取一个slab, 不行的话就从Buddy System 申请一个全新的slab 来使用。主要代码如下：

```
/* file: mm/slub.c */

static void *___slab_alloc(struct kmem_cache *s, gfp_t gfpflags, int node,
                           unsigned long addr, struct kmem_cache_cpu *c)
{
    void *freelist;
    struct page *page;

    page = c->page;
    if (!page) {
        /* 说明当前CPU的slab还为空，尝试分配一个新的 slab */
        if (unlikely(node != NUMA_NO_NODE &&
                     !node_isset(node, slab_nodes)))
            node = NUMA_NO_NODE;
        goto new_slab;
    }
/* 分配对象的代码块 */
redo:
    /* must check again c->freelist in case of cpu migration or IRQ */
    /* 再次检查 c->freelist，如果分配成功则跳转到 load_freelist 做后续处理 */
    freelist = c->freelist;
    if (freelist)
        goto load_freelist;

    /* 如果 kmem_cache_cpu 的 freelist 已经没有空闲对象，而 page 可能是重新分配的 slab, 该函数将 page 中的 freelist 转移出来。
     * 在 reload_freelist 部分会将该 freelist 设置到 kmem_cache_cpu 中。总体来说，该操作就是为了将新的slab设置到 kmem_cache_cpu 中
     */
    freelist = get_freelist(s, page);

    if (!freelist) {
        c->page = NULL;
        goto new_slab;
    }

load_freelist:
    /* 设置 slab 到 kmem_cache_cpu 中 */
    c->freelist = get_freepointer(s, freelist);
    c->tid = next_tid(c->tid);
    /* 分配成功，返回 */
    return freelist;

new_slab:
    /* 函数 new_slab_objects 会先检查 kmem_cache_node 的partial 列表，如果列表为空则从Buddy System 重新分配 slab */
    freelist = new_slab_objects(s, gfpflags, node, &c);

    if (unlikely(!freelist)) {
        slab_out_of_memory(s, gfpflags, node);
        return NULL;
    }

    page = c->page;
    if (likely(!kmem_cache_debug(s) && pfmemalloc_match(page, gfpflags)))
        goto load_freelist;

    /* 该函数会将新分配的 slab 放入 kmem_cache_node 的 partial 列表中 */
    deactivate_slab(s, page, get_freepointer(s, freelist), c);
    return freelist;
}
```

从Buddy System 分配与初始化slab 的函数在前面已经介绍过，这里不再讨论。

向缓存中释放对象时逻辑很简单，就是将该对象放入对应slab 的freelist 列表即可。但在如下几种情况下会调整slab 的位置：

* slab 在 kmemcachenode 的full 列表中时，需要将该slab 放入 partial 列表中
* slab 变成完全空闲状态时，即没有对象被使用，那么如果此时 kmemcachenode 有太多 slab 的话，需要将整个 slab 释放，将内存还给Buddy System


