☰
CS-Base 图解系统:CPU 如何读写数据与调度任务——从 Cache 伪共享到 Linux 完全公平调度
2026/10/2 1:37:20 网站建设 项目流程
  • 文档
  • 教程
  • 知识库

【免费下载链接】CS-Base

图解计算机网络、操作系统、计算机组成、数据库,共 1000 张图 + 50 万字,破除晦涩难懂的计算机基础知识,让天下没有难懂的八股文!🚀 在线阅读:https://xiaolincoding.com

项目地址:https://gitcode.com/GitHub_Trending/cs/CS-Base
点击查看免费下载

导读:本文以 CS-Base 仓库 os/1_hardware/how_cpu_deal_task.md 为核心脉络,系统讲解两个开发者绕不开的 CPU 主题:CPU 如何以 Cache Line 为单位读写数据,以及多核场景下 Cache 伪共享的成因与规避;同时深入 Linux 内核的任务调度体系,讲清task_struct、调度类、CFS 完全公平调度与nice/renice/chrt的实战调优。读完后,你将能写出缓存命中率更高的代码、在项目中有意识地规避伪共享,并能对高响应要求的任务做优先级干预。


一、CPU 是如何读写数据的?

要理解 CPU 如何读写数据,前提是先理解 CPU 的架构。对于一个现代 CPU,通常包含多个 CPU 核心,每个核心都拥有自己私有的L1 Cache和L2 Cache,其中 L1 Cache 又细分为dCache(数据缓存)与iCache(指令缓存),而L3 Cache 则是多个核心共享的,这就是 CPU 典型的缓存层次结构。

把视角拉大到 CPU 外部,还有内存与硬盘,这些存储设备共同构成了金字塔存储层次:

  • 从上往下,存储设备的容量越来越大,访问速度越来越慢;
  • CPU 访问 L1 Cache 的速度比访问内存快约100 倍;
  • 因此 L1~L3 Cache 存在的意义,就是充当 CPU 与内存之间的缓存层,降低 CPU 对内存的访问频率。

关于各级缓存的访问耗时差异与"为什么有了内存还需要 Cache",可进一步参阅仓库姊妹篇 如何写出让 CPU 跑得更快的代码?:其中给出了一次内存访问约需200~300个时钟周期、L1 Cache 仅需2~4个时钟周期等量化对比,正是这数量级的差距催生了 CPU 内部的多级缓存。

1.1 CPU Cache Line:CPU 读取数据的基本单位

CPU 从内存读取数据到 Cache 时,并不是一个字节一个字节地读取,而是一块一块地读取,这一块块的数据被称为CPU Cache Line(缓存块)。也就是说:

CPU Cache Line 是 CPU 从内存读取数据到 Cache 的单位。

在 Linux 系统上,可以通过以下方式查看本机 Cache Line 的大小(一般常见值为 64 字节):

# 查看 L1 Cache Line 大小(单位为字节) cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size

若 L1 Cache Line 大小为 64 字节,即意味着L1 Cache 一次载入数据的大小是 64 字节。Cache Line 的内部结构(索引 Index、有效位 Valid、组标记 Tag、数据块 Data Block)以及直接映射 Cache 的寻址逻辑,均已在 如何写出让 CPU 跑得更快的代码? 中做了完整剖析,本文不再赘述,但下面两个结论与本文主题直接相关:

  1. 访问数组时按内存地址顺序遍历:由于 CPU 会一次性把连续 64 字节载入 Cache,因此按照物理内存地址分布的顺序访问元素,Cache 命中率会非常高,能大幅减少从内存读取数据的频率,从而提升程序性能;
  2. 访问独立变量时则要小心伪共享:当我们操作的不是数组而是普通变量,且处于多核 CPU 环境时,就可能触发Cache 伪共享——这是一个性能杀手,需要主动规避。

二、Cache 伪共享:多核场景下的隐形性能杀手

2.1 伪共享是如何发生的?——MESI 协议视角的完整推演

先构造一个场景:假设有一颗双核心 CPU,两个核心并行运行着两个不同线程,它们分别从内存读取两个long类型的变量 A 和 B。这两个变量的物理地址连续,Cache Line 大小为 64 字节,且变量 A 恰好位于 Cache Line 的开头位置。那么:

  • 变量 A 和 B 落在同一个 Cache Line中;
  • 因为 CPU Cache Line 是读取单位,这两个数据会同时被读入两个 CPU 核心各自的 Cache中。

表面上看这没什么问题,但接下来如果两个核心的线程分别修改不同的变量(1 号核心只改 A,2 号核心只改 B),缓存一致性协议就要开始"打架"了。这里需要借助MESI 协议(Modified 已修改 / Exclusive 独占 / Shared 共享 / Invalidated 已失效)来说明,完整的协议状态机与"写直达/写回"两种写策略详见仓库文档 CPU 缓存一致性,此处只推演伪共享发生的五个关键步骤:

① 初始状态:变量 A 和 B 都不在任何 Cache 中。假设 1 号核心绑定线程 A(只读写变量 A),2 号核心绑定线程 B(只读写变量 B)。

② 1 号核心读取变量 A:由于读取单位是 Cache Line,而 A 与 B 恰好在同一个 Cache Line 中,因此 A、B 的数据会一起被加载进 1 号核心的 Cache,该 Cache Line 被标记为「独占(Exclusive)」状态。

③ 2 号核心读取变量 B:同样以 Cache Line 为单位读取,载入的数据同样包含 A 和 B。此时两个核心都缓存了同一份数据,两边的 Cache Line 状态都变为「共享(Shared)」状态。

④ 1 号核心修改变量 A:发现该 Cache Line 是「共享」状态,不能直接改,必须先通过总线广播消息通知 2 号核心,把其对应的 Cache Line 标记为「已失效(Invalidated)」;随后 1 号核心的 Cache Line 变为「已修改(Modified)」状态并修改变量 A。

⑤ 2 号核心修改变量 B:此刻 2 号核心的 Cache Line 已是失效状态,且 1 号核心存在该数据的「已修改」副本,因此必须先把 1 号核心的 Cache Line 写回内存,再重新从内存读入 Cache Line 数据,最后才修改变量 B 并标记为「已修改」。

结论:如果 1 号和 2 号核心持续交替地分别修改 A 和 B,就会反复执行 ④ 和 ⑤,Cache 完全起不到缓存的效果。虽然变量 A 和 B 之间毫无逻辑关系,仅仅因为归属于同一个 Cache Line,任意一方被修改都会牵连另一方失效。

于是得到伪共享的定义:

伪共享(False Sharing):多个线程同时读写同一个 Cache Line 内的不同变量,导致 CPU Cache 反复失效的现象。

值得注意的是,MESI 协议的存在正是为了多核缓存一致性(写传播 + 事务串行化),但在"不同变量同处一个 Cache Line"这种场景下,协议本身反而放大了总线交互开销——这就是伪共享成为性能杀手的根本原因。

2.2 避免伪共享的方法一:Cache Line 字节对齐(内核宏)

对于多个线程共享的热点数据(即频繁被修改的数据),应当避免它们恰好落在同一个 Cache Line 中。Linux 内核对此提供了现成的解决方案——__cacheline_aligned_in_smp宏:

/* 内核 include/linux/cache.h 中的定义(示意) */ #ifdef CONFIG_SMP #define __cacheline_aligned_in_smp __cacheline_aligned /* 多核:对齐到 Cache Line */ #else #define __cacheline_aligned_in_smp /* 单核:空定义 */ #endif

从定义可以看出:

  • 多核(SMP)系统下,该宏展开为__cacheline_aligned,即对齐到 Cache Line 大小;
  • 单核系统下,该宏是空的,因为单核不存在跨核心缓存一致性问题,无需付出对齐的空间代价。

举例来说,假设有下面这个结构体,成员a和b在物理内存上连续,可能落在同一个 Cache Line 中:

struct test { long a; long b; /* 与 a 相邻,极可能与 a 位于同一 Cache Line */ };

为避免伪共享,可以给b加上__cacheline_aligned_in_smp宏,把b的地址强制对齐到 Cache Line 边界:

struct test { long a; long b __cacheline_aligned_in_smp; /* 对齐到 Cache Line,与 a 分离 */ };

这样a和b就不会再共享同一个 Cache Line。这种规避方式的本质是"以空间换时间":浪费一部分 Cache 空间,换取性能提升。

2.3 避免伪共享的方法二:字节填充(Disruptor 的经典实践)

再看一个应用层的经典方案:Java 并发框架Disruptor采用「字节填充 + 继承」的方式来规避伪共享。

Disruptor 中的RingBuffer类经常被多个线程并发使用,其代码结构如下(示意):

// Disruptor 源码结构(示意) abstract class RingBufferPad { protected long p1, p2, p3, p4, p5, p6, p7; // 前置填充:7 个无用的 long } abstract class RingBufferFields<E> extends RingBufferPad { // 实际业务字段,如 cursor 等,位于 7 个前置 long 与 7 个后置 long 之间 } public final class RingBuffer<E> extends RingBufferFields<E> { protected long p1, p2, p3, p4, p5, p6, p7; // 后置填充:7 个无用的 long }

这些 7 个long变量看似毫无作用,却对性能起着至关重要的作用。推导如下:

  • 一般 64 位 CPU 的 Cache Line 大小是 64 字节,一个long是 8 字节,因此一个 Cache Line 恰好能容纳 8 个long;
  • 根据 JVM 对象继承关系,父类成员与子类成员的地址是连续排列布局的;
  • 因此RingBufferPad中的 7 个long作为 Cache Line 的前置填充,RingBuffer末尾的 7 个long作为 Cache Line 的后置填充,这 14 个long变量没有任何实际用途,也从不会被读写;
  • RingBufferFields中的业务字段都是final修饰的,第一次加载后不会再被修改。

于是,无论 Cache 如何加载,整个 Cache Line 里都没有会发生更新操作的数据。只要数据被频繁读取访问,就不会因其他核心的写入而被换出 Cache,也就自然杜绝了伪共享问题。


三、CPU 是如何选择线程的?

搞清楚 CPU 读写数据的过程后,另一个核心问题是:CPU 根据什么来选择当前要执行的线程?

3.1 调度对象:task_struct 任务

在 Linux 内核中,进程和线程统一用task_struct结构体表示。二者的区别在于:线程的task_struct中部分资源共享了进程已创建的资源(如内存地址空间、代码段、文件描述符等),因此 Linux 中的线程也被称为轻量级进程(LWP)——因为它比进程的task_struct承载的资源少,故以"轻"得名。

一般来说,没有创建线程的进程只有单个执行流,称为主线程;想让进程处理更多事情,可以创建多个线程分别去处理,但无论多少线程,对应到内核里都是一个个task_struct。所以:

Linux 内核里的调度器,调度对象就是task_struct(下文中统称为任务)。

关于进程/线程的完整知识(PCB 进程控制块、线程模型、上下文切换等),可延伸阅读仓库文档 进程、线程基础知识;关于进程的五种状态、就绪/阻塞队列如何组织,是理解"排队等待 CPU"的重要铺垫。

3.2 任务的分类与优先级范围

在 Linux 系统中,根据任务的优先级以及响应要求,主要分为两类(优先级数值越小,优先级越高):

任务类型优先级范围特点
实时任务0~99对系统响应时间要求很高,需要尽可能快地被执行
普通任务100~139响应时间没有很高要求

3.3 调度类(Scheduling Class)

为了保障高优先级任务能尽早执行,Linux 把调度器划分为多种调度类,其整体优先级顺序为:

Deadline > Realtime > Fair

也就是说,选择下一个任务执行时,会先从dl_rq(Deadline 运行队列)中选择,然后从rt_rq(实时任务队列)中选择,最后才轮到cfs_rq(CFS 普通任务队列)。因此实时任务总是比普通任务优先被执行。

Deadline 与 Realtime 调度类应用于实时任务,二者的调度策略合计有三种:

调度策略工作机制
SCHED_DEADLINE按照 deadline 调度,距离当前时间点最近的 deadline 任务优先被调度
SCHED_FIFO相同优先级任务按先来先服务原则执行;更高优先级任务可以抢占低优先级任务(即"插队")
SCHED_RR相同优先级任务轮流运行,每个任务都有时间片,用完时间片的任务被放到队列尾部,保证同级公平;高优先级任务依然可以抢占低优先级任务

Fair 调度类应用于普通任务,统一由 CFS 调度器管理,包含两种调度策略:

调度策略适用场景
SCHED_NORMAL普通任务使用的默认调度策略
SCHED_BATCH后台任务调度策略,不与终端交互,可在不影响其他交互任务的前提下适当降低其优先级

3.4 完全公平调度(CFS)与 vruntime

我们平日里遇到的几乎都是普通任务,对普通任务来说公平性最重要。Linux 为此实现了基于CFS(Completely Fair Scheduling,完全公平调度)的调度算法。

CFS 的理念是让分配给每个任务的 CPU 时间尽量相等,具体手段是为每个任务安排一个虚拟运行时间 vruntime:

  • 一个任务在运行时,运行得越久,它的vruntime就越大;
  • 没有被运行的任务,vruntime不会变化;
  • CFS 调度时会优先选择 vruntime 少的任务,从而保证公平。

打个比方:把一桶奶茶平均分到 10 个杯子里,你看哪杯少就多倒一些,哪杯多了就先不倒——经过多轮操作,虽然不能保证每杯完全一样多,但至少是公平的。

上面的比喻没考虑优先级。虽然都是普通任务,但普通任务之间仍有优先级区分,因此计算vruntime时还要考虑普通任务的权重值(weight)。注意:权重值并不等于优先级数值,内核中维护着一张nice 级别与权重值的转换表,nice 级别越低,权重值越大。于是有了 vruntime 的核心公式:

vruntime = vruntime + (实际运行时间 × NICE_0_LOAD) / 权重值

(其中NICE_0_LOAD可理解为常量。)

可以直观推导:在同样的实际运行时间里,高权重任务的 vruntime 增长得比低权重任务慢(更少)。而 CFS 优先选择 vruntime 少的任务,所以高权重任务会被优先调度,获得的实际运行时间自然更多。

3.5 CPU 运行队列:任务如何排队

一个系统通常运行着大量任务,数量远超 CPU 核心数,因此需要排队。每个 CPU 都有自己的运行队列(Run Queue,rq),用于描述在该 CPU 上运行的所有进程,由三个子队列组成:

子队列数据结构说明
dl_rq—Deadline 运行队列
rt_rq—实时任务运行队列
cfs_rq红黑树CFS 运行队列,按 vruntime 大小排序,最左侧叶子节点就是下次会被调度的任务

调度类之间的优先级决定了选择顺序:Deadline > Realtime > Fair,即先看dl_rq,再看rt_rq,最后看cfs_rq——实时任务总是先于普通任务执行。

3.6 调整优先级:nice / renice / chrt 实战

如果没有特意指定优先级,任务默认都是普通任务,由 CFS 调度器管理,目标是公平分配 CPU 时间。如果你希望某个普通任务获得更多执行时间,可以调整它的nice 值。

nice 值要点:

  • 设置范围:-20 ~ 19,值越低优先级越高(-20最高,19最低),默认值为 0;
  • nice 值并非优先级本身,而是优先级的修正数值,二者关系为:
priority(new) = priority(old) + nice
  • 内核中 priority 范围为0~139,值越低优先级越高;其中0~99提供给实时任务,nice 值映射到100~139,这个范围供普通任务使用,因此 nice 调整的是普通任务的优先级;
  • 由于 nice 值越低 → 权重值越大 → 计算出的 vruntime 越少 → CFS 越优先调度,所以nice 值越低,任务优先级越高。

实战操作示例:

启动任务时指定 nice 值(例如让mysqld以-3优先级启动):

nice -n -3 mysqld

如果想修改正在运行的任务的优先级,使用renice调整 nice 值(<PID>为目标进程号):

renice -n -3 -p <PID>

注意:nice 调整的是普通任务的优先级,所以不管怎么缩小 nice 值,任务永远都是普通任务,不可能越级成为实时任务。如果某些任务对实时性要求很高,可以考虑同时改变任务的调度策略与优先级,使其变成实时任务,使用chrt命令:

# 将 <PID> 设置为 SCHED_FIFO 实时调度策略,优先级设为 90(0~99 内) chrt -f -p 90 <PID> # 将 <PID> 设置为 SCHED_RR 实时调度策略,优先级设为 90 chrt -r -p 90 <PID>

关于抢占/非抢占、时间片、优先级调度、多级反馈队列等经典调度算法(FCFS、SJF、HRRN、RR、HPF、MFQ),以及对应的典型场景与时间片取值建议(如20ms~50ms的折中值),仓库文档 进程调度/页面置换/磁盘调度算法 有系统化图解,可作为调度体系学习的延伸。


四、总结

理解 CPU 如何执行任务,本质上是理解两条主线:

主线一:CPU 如何读写数据

  • CPU 内部多个 Cache + 外部内存和磁盘构成金字塔存储结构,越往下容量越大、速度越慢;
  • CPU 读写数据以CPU Cache Line(通常 64 字节)为单位,而非逐字节;
  • 操作数组时按内存地址顺序访问,能充分利用 Cache、提升命中率;
  • 操作普通变量且处于多核环境时,须警惕Cache 伪共享:多个线程读写同一个 Cache Line 内的不同变量会导致 Cache 反复失效;
  • 规避伪共享的常见手段包括Cache Line 大小字节对齐(如内核__cacheline_aligned_in_smp宏)与字节填充(如 Disruptor 的 7+7 个 long 填充),本质是"以空间换时间"。

主线二:CPU 如何选择线程

  • Linux 中进程与线程统一用task_struct表示,调度对象就是它;
  • 任务按优先级分为实时任务(0~99)与普通任务(100~139);
  • 调度类优先级为Deadline > Realtime > Fair,普通任务由CFS管理,按vruntime最小者优先调度,并通过权重把 nice 值折算进公平性计算;
  • 每个 CPU 有自己的运行队列(dl_rq/rt_rq/cfs_rq),普通任务队列由红黑树按 vruntime 排序;
  • 若任务对响应要求很高,可通过nice/renice调整普通任务优先级,或用chrt切换调度策略为实时任务。

当系统中运行的线程数远超 CPU 核心数时,排队等待必然带来延迟;如果你的任务对延迟容忍度很低,就可以通过上述手段人为干预 Linux 的默认调度策略与优先级,让关键任务获得更快、更稳定的 CPU 时间。


延伸阅读:本主题的相邻知识点均收录于本仓库os/目录:CPU 缓存命中率与直接映射 Cache 的完整原理见 如何写出让 CPU 跑得更快的代码?;MESI 协议状态机、写直达/写回策略见 CPU 缓存一致性;CPU 执行程序与指令周期见 CPU 是如何执行程序的?;进程/线程基础与 PCB 见 进程、线程基础知识;经典调度算法体系见 进程调度/页面置换/磁盘调度算法。

  • 文档
  • 教程
  • 知识库

【免费下载链接】CS-Base

图解计算机网络、操作系统、计算机组成、数据库,共 1000 张图 + 50 万字,破除晦涩难懂的计算机基础知识,让天下没有难懂的八股文!🚀 在线阅读:https://xiaolincoding.com

项目地址:https://gitcode.com/GitHub_Trending/cs/CS-Base
点击查看免费下载

相关推荐

上一篇:MiniCPM-V多模态训练数据构建深度解析:从数据标注到模型微调实战指南
下一篇:推荐开源项目:Laravel Doctrine ORM

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询