【操作系统】深入内核源码,带你彻底理解Linux CFS调度器(下篇)

202 阅读12分钟

header_kv_3.jpg

这是本人修读《操作系统》课程时,所撰写的期末大作业报告。随着课程的结束,现在本人将该报告重新编辑成上下两篇并开源出来,以供对Linux感兴趣的同学参考。

本文为下篇,旨在验证上篇中我们对CFS调度器的理论分析结论,与Linux系统中实际观察到的效果是否一致

由于本人水平有限,文中内容如有疏漏错误,还请各路dalao指正~


一、实验环境

1. 实验软硬件环境

宿主机:13th Gen Intel(R) Core(TM) i5-13500H 2.60 GHz

虚拟机软件:VMware® Workstation 17 Pro

实验用操作系统:Ubuntu 18.04(基于Linux 5.15.0-119-generic x86_64)

2. 实验过程中涉及到的工具软件

gcc:C语言编译器,版本号9.4.0

perf:Linux系统性能指标观测工具,版本号5.15.160

vim:用于编辑C语言代码、shell脚本等,版本号8.1.1847

3.实验过程中可能涉及到的Linux终端命令

sudo su:涉及到观测内核性能指标、修改内核参数等操作,一般需要首先获取root权限

sysctl:用于临时修改内核参数(操作系统关机重启后修改失效)

nice、renice:用于修改指定进程的nice优先级参数

二、实验准备工作

1. 安装相关软件

自行安装gcc、perf和vim等实验所需的工具软件。

2. 编写测试用程序代码

本实验以验证性实验为主,为此我们首先需要编写用于验证实验的两段程序,分别模拟实际生产环境中的CPU 密集型和I/O密集型程序。

前者会执行大量计算,消耗 CPU 时间;后者模拟频繁读写文件或进行网络通信的场景,大部分时间在我们的进程都在等待 I/O 完成(即处于TASK_INTERRUPTIBLE睡眠状态,可通过usleep系统调用进行模拟)。

需特别注意的是,现代Linux系统中实现了均衡负载机制,它在进程运行中途,可能会根据一定的规则将其调往其他CPU核心继续执行,而我们的实验假设所有进程至始至终都在抢占同一颗CPU核心,因此需要设法将实验中的所有进程“钉死”在同一颗CPU核心上。这可以通过Linux内核提供的sched_setaffinity系统调用轻松实现。

CPU密集型程序代码:

#define _GNU_SOURCE

#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include <sched.h>
#include <sys/resource.h>

int main(int argc, char *argv[]) {
    cpu_set_t mask;
    CPU_ZERO(&mask);
    CPU_SET(3, &mask); 
    // 我们将进程钉死在CPU 3,
    // 防止Linux内核将其负载均衡到其他CPU核上执行。
    if (sched_setaffinity(0, sizeof(mask), &mask) < 0) {
        perror("sched_setaffinity");
        exit(1);
    }

    long long iterations = atoll(argv[1]);

    // 设置进程nice值
    if (argc > 2) {
        int nice = atoi(argv[2]);
        if (setpriority(PRIO_PROCESS, 0, nice) < 0) {
            perror("setpriority");
            exit(1);
        }  
    }


    printf("CPU bound task starting (%lld iterations)...\n", iterations);
    volatile double dummy = 0.0;
    for (long long i = 0; i < iterations; ++i) {
        dummy += i * 0.00001; // 做一些无意义的计算
    }
    printf("CPU bound task finished.\n");
    return 0;
}

IO密集型程序代码:

#define _GNU_SOURCE

#include <sched.h>
#include <stdlib.h>
#include <stdio.h>
#include <sys/resource.h>
#include <time.h>
#include <unistd.h> // for usleep

int main(int argc, char *argv[]) {
    cpu_set_t mask;
    CPU_ZERO(&mask);
    CPU_SET(3, &mask); 
    // 我们将进程钉死在CPU 3,
    // 防止Linux内核将其负载均衡到其他CPU核上执行。
    if (sched_setaffinity(0, sizeof(mask), &mask) < 0) {
        perror("sched_setaffinity");
        exit(1);
    }

    // 设置进程nice值
    if (argc > 3) {
        int nice = atoi(argv[3]);
        if (setpriority(PRIO_PROCESS, 0, nice) < 0) {
            perror("setpriority");
            exit(1);
        }  
    }

    int loops = 100;        // 循环次数
    int sleep_us = 10000; // 每次循环模拟 I/O 等待时间 (10ms)

    if (argc > 1) loops = atoi(argv[1]);
    if (argc > 2) sleep_us = atoi(argv[2]);

    printf("I/O bound task starting (%d loops, %d us sleep)...\n", loops, sleep_us);
    for (int i = 0; i < loops; ++i) {
        // 模拟少量计算
        volatile int dummy = 0;
        for (long long i = 0; i < 1000; ++i) {
            dummy += i * 0.00001; // 做一些无意义的计算
        }
        // 模拟 I/O 等待
        usleep(sleep_us);
    }
    printf("I/O bound task finished.\n");
    return 0;
}

为避免gcc编译器对我们的代码进行优化修改,导致实验效果不符合预期,编译时建议添加-O0参数!

3. 关闭 Linux 内核自动进程组调度 (Automatic Process Group Scheduling) 机制

Linux内核引入该机制的初衷是为了改善桌面交互性能。它认为属于同一个终端会话(TTY)的进程组应该作为一个整体与其他会话竞争 CPU 资源,而不是让另一个会话中的后台计算任务饿死该会话的前台交互任务。

当该机制被启用时,内核会为每个新的 TTY 会话自动创建一个调度组(autogroup)。CFS 调度器会首先在这些活动的 autogroup 之间尝试公平地分配 CPU 时间,而我们的实验假设某一颗CPU核心的时间会被直接地分配给所有进程。可见,如果不关闭该机制,将会导致我们无法在实验中观察到预期结果。

要检测该机制是否开启,或关闭该机制,可参考如下命令:

cat /proc/sys/kernel/sched_autogroup_enabled  # 检查是否被开启
sudo sysctl -w kernel.sched_autogroup_enabled=0  # 临时关闭(关机重启后会重新开启)

三、实验I:nice值对CPU分配的影响

1. 实验目标

观察不同 nice 值的进程在竞争 CPU 时的表现。

2. 实验步骤

(1)基准测试

操作: 打开两个终端,分别使用perf stat命令运行一个 CPU 密集型任务,它们的nice均为默认值0。

perf stat -e task-clock,duration_time ./cpu_bound 20000000000 0  # 终端 1
perf stat -e task-clock,duration_time ./cpu_bound 20000000000 0  # 终端 2 

观察指标:task-clock (进程代码运行实际消耗的CPU时间) 和 duration_time (挂钟时间,相当于教材中的周转时间)。

预期效果:这两个进程的nice值相等,即调度优先级相等,因此理论上在每轮调度周期中CFS分配给它们的时间片应当相等。故预期可以观察到task-clock和duration_time大致相等。

实测效果

image.png

(2)调整nice值

操作:

perf stat -e task-clock,duration_time ./cpu_bound 2000000000 19  # nice=19,最低优先级
perf stat -e task-clock,duration_time ./cpu_bound 2000000000 0  # nice=0,默认优先级

预期效果:这两个进程内的代码逻辑完全一致,因此预期观察到task-clock大致相等。由于前者的优先级远小于后者,根据CFS算法原理,每轮调度周期中分配到的时间片远小于后者,需要更多轮调度周期才能完成执行,故预期观察到duration_time前者远大于后者。

实测效果

image.png

(3)调整nice值(反向)

操作:

perf stat -e task-clock,duration_time ./cpu_bound 2000000000 -20  # nice=-20,最高优先级
perf stat -e task-clock,duration_time ./cpu_bound 2000000000 0  # nice=0,默认优先级 |

预期效果:预期观察到task-clock大致相等,duration_time前者远小于后者。理论分析同(2)。

实测效果

image.png

四、实验II:CFS调度参数的影响

1. 实验目标

观察调整CFS调度器参数sched_latency_ns和sched_min_granularity_ns对系统关键性能指标(特别是上下文切换频率)的影响。

2. 实验步骤

(1)查看当前参数值

操作: 记录下系统默认的参数值,并计算相应的sched_nr_latency。

cat /sys/kernel/debug/sched/latency_ns
cat /sys/kernel/debug/sched/min_granularity_ns

实测效果:

从中得知默认的sched_latency_ns=18000000,sched_min_granularity_ns=2250000,sched_nr_latency=sched_latency_ns/sched_min_granularity_ns=8.

image.png

(2)基准测试

操作: 同时在后台启动多个 CPU 密集型任务(例如4个,要求不能超过sched_nr_latency),并监控这组特定进程在单位时间(例如10秒)内总上下文切换:

# 如果有相关进程还没退出,先强行把它们杀掉
PIDS=$(pgrep -x cpu_bound)
if [ -n "$PIDS" ]; then
    kill -9 $PIDSfi
    
# 创建4个测试进程
./cpu_bound 2000000000 & ./cpu_bound 2000000000 &
./cpu_bound 2000000000 & ./cpu_bound 2000000000 &

# 获取进程号
PIDS=$(pgrep -x -d, cpu_bound)
echo "Monitoring PIDs: $PIDS"

# 持续监控10s
sudo perf stat -e context-switches,task-clock -p $PIDS -- sleep 10

实测效果

image.png

(3)增大sched_latency_ns

操作: 设置一个较大的sched_latency_ns值(例如,默认值的 5 倍)。然后再次重复(2)中的测试步骤。

# 示例:假设默认是18000000 (18ms)
echo 90000000 > /sys/kernel/debug/sched/latency_ns

预期效果:增大sched_latency_ns会导致CFS调度器每轮调度周期时长被增大,相应地每个进程被分配到的可以持续占用CPU的时间片也增大,因此预期观察到的上下文切换次数会远小于(2)。

实测效果:

image.png

(4)减小sched_latency_ns

操作: 设置一个较小的sched_latency_ns值(例如,默认值的 1/5 ),并相应调小sched_min_granularity_ns 以保持比例关系。然后再次重复(2)中的测试步骤。

# 示例:假设默认是 18000000 (18ms)
echo 3600000 > /sys/kernel/debug/sched/latency_ns
# 示例:假设默认是 2250000 (2.25ms)
echo 450000 > /sys/kernel/debug/sched/min_granularity_ns

预期效果: 预期观察到上下文切换次数会远大于(2),理论分析同(3)

实测效果:

image.png

(5)增大sched_min_granularity_ns

操作: 恢复 sched_latency_ns 到默认值或一个中间值,再设置一个较大的 sched_min_granularity_ns 值(例如10ms,但不应超过sched_latency_ns)。然后再次重复(2)中的测试步骤。

# 恢复 latency 到默认 (假设 18ms)
echo 18000000 > /sys/kernel/debug/sched/latency_ns
# 设置较大的 min_granularity(例如10ms)
echo 10000000 > /sys/kernel/debug/sched/min_granularity_ns 

预期效果: 增大 sched_min_granularity_ns会导致进程能够持续占用CPU的时间长度下界、以及能够分配到的时间片增大,因此预期观察到上下文切换次数会远小于(2)。

实测效果:

image.png

(6)恢复默认参数

操作: 实验结束后应当重启系统或手动恢复调度器参数到默认值,避免影响后续实验。

3. 反思:在生产实践中,如果要通过修改这些参数的方式对CFS调度器进行调优,需要在哪些方面进行权衡?

(1)在吞吐量(Through Put)与延迟(Latency)/响应性(Responsiveness)之间做权衡

"提高吞吐量"通常倾向于减少上下文切换开销,可能需要增大sched_latency_ns和sched_min_granularity_ns,让任务运行更长时间。但以牺牲任务的响应速度为代价。

"降低延迟/提高响应性"通常需要减小sched_latency_ns和sched_min_granularity_ns,让任务更快地得到响应。但这会增加上下文切换次数,可能因调度开销增大而降低系统总吞吐量。

(2)在公平性(Fairness)与开销(Overhead)之间做权衡

CFS 的核心是公平性。更短的 sched_latency_ns 能在更短的时间粒度上体现公平性(任务能更快地轮流运行)。

但追求极致的、短时间内的公平性(非常小的 latency 和 granularity)会导致调度开销显著增加(例如频繁的上下文切换)而损害整体性能。因此需要在“多久内实现公平”和“实现公平的成本”之间找到平衡。

(3)在CPU密集型任务与I/O密集型/交互式任务之间做权衡

较大的 latency 和 granularity 可能对 CPU 密集型任务的吞吐量有利(减少上下文切换)。

较小的 latency 对交互式和 I/O 密集型任务的响应性有利(只需延迟等待较短时间,即可再次获得 CPU)。虽然CFS 的 vruntime 机制已体现了对I/O任务及交互式任务的照顾,但过大的 latency在整体上仍可能会延缓它们等待被调度的时间。

五、实验III:混合负载下的CFS表现

1. 实验目标

观察CFS如何处理 CPU 密集型和 I/O 密集型任务混合运行的场景。

2. 实验步骤

(1)基准测试

操作: 在没有I/O密集型任务干扰的情况下,运行一个CPU密集型任务。

perf stat -e context-switches,task-clock,duration_time ./cpu_bound 10000000000

观察指标:CPU 密集型任务的context-switches

实测效果

image.png

(2)运行混合任务

操作: 同时运行一个 CPU 密集型任务和一个 I/O 密集型任务。

# 终端1:CPU密集型任务
perf stat -e context-switches,task-clock,duration_time ./cpu_bound 10000000000
# 终端2:让 I/O 任务运行足够长时间,例如循环 5000 次,每次 sleep 5ms
perf stat -e context-switches,task-clock,duration_time ./io_bound 5000 5000 |

观察指标:CPU 密集型任务的context-switches,以及I/O密集型任务的task-clock和duration_time。

预期效果:

  1. 由于在CPU密集型任务执行过程中,CPU会频繁地被I/O任务抢占(即两者在持续竞争CPU),因此预期CPU密集型任务的上下文切换次数会出现明显劣化。
  2. I/O密集型任务大部分时间都处于睡眠态,因此预期task-clock会非常短。
  3. 当任务睡眠等待I/O时,其vruntime停止增长。一旦唤醒,其vruntime通常远小于持续运行的CPU密集型任务,故CFS调度器很有可能会决定使用前者抢占后者,以维持双方的vruntime大致接近,确保近似的完全公平。据此预期I/O密集型任务不会出现明显的饥饿现象,即它的duration_time不会远大于预设的I/O总耗时。

实测效果:

image.png

结论:

  1. CPU密集型任务的上下文切换次数出现了剧烈的劣化,符合预期。
  2. I/O密集型任务的CPU时间极短,符合预期。
  3. I/O密集型任务的周转时间大约在27s左右,与预设的I/O总时间5000*5ms=25s差距不大,这表明I/O密集型任务在被唤醒后,总体上能够及时地被调度执行用户态代码,并没有出现明显的饥饿,符合预期。

六、参考文献

[1]Linux Kernel Developers. Linux Kernel Version 5.15.182[Source code].[2025-05-09]. git.kernel.org/pub/scm/lin….

[2]张彦飞著.深入理解Linux进程与内存:修炼底层内功,掌握高性能原理[M].北京:电子工业出版社,2024.