多伦多大学-ECE454-C-语言高性能编程笔记-全-
多伦多大学 ECE454 C 语言高性能编程笔记(全)
001:导论

在本节课中,我们将要学习高性能系统编程课程的基本介绍、课程结构、核心概念以及为什么性能优化如此重要。我们将探讨从编写功能正确的软件到编写高效能软件的关键转变。
课程概述与目标
欢迎来到计算机系统编程课程,或者我更愿意称之为“性能编程”课程。很高兴再次见到许多熟悉的面孔。我是你们的讲师 John。
本课程的目标是,在课程结束时,你们将能够编写不仅是功能正确,而且是高性能的软件。你们目前可能已经能够编写功能正常的软件,但很可能还无法编写高性能的软件。
为什么性能至关重要
我们为什么需要关注软件性能?除了课程名称本身有些模糊外,其核心在于价值。如今,大多数运行在云端服务器上的软件都非常昂贵。即使只提升2%的性能,乘以巨大的规模后,其意义也相当显著。
关注环境与能源消耗也是一个重要原因。编写更高效、性能更好的软件意味着消耗更少的电力。此外,云端服务器经常处于闲置状态。如果你知道如何充分利用所有资源,提高利用率,就意味着你正在有效利用所支付的硬件,这会让你的雇主和你自己都非常满意。
课程结构与物流信息
课程的前半部分主要是介绍课程安排。以下是一些重要的课程链接,请务必使用你的多伦多大学ID登录并关联你的 Discord 和 GitHub 账户。
- 课程网站与资源:讲座材料将公开在我的网站上。实验课材料将使用 GitHub Classroom,目前第一周是空的,但之后会更新。
- 交流平台:课程讨论将全部在 Discord 上进行。我将在 YouTube 上直播讲座并发布录像。
- 匿名提问:为了解决 Discord 无法匿名发送消息的问题,我们创建了一个 Discord 机器人。输入
/anon然后输入你的消息,消息将以机器人的名义发出,对其他人保持匿名。
讲座出勤仍然很重要。现场反馈和实时编码演示能帮助我根据你们的反应调整讲解方式。性能优化领域常常会出现一些奇怪的现象,现场互动有助于澄清疑惑。
实验与考核安排
本课程是讲师和助教第一次讲授,所以请大家多多包涵。我们可能会遇到一些问题,但会尽力解决。课程安排大致如下:
以下是实验和考试的时间线与权重:
- 实验 1:为期一周,占 5%,将于下周发布。
- 实验 2:为期三周,占 9%,截止日期为 10月7日。
- 期中考试:占 20%,暂定于 10月17日那一周。
- 实验 3:为期三周。
- 实验 4 和 5:是较大的实验。实验 5 要求你将课程所学应用于一个软件,使其性能最大化,这应该会非常有趣,截止日期为课程最后一天。
- 期末考试:占 40%。
所有分数加起来为 100 分。
关于学术诚信,你们可以一起学习,在 Discord 上讨论概念,但请不要在 Discord 上发布实验代码的解决方案。请勿作弊,因为检查代码相似性非常容易。如果遇到困难,请尽早寻求帮助。
性能优化的背景与趋势
本课程关注性能,特别是可扩展性和效率。过去,提升性能非常容易。摩尔定律盛行,每 18 个月晶体管数量翻倍,单核速度随之提升。这意味着,要让软件运行速度翻倍,你只需要等待 18 个月然后购买新 CPU 即可。
然而,这种情况已不再成立。单核性能在很久以前就趋于平稳。这主要是由于 CPU 的物理限制。现代系统拥有大量核心,即使是手表也有两个核心。因此,编程时必须考虑并行性。
另一个趋势是使用专用硬件。大多数 CPU 现在都有用于特定功能(如视频编码、AI 矩阵乘法)的专用硬件。还有使用 ASIC(专用集成电路)的趋势,即将 C 函数烧录到硬件中以极速运行。GPU 也被广泛用于需要大规模并行计算的任务,如 AI。
程序员应知的性能数字
既然我们关心性能,就需要了解一些基本的性能数量级。以下是每个程序员都应该知道的延迟数字,它们说明了远离 CPU 计算核心时,性能下降得非常剧烈。
以下是一些关键操作的近似延迟时间,请注意数量级的巨大差异:
- L1 缓存引用:约 1 纳秒。
- 分支预测错误:约 3 纳秒。
- L2 缓存引用:约 4 纳秒。
- 互斥锁(Mutex)加锁/解锁:约 17 纳秒。
- 主内存引用:约 100 纳秒。
- 从 SSD 顺序读取 1MB:约 16 微秒。
- 同一数据中心内的往返延迟:约 500 微秒。
- 从磁盘顺序读取 1MB:约 20 毫秒。
- 从加州到荷兰再返回的互联网数据包往返:约 150 毫秒。

这些数字告诉我们,在编程时应尽量避免磁盘 I/O,并尽可能让数据停留在缓存中。
为了降低程序延迟,我们必须找到瓶颈。例如,完全避免磁盘访问可能带来十万倍的性能提升。使用 Memcached 等缓存层将数据保存在内存中,而不是每次访问磁盘。优化内存分配器,让数据更适合缓存,也能带来百倍的提升。
课程模块与实验设计
本课程将从单线程应用开始,逐步深入到多线程应用。课程模块组织如下:
第一个模块是代码测量。在改进之前,我们需要知道改进什么。主题包括如何找到程序瓶颈、代码优化原则、编译器的作用以及如何剖析程序。相关实验是实验 1(性能剖析)和实验 2(基于剖析结果进行优化)。
第二个模块主要是内存管理,目标是让单核程序运行得更快。主题包括内存层次结构、缓存与局部性、虚拟内存,内容会比操作系统课程更深入。相关实验是实验 2(优化给定代码的内存性能)和实验 3(编写自己的高性能内存分配器,实现 malloc 和 free 等函数)。
第三个模块分为两部分,都是关于并行化。3A 部分是在单台机器上进行并行化(多核、多线程、同步及其性能)。相关实验是实验 4(使用线程和同步加速程序)和实验 5(对给定的游戏模拟进行极致性能优化)。3B 部分是在多台机器上进行并行化(大数据分析框架、云计算、存储系统),由于预算限制可能没有对应实验。
从视觉上看,模块 1 关注单个核心上的代码优化;模块 2 关注内存管理,以更好地利用缓存;模块 3A 引入并行性,处理多核及其共享缓存,构建更真实的系统。
开发环境与代码示例

代码示例将会很多,并会提供给你们。你需要接受 GitHub 组织的邀请才能访问代码仓库。实验的设置指南将帮助你配置开发环境。
对于课程中的示例代码,我使用 Meson 构建系统。你只需要在代码目录中执行 meson setup build 和编译命令即可。实验可能使用不同的构建系统,但我的材料都使用 Meson,可执行文件会在 build 目录中生成。
避免过早优化
有一句著名的格言:过早优化是万恶之源。我们可能自以为知道得更清楚,在没有任何证据的情况下就去优化看似不合理的代码,但这通常是个坏主意。
为了说明这一点,让我们尝试编写一个 cp 命令的克隆程序。一个简单的实现会打开源文件和目标文件,然后循环读取并写入。这个程序功能正常。
然而,有人可能会想:“如果文件是空的(0字节),我为什么还要执行读系统调用呢?我可以先检查文件大小,如果是0就直接退出,避免无用的读操作。” 这看起来是个聪明的优化。
但问题在于,类 Unix 系统中有一些特殊的文件(如 /proc/cpuinfo),它们并不实际存在于磁盘上,其报告的大小为0,但却包含内容(读取时由内核动态生成)。上述“优化”会导致无法正确复制这类文件,使程序从功能正确变为功能错误。
这个例子告诉我们,除非你真正了解系统的所有细节,并且有确凿证据表明优化是必要和安全的,否则不要试图过于聪明。一个不执行任何实际工作的读系统调用所浪费的几纳秒,与程序正确性相比微不足道。
性能度量指标
衡量程序性能最简单的方法之一是加速比。我们通常关注延迟,即完成一个任务需要多少秒。
计算延迟加速比的公式是:
Speedup = L_old / L_new
其中 L_old 是优化前任务的延迟,L_new 是优化后的延迟。例如,原任务耗时1秒,优化后耗时0.5秒,则加速比为 2。
测量延迟的常用工具是 time 命令。使用 /usr/bin/time -p 可以获取三个时间:
- real:实际流逝的“墙钟时间”。
- user:所有 CPU 核心在用户模式下执行指令的时间总和。
- sys:所有 CPU 核心在内核模式下执行指令的时间总和。
对于多核系统,user + sys 时间可能大于 real 时间。
另一个指标是吞吐量,即单位时间内完成的任务数量。

吞吐量 Q 的公式为:
Q = P / L
其中 P 是可同时处理的任务数(初期可视为1),L 是单个任务的延迟。吞吐量本质上是延迟的倒数。

我们需要根据应用场景决定关注延迟还是吞吐量。例如,空客 A380 飞机从多伦多到法兰克福需8小时,可载470人;协和飞机只需4小时,但仅载132人。对于乘客(关注延迟),协和更好;对于航空公司(关注吞吐量,即每小时运送乘客数),空客更优。卫星互联网带宽(吞吐量)很高,但延迟很大,因此不适合在线游戏。
在服务器中,使用更快的 CPU 可以同时降低延迟和提高吞吐量(正相关)。但通过并行处理多个请求,可能会以增加单个请求的延迟为代价,来换取更高的吞吐量(负相关,存在权衡)。
性能提升的理论上限
性能提升存在理论极限,这由阿姆达尔定律描述。该定律指出,程序的整体加速比取决于可被加速部分的比例。
阿姆达尔定律的公式为:
Overall Speedup = 1 / ((1 - P) + (P / S))
其中 P 是程序中可被加速部分的比例,S 是该部分获得的加速比。

即使可加速部分能获得无限大的加速比(S 趋近于无穷大),整体加速比的上限也是 1 / (1 - P)。例如,如果程序中 90% 的代码可以被优化,那么整体加速比的上限是 10 倍。
举例来说,如果一个优化使循环速度提升3倍,而程序 70% 的时间花在循环上,那么整体加速比约为 1.875。
其他性能考量
加速比和吞吐量并非性能的全部。其他重要考量包括:
- 利用率:硬件资源的实际使用率。
- 有效吞吐量:传输的有用数据量(排除协议开销等)。
- 抖动:延迟的一致性。
- 最佳/最差/平均情况:取决于具体需求。
性能评估非常复杂,至今仍是活跃的研究领域。过度专注于性能有时会带来巨大压力。
为什么使用 C++
本课程主要使用 C++ 进行演示。一个简单的排序例子可以说明原因。我们比较四种排序实现:
- C 语言使用
qsort。 - C 语言手写排序(如归并排序)。
- C++ 使用
std::sort对数组排序。 - C++ 使用
std::sort对vector排序。
在速度和可读性上,C++ 版本(尤其是使用 std::sort)通常更具优势。C 语言的 qsort 需要传递函数指针和进行指针转换,这带来了调用开销和间接性。C++ 的模板机制允许编译器在编译时生成针对特定类型的优化代码,内联比较操作,避免了函数调用和额外的解引用开销,从而获得更好的性能。同时,C++ 的语法通常更清晰易读。
使用 vector 和数组的性能差异很小,但 vector 提供了更多便利。因此,为了编写高性能且易于维护的代码,我们选择使用 C++(当然,需要合理使用模板等特性)。
总结

本节课中我们一起学习了高性能系统编程课程的导论。我们了解了课程目标、结构以及性能优化的重要性。我们探讨了从功能正确到高性能软件的转变,学习了关键的性能度量指标(延迟、吞吐量、加速比)以及阿姆达尔定律。我们通过实例明白了“过早优化是万恶之源”的深刻含义,并了解了为什么 C++ 是进行系统级性能编程的合适语言。在接下来的课程中,我们将深入代码测量、内存管理和并行化等具体技术。
002:性能分析入门 🚀

在本节课中,我们将要学习性能分析的基础知识。性能分析是优化程序的关键第一步,它能帮助我们精确地定位程序中消耗时间最多的部分,从而避免盲目优化。
性能分析的必要性
上一节我们介绍了性能编程的基本概念。本节中我们来看看如何具体测量和分析程序性能。
在性能编程中,我们需要精确地测量我们感兴趣的函数所花费的时间。如果仅仅使用系统时间来测量整个程序的运行时间,工作量会非常巨大,并且我们无法知道具体是哪个部分拖慢了速度。盲目优化是万恶之源,因此我们必须通过测量来确保我们优化的是正确的地方。
我们使用性能分析工具来测量程序,以查看程序执行时间具体消耗在哪些部分。这就是实验一的内容。

性能分析工具的类型

性能分析工具主要有两种输出形式:扁平分析和调用图分析。
以下是两种主要的性能分析工具类型:
- 扁平分析器:仅计算特定函数的平均执行时间,不包含更多信息,例如谁调用了它。
- 调用图分析器:计算函数的调用时间、调用频率,并展示一个显示函数调用关系的调用图。调用图以图形方式展示函数间的调用关系,例如
main调用foo,foo再调用其他函数。
性能分析工具收集数据的方式也有两种:
- 统计采样:以固定频率(例如每2毫秒)对系统状态进行采样,检查程序正在执行什么。这会轻微减慢程序运行速度。
- 代码插桩:在编译时或运行时向程序中添加额外的指令来记录数据。例如,Valgrind 就是在运行时进行插桩的工具。

大型软件项目的性能分析指南
对于大型软件项目,我们遵循以下基本准则:
- 首先编写清晰简洁的代码:确保代码正确,避免过早优化。
- 建立性能基线:在确保程序满足基本功能后,进行性能分析以获得一个性能基准。
- 及早重新设计:如果发现程序架构存在根本性的性能瓶颈,应尽早重新设计。
- 聚焦关键代码:将优化精力集中在真正影响性能的代码上。
示例程序分析

让我们通过一个简单的示例程序来实践。这个程序会进行一些随机的整数和浮点数数学运算。
int main() {
long long sum = 0;
for (int i = 0; i < 100000000; ++i) {
sum += int_math(i);
sum += float_math(i);
}
return 0;
}
int_math 和 float_math 函数内部会调用 power 和 helper 函数,其中 power 函数使用一个简单的循环来计算幂。我们的目标是找出这个程序中耗时最多的部分。
本课程将使用的工具

在本课程中,我们将主要使用两个工具:gcov 和 gprof。

以下是这两个工具的简要介绍:
gcov:一个代码覆盖率工具。它通过插桩来统计程序中每一行代码被执行了多少次。它需要调试信息,但不提供性能时间数据。gprof:一个性能分析器。它结合了采样和插桩技术,告诉你每个函数执行了多长时间以及被调用了多少次。
使用 gcov


要使用 gcov,你需要在编译和链接时添加 --coverage 标志。使用 Meson 构建系统时,可以通过 -Dcoverage=true 选项来启用。编译后运行程序,gcov 会生成数据文件。使用 gcovr 命令可以查看文本或 HTML 格式的覆盖率报告。
覆盖率报告会显示每行代码被执行的次数。例如,main 函数中的循环体被执行了 1 亿次,而 int_power 函数总共被调用了 3 亿次。这能帮助我们识别出执行最频繁的代码区域。
使用 gprof
要使用 gprof,你需要在编译和链接时添加 -pg 标志。在 Meson 中,可以通过设置 c_args 和 c_link_args 来添加。编译后运行程序,它会生成一个 gmon.out 文件。使用 gprof <可执行文件名> 命令即可解读性能数据。
gprof 的输出分为两部分:扁平配置文件和调用图。
解读扁平配置文件
扁平配置文件按函数自身消耗的时间排序。以下是对各列的解释:
- % time:程序执行时间中,花费在该函数上的百分比。
- self seconds:估计花费在该函数本身(不包括其调用的子函数)上的秒数。
- calls:该函数被调用的次数。
- self ns/call:每次调用该函数本身平均花费的纳秒数(self seconds / calls)。
- total ns/call:每次调用该函数平均花费的总纳秒数(包括其调用的所有子函数)。
通过扁平配置文件,我们可以快速找到“热点”函数,即最耗时的函数,从而确定优化重点。
解读调用图
调用图展示了函数之间的调用关系和时间分布。它的阅读方式如下:
- 索引行:以索引号开头的那一行是当前正在查看的“主”函数。
- 上方行:列出了调用这个“主”函数的函数(调用者)。
- 下方行:列出了这个“主”函数所调用的函数(被调用者)。
对于“主”函数这一行:
- % time:执行该函数及其所有子函数所花费的时间占总时间的百分比。
- self:与扁平配置文件中的
self seconds含义相同。 - children:该函数调用的所有子函数所花费的时间。
对于调用者/被调用者行:
- self:当从当前行函数调用时,花费在“主”函数本身的时间。
- children:当从当前行函数调用时,花费在“主”函数的子函数上的时间。
- called:从当前行函数调用“主”函数的次数 / “主”函数被调用的总次数。

调用图帮助我们理解时间的传播路径,以及某个函数在不同调用上下文中的性能表现。
注意:在 gprof 的输出中,你可能会看到一个名为 _init 的函数占用了相当比例的时间(例如18%)。这通常是性能分析工具自身开销的误报,可以忽略。这也是为什么从 main 函数开始的累计时间百分比不是100%的原因。
实验一提示

在实验一中,你将使用这些工具来分析一个给定的代码库。你还需要尝试不同的编译器标志组合(例如开启/关闭调试信息 debug 和优化级别 optimization)并观察它们对性能分析结果的影响。你可以使用 Meson 的 -D 选项来设置这些标志,例如 -Ddebug=false -Doptimization=2。
总结

本节课中我们一起学习了性能分析的基础知识。我们了解到性能分析是优化程序的第一步,它帮助我们定位性能瓶颈。我们介绍了两种主要的分析工具:用于代码覆盖率的 gcov 和用于性能分析的 gprof。我们学习了如何生成和解读 gprof 的扁平配置文件与调用图,从而找出程序中最耗时的部分。掌握这些技能将使你能够有针对性地进行优化,从而显著提升程序性能。
003:CPU与编译器 🚀

在本节课中,我们将学习CPU架构的基础知识以及编译器如何优化我们的代码。理解这些硬件和软件层面的原理,对于编写高性能程序至关重要。
CPU架构演进之旅 💻
上一节我们介绍了性能分析工具,本节中我们来看看程序运行的硬件基础——CPU。为了优化程序,我们需要对硬件有基本的了解。
性能衡量指标
如何衡量单个CPU核心的性能?更高的频率是否意味着更快的核心?答案是否定的。有许多因素需要考虑,包括:
- IPS:每秒指令数。
- FLOPS:每秒浮点运算次数。
- IPC:每时钟周期指令数。
- CPI:每条指令周期数(CPI是IPC的倒数)。
这些指标衡量的是吞吐量,而非延迟。虽然现代计算机速度很快,延迟通常不是主要性能瓶颈,但在多核时代,理解这些概念依然重要。
历史回顾:从简单到复杂
CPU设计经历了漫长的演进。早期的处理器(如Intel 4004)没有流水线、虚拟内存甚至中断。指令按顺序执行,CPI等于流水线阶段数(例如5个阶段,CPI就是5)。
随后,流水线技术被引入。其核心思想是让处理器不同单元在每个时钟周期同时工作,处理指令的不同阶段。理想情况下,这能将CPI降低到接近1。
然而,流水线遇到了分支指令的挑战。CPU需要知道下一条指令是什么,但分支结果在执行后才能确定。最初的解决方案是流水线停顿,但这会降低性能。
为了解决分支问题,现代CPU采用了分支预测技术。CPU根据历史记录猜测分支走向,并开始推测执行。如果预测正确,则性能大幅提升;如果错误,则回退执行结果。现代分支预测器的准确率通常高达95%左右。
为了进一步提升性能,CPU设计走向了超标量和乱序执行。
- 超标量:通过复制硬件单元,在单个时钟周期内执行多条相互独立的指令(指令级并行,ILP)。
- 乱序执行:CPU不再严格按照程序顺序执行指令,而是动态调度,只要最终结果一致即可。这主要是为了隐藏缓存未命中等延迟。
此外,还有同步多线程技术(如Intel的Hyper-Threading)。它让单个物理核心能同时执行两个线程的指令,以更好地利用闲置的硬件资源。不过,这项技术的实际收益有限,现代CPU有逐渐弃用的趋势。
核心要点总结
CPU性能提升的历程,本质上是降低CPI(每条指令周期数)的旅程:
- 顺序执行:CPI等于流水线阶段数(例如5)。
- 流水线:将CPI降低至接近1。
- 分支预测与推测执行:克服分支导致的停顿,使CPI稳定在1左右。
- 超标量与乱序执行:通过指令级并行,使CPI低于1,实现超线性缩放。
- 同步多线程:旨在提供更多指令选择以保持CPI低于1,但收益有限。
如今,硬件已经非常智能。对于软件开发者而言,最主要的性能关注点在于缓存。数据的布局和减少间接访问的层级,对性能有巨大影响。
编译器优化揭秘 🛠️
了解了硬件如何“加速”代码后,我们来看看软件的搭档——编译器。大多数人使用-O2或-O3选项时,并不清楚编译器具体做了什么。本节将揭开这部分魔法。
编译器优化等级
GCC等编译器提供不同的优化等级,各有侧重:
- -O0:默认级别。不进行优化,编译速度最快,生成的代码最易于调试。
- -O1(或
-O):进行一些能明显减小代码体积或缩短执行时间且无副作用的优化。这是“免费的”性能提升。 - -O2:进行所有不涉及空间与速度权衡的优化。这是大多数Linux发行版的默认编译选项,在代码大小和运行速度间取得了良好平衡。
- -O3:进行所有优化,包括可能显著增加代码体积的优化。
- -Ofast:在
-O3基础上,进行不符合严格语言标准(如IEEE浮点规则)的激进优化,以换取更高速度。适用于对精度要求不高的场景。
重要提示:使用-O2或-O3编译的程序不适合用GDB调试,因为优化会改变代码结构,导致调试信息与执行流不符。
标量优化
编译器擅长对单个值进行优化,这些优化通常在所有级别开启。
常量折叠与传播
编译器会在编译时计算常量表达式,并将结果直接嵌入代码。
int i = 128 * 128; // 编译时直接计算为 16384
公共子表达式消除
如果同一表达式被多次计算,编译器会计算一次并复用结果。
a = b * c + g;
d = b * c + d;
// 编译器会生成类似如下的中间代码:
temp = b * c;
a = temp + g;
d = temp + d;
复制传播
如果一个变量只是另一个变量的副本,编译器会直接使用原变量。
y = x;
z = 3 + y;
// 优化为:
z = 3 + x;
死代码消除
编译器会移除永远不会执行的代码。
if (0) {
z = x + y; // 整个代码块会被删除
}
循环优化
循环是性能优化的关键区域,编译器会进行多种变换。
循环展开
将循环体复制多次,减少循环开销。
for (int i = 0; i < 4; i++) f(i);
// 可能被展开为:
f(0); f(1); f(2); f(3);
编译器会根据循环次数权衡展开的收益,避免代码膨胀过度。
循环交换
为了改善内存访问模式(缓存友好性),编译器会交换嵌套循环的顺序。
for (int i = 0; i < N; i++)
for (int j = 0; j < M; j++)
array[j][i] = ...; // 按列访问,不连续
// 编译器可能交换循环,改为按行访问:
for (int j = 0; j < M; j++)
for (int i = 0; i < N; i++)
array[j][i] = ...; // 按行访问,连续
循环融合与裂变
- 融合:将两个相邻的、迭代范围相同的循环合并为一个。
- 裂变:将一个循环拆分成多个。这两种优化需要在循环开销和数据局部性之间权衡。
循环不变量外提
将循环内不变的计算移到循环外部。
for (int i = 0; i < 100; i++) {
int s = x * y; // x, y 在循环中不变
array[i] = i * s;
}
// 优化为:
int s = x * y;
for (int i = 0; i < 100; i++) {
array[i] = i * s;
}
其他重要优化
去虚拟化
对于C++的虚函数调用,如果编译器能在编译时确定具体调用哪个函数,就会绕过虚函数表查找,直接进行静态调用,提升性能。
尾调用消除
如果函数的最后一步是调用另一个函数(或自身,即尾递归),编译器可以优化掉这次调用的栈帧开销,将其转换为跳转或循环。
int factorial(int n, int acc = 1) {
if (n <= 1) return acc;
return factorial(n - 1, acc * n); // 尾递归,可优化为循环
}
与编译器沟通:restrict 关键字
restrict是C99引入的类型限定符,仅用于指针。它向编译器承诺:此指针是访问其所指内存的唯一途径(即不存在指针别名)。这能帮助编译器进行更激进的优化,例如减少冗余的内存加载。
void update(int* restrict a, int* restrict b, int* val) {
*a += *val;
*b += *val; // 编译器知道val不会被a或b修改,可能无需重新加载*val
}
⚠️ 重要警告:如果你对编译器撒谎(即restrict指针实际上存在别名),将导致未定义行为,程序可能产生错误结果。除非你非常确信且能从中获得显著性能提升,否则应谨慎使用restrict。
编译器内部表示
编译器在优化时,通常会将代码转换为一种称为三地址码或中间表示的形式。它类似于汇编,但假设有无限多个寄存器,便于进行分析和转换。例如,GCC的中间表示叫做GIMPLE。你可以使用-fdump-tree-gimple选项来查看它,这有时比直接阅读汇编更清晰。
辅助编译器优化
分支预测提示
你可以使用__builtin_expect内建函数或likely/unlikely宏,向编译器提示分支的走向概率,帮助它生成更优的代码布局。
if (__builtin_expect(condition, 1)) { // 提示condition很可能为真
// 快速路径代码
}
架构特定优化
使用-march和-mtune标志可以针对特定CPU架构(如-march=native)进行优化,启用特殊的指令集(如AVX)。这能提升在目标机器上的性能,但会牺牲可移植性。
总结与工具 🎯
本节课中我们一起学习了CPU架构的演进历史和编译器执行的关键优化。
- CPU方面:我们了解了流水线、分支预测、超标量、乱序执行等技术如何协同工作,将CPI降至1以下,从而提升指令吞吐量。
- 编译器方面:我们探讨了不同优化等级的含义,以及常量传播、循环优化、尾调用消除等具体优化技术。我们还学习了如何通过
restrict关键字和分支提示与编译器沟通。
记住黄金法则:过早优化是万恶之源。首先编写清晰、正确的代码,然后利用性能分析工具找到瓶颈,最后再考虑应用这些高级优化知识。
实用工具推荐:Compiler Explorer (godbolt.org) 是一个绝佳的网站,可以让你在线编写代码,实时查看不同编译器、不同优化选项下生成的汇编或中间代码,是学习和试验编译器优化的利器。

希望这些知识能帮助你更好地完成实验,并理解高性能编程背后的原理。
004:性能示例 🚀

在本节课中,我们将通过具体的性能示例,深入探讨编译器优化、数据结构选择以及性能分析工具的实际应用。我们将重点关注内联优化、向量与链表的性能对比,以及如何使用 perf 工具分析缓存行为。
概述
上一讲我们介绍了一系列编译器优化。核心结论是:你应该专注于编写可读性强的代码,编译器通常比你更擅长进行简单的优化。性能分析的目标是集中精力在能带来最大性能提升的地方。此外,应尽可能多地为编译器提供正确的信息,以获得更好的性能。
内联优化详解
上一节我们介绍了编译器优化的理念,本节中我们来看看一个最具影响力的优化:内联。
内联优化如其名,它将函数体直接“移动”到调用它的地方,从而消除函数调用的开销。例如,一个简单的访问器方法:
class Point {
private:
int x;
public:
int getX() { return x; }
};
// 调用
Point p;
std::cout << p.getX();
经过内联优化后,编译器生成的代码本质上等同于直接访问成员变量 x:
std::cout << p.x; // 概念上的等价代码
这消除了函数调用和返回的指令开销。如果涉及虚函数,内联还会与去虚拟化优化协同工作。

然而,内联也存在权衡:
- 优点:消除函数调用开销,并为后续优化(如常量折叠、公共子表达式消除)创造更多机会。
- 缺点:如果函数体较大或在多个地方被调用,会导致代码体积(二进制文件大小)显著增加。这可能会因指令缓存未命中而降低运行时性能。
C++中的 inline 关键字仅是对编译器的建议。编译器会根据自身的启发式规则(如函数大小、调用频率)决定是否真正内联。盲目使用 inline 不一定能提升性能,甚至可能适得其反。因此,必须通过性能分析来验证优化效果。
正是内联优化,使得我们在之前的排序实验中,C++版本的性能大幅超越C版本。Java虚拟机(JVM)也采用了类似的热点代码检测与内联策略。
数据结构性能对比:向量 vs 链表

在讨论了编译器优化后,我们转向一个经典的性能抉择:在特定任务中,选择向量(动态数组)还是链表?
假设我们有一个任务:
- 向一个数据结构中插入 N 个随机整数,并始终保持其有序。
- 随后,按随机给定的索引,逐个移除所有元素。
以下是两种数据结构操作的理论时间复杂度分析:
| 操作 | 向量 (Vector/Array) | 链表 (Linked List) |
|---|---|---|
| 插入 | 搜索:O(log N) (二分查找) 插入:O(N) (可能需要移动后续所有元素) |
搜索:O(N) (线性遍历) 插入:O(1) (修改指针) |
| 移除 | 访问:O(1) (按索引) 移除:O(N) (可能需要移动后续元素) |
访问:O(N) (遍历到索引处) 移除:O(1) (修改指针) |
仅从理论复杂度看,链表在插入和移除操作上似乎有优势,因为它避免了移动大量数据。

实际性能测试

我们编写了一个测试程序,使用C++模板对向量(std::vector)和链表(std::list)进行上述操作并计时。
以下是测试的核心逻辑片段:
template<typename Container>
void test(Container& c, int* insertData, int* removeIndices, int N) {
// 计时:有序插入
for (int i = 0; i < N; ++i) {
auto pos = std::lower_bound(c.begin(), c.end(), insertData[i]);
c.insert(pos, insertData[i]);
}
// 计时:按随机索引移除
auto it = c.begin();
for (int i = 0; i < N; ++i) {
std::advance(it, removeIndices[i]);
it = c.erase(it);
}
}

测试结果令人惊讶:对于中等规模的数据(如数万个元素),向量的性能显著优于链表,速度可能快一个数量级甚至更多。
性能差异的核心原因:缓存局部性
理论复杂度忽略了现代计算机体系结构的一个关键因素:缓存。
- 向量:数据在内存中是连续存储的。当CPU访问一个元素时,会将其周围的一大块数据(一个缓存行)加载到高速缓存中。后续的顺序访问很可能直接在缓存中命中,速度极快。即使插入/删除需要移动数据,这些移动操作也是高效的顺序内存访问。
- 链表:每个节点分散在堆内存的不同位置。遍历链表时,每次跳转到下一个节点都可能导致缓存未命中,必须从更慢的主内存中加载数据。这种随机内存访问模式对缓存极不友好。
使用Linux性能分析工具 perf 可以量化这一点:
- 运行向量版本:缓存未命中率可能低于 1%。
- 运行链表版本:缓存未命中率可能高达 90% 以上。
巨大的缓存未命中率差异是链表性能远低于理论预期的根本原因。
没有放之四海而皆准的方案
当然,链表并非一无是处。如果我们改变数据访问模式:
- 始终在头部插入和删除:链表确实能展现出O(1)的优势。
- 存储的元素非常大:移动元素的成本可能超过缓存优势。
关键在于:性能取决于实际的数据和使用模式。因此,必须使用具有代表性的数据进行性能剖析,避免为错误的情况进行优化。
更多性能建议与工具
在对比了数据结构之后,我们来总结一些通用的性能准则并介绍强大的分析工具。
通用性能建议
- 精简数据结构:不要存储不必要的数据。选择合适的最小数据类型(例如,用
uint8_t代替int存储0-100的值)。 - 追求可预测的内存访问:顺序访问优于随机访问。
- 默认选择向量:在不确定时,
std::vector通常是更好的默认选择,因其出色的缓存局部性。 - 利用抽象编程:使用C++模板等抽象机制,可以轻松地在不同实现(如向量和链表)之间切换和测试,而无需重写大量代码。
- 编写可读的代码:可读的代码是优化和维护的基础。难以理解的代码(如深度嵌套的宏、随处可见的
void*)会阻碍性能分析和优化。
性能分析工具:perf
perf 是一个强大的Linux性能剖析工具,可以直接访问硬件性能计数器。
基本用法:
perf stat -e cache-references,cache-misses,instructions,cycles ./your_program
它可以提供的信息包括:
- 任务时钟时间、CPU利用率
- 指令数、周期数,并计算 IPC(每周期指令数)
- 缓存引用和缓存未命中次数及比率
- 分支预测次数及误预测次数
- 缺页异常次数
这些硬件层面的数据(尤其是缓存未命中率)对于解释像向量/链表对比这样的性能现象至关重要。
总结
本节课中我们一起学习了:
- 内联优化的原理与权衡,它是提升函数调用密集型代码性能的关键。
- 数据结构选择的实践:通过向量与链表的对比,我们认识到缓存局部性对实际性能的影响往往超过理论时间复杂度。在多数情况下,连续存储的向量因更好的缓存友好性而表现更佳。
- 性能分析的方法:强调使用
perf等工具进行实证分析,关注缓存未命中率等硬件指标,并使用代表性数据来指导优化方向。 - 核心编程理念:始终从编写清晰、可读的代码开始,在性能分析指出瓶颈后,再有针对性地进行优化,并充分考虑数据访问模式与硬件特性。

记住,在性能优化领域,“视情况而定”是常态, profiling(性能剖析)是你最可靠的导航仪。
005:程序优化

在本节课中,我们将学习如何对一个简单的C语言向量(动态数组)求和函数进行性能优化。我们将从最基础的版本开始,逐步应用各种优化技术,并观察每一步带来的性能提升。课程的核心是理解编译器优化的局限性,并学习如何通过手动调整代码来充分利用现代CPU的硬件特性。

概述:我们的优化目标
我们将优化的函数名为 combined,其功能是遍历一个整数向量,将所有元素通过一个可配置的操作(如加法、乘法)累加起来。初始版本代码功能正确但性能低下。
我们的性能指标是 每个元素消耗的CPU周期数 (Cycles Per Element)。目标是从初始的20个周期/元素,通过优化降低到接近甚至低于1个周期/元素。
初始代码框架:
// 向量结构(简化)
typedef struct {
int* data;
size_t length;
} vector_t;

// 需要优化的函数
void combined(int* dest, vector_t* v, int identity) {
*dest = identity;
for (size_t i = 0; i < vector_length(v); i++) {
int val;
vector_get_element(v, i, &val); // 获取元素值
*dest = *dest OP val; // OP 是编译时定义的操作(如 + 或 *)
}
}


优化步骤1:启用编译器优化
上一节我们介绍了性能基准。本节中我们来看看最直接有效的优化手段:让编译器为我们工作。

编译器优化是提升性能最简单、最安全的方法。在没有任何优化标志(-O0,通常用于调试)的情况下,我们的函数需要 20个周期/元素。

操作:在编译时添加 -O2 优化标志。
结果:性能立即提升一倍,达到 10个周期/元素。
原理:-O2 优化级别启用了包括更好的寄存器分配、指令调度和内联小型函数在内的多种优化,但尚未触及算法层面的问题。


优化步骤2:手动提升循环不变量



尽管使用了 -O2,编译器仍然不够“聪明”。观察循环条件 i < vector_length(v),每次迭代都会调用 vector_length 函数。然而,向量的长度在循环中不会改变,这个调用是“循环不变量”。
循环不变量代码移动 (Loop-Invariant Code Motion) 是一种优化,将循环中不变的计算移到循环外部。


优化后代码:
void combined(int* dest, vector_t* v, int identity) {
*dest = identity;
size_t len = vector_length(v); // 将长度计算移出循环
for (size_t i = 0; i < len; i++) { // 直接使用局部变量
int val;
vector_get_element(v, i, &val);
*dest = *dest OP val;
}
}
结果:性能从 10 提升到 7 个周期/元素。
为何编译器不做?编译器在编译 combined 时,只看到 vector_length 的函数声明,不知道其内部实现。它必须假设这个函数可能有“副作用”(例如修改全局状态、打印日志),因此不能安全地将其移出循环。
优化步骤3:手动内联函数调用
在循环体内,vector_get_element 函数调用开销很大。它内部可能包含边界检查等逻辑。对于我们的场景,我们知道索引 i 始终在边界内,因此可以安全地手动内联这个操作。

优化思路:直接获取向量数据数组的起始指针,然后通过数组索引访问。
优化后代码:
void combined(int* dest, vector_t* v, int identity) {
*dest = identity;
size_t len = vector_length(v);
int* data = vector_start(v); // 获取数据数组起始地址
for (size_t i = 0; i < len; i++) {
*dest = *dest OP data[i]; // 直接数组访问,省去函数调用
}
}
结果:性能产生巨大飞跃,从 7 提升到 1.78 个周期/元素。
关键点:消除函数调用开销和冗余的边界检查是此步性能提升的主要原因。

优化步骤4:减少内存访问
目前,每次迭代我们都要对 *dest 进行“读-修改-写”操作。访问内存(即使是缓存)的速度远慢于访问寄存器。
优化思路:使用一个局部变量作为累加器,只在循环结束后将结果写回 dest。
优化后代码:
void combined(int* dest, vector_t* v, int identity) {
int acc = identity; // 使用局部变量作为累加器
size_t len = vector_length(v);
int* data = vector_start(v);
for (size_t i = 0; i < len; i++) {
acc = acc OP data[i];
}
*dest = acc; // 循环结束后,一次性写回内存
}
结果:性能从 1.78 轻微提升到 1.6 个周期/元素。提升不大是因为原来的内存访问已被缓存很好地处理,但此优化在更复杂的场景下可能更有益。
核心概念:局部性原理。编译器更容易将局部变量优化到寄存器中,而内存地址可能被其他线程或函数访问,编译器优化时必须保守。


优化步骤5:循环展开
循环控制(i++,条件判断)本身也有开销。循环展开 (Loop Unrolling) 通过在一次迭代中处理多个数据元素来分摊这种开销,同时也为CPU的指令级并行提供了更多机会。

手动展开2次的示例:
void combined(int* dest, vector_t* v, int identity) {
int acc = identity;
size_t len = vector_length(v);
int* data = vector_start(v);
size_t i = 0;
// 每次迭代处理2个元素
for (; i + 1 < len; i += 2) {
acc = acc OP data[i];
acc = acc OP data[i + 1];
}
// 处理剩余元素
for (; i < len; i++) {
acc = acc OP data[i];
}
*dest = acc;
}
我们测试了展开2、3、4、8、16次的效果。发现展开4次时性能有显著提升,这是因为触发了编译器的自动向量化。


使用编译器自动展开:我们也可以使用 -funroll-loops 编译标志让编译器自动决定展开因子。在本例中,编译器选择了展开8次。

结果对比:
- 手动展开4次:~1.1 周期/元素
- 手动展开8次:~0.7 周期/元素
- 编译器展开8次:~0.9 周期/元素

为何展开有效:
- 减少分支:减少了循环条件判断的次数。
- 增加指令级并行:CPU可以同时执行多个独立的加载和加法操作。
- 启用向量化:为编译器使用SIMD指令创造了条件。


深入分析:SIMD向量化
性能在展开4次和8次后大幅提升的关键,是编译器生成了 SIMD (Single Instruction, Multiple Data) 指令。
什么是SIMD?
- SIMD允许一条指令同时对多个数据执行相同的操作。
- 例如,SSE/AVX指令集提供了128位、256位甚至512位的寄存器(XMM, YMM, ZMM)。
- 一个128位的XMM寄存器可以存放4个32位整数。一条
PADDD(压缩整数加法)指令可以一次性完成这4对整数的加法。



编译器生成的向量化汇编代码示意:
; 将 data[i] 开始的4个整数加载到 XMM0 寄存器
movdqu xmm0, [rdx + rax*4]
; 将累加器中的4个整数加载到 XMM1 寄存器
movdqu xmm1, [rsi]
; 执行4个整数并行加法
paddd xmm1, xmm0
; 将结果存回累加器位置
movdqu [rsi], xmm1
这使得我们实现了 超线性加速,即每个元素消耗的周期数小于1,因为硬件在每个周期内处理了多个数据。
优化步骤6:改变结合顺序以提升并行度


对于满足结合律的操作(如整数加法、乘法),我们可以改变计算顺序来打破数据依赖链,从而暴露更多并行性。

原始顺序(左结合):acc = ((((acc + data[0]) + data[1]) + data[2]) + data[3]) ...
这形成了一个长依赖链,每个加法都必须等待前一个加法的结果。
优化顺序(两两结合):
// 假设展开8次,并手动分组
acc = (acc OP data[0]) OP data[1]; // 组1
int acc2 = data[2] OP data[3]; // 组2,独立计算
int acc3 = data[4] OP data[5]; // 组3,独立计算
int acc4 = data[6] OP data[7]; // 组4,独立计算
// 然后合并各组结果
acc = (acc OP acc2) OP (acc3 OP acc4);
结果:此手动优化版本(在SIMD出现前)是性能最好的,达到了约 0.7 周期/元素。它通过创建多个独立的累加路径,让CPU能同时执行更多操作。

重要启示:在现代编译器能够自动向量化后,这种复杂的、难以阅读的手动优化可能不再是最优选择。简单清晰的代码反而更容易被编译器优化。


性能优化总结表
以下是本节课所有优化步骤的效果总结:
| 优化步骤 | 关键改动 | 周期/元素 | 加速比 |
|---|---|---|---|
| 1. 初始版本 | 无优化 (-O0) |
20.0 | 1.0x |
| 2. 编译器优化 | 添加 -O2 标志 |
10.0 | 2.0x |
| 3. 提升循环不变量 | 手动将 vector_length 移出循环 |
7.0 | ~2.9x |
| 4. 手动内联访问 | 直接使用数组指针,消除函数调用 | 1.78 | ~11.2x |
| 5. 使用局部累加器 | 减少内存读写,使用寄存器 | 1.60 | ~12.5x |
| 6. 循环展开 (4次) | 手动展开循环4次,触发自动向量化 | ~1.10 | ~18.2x |
| 7. 循环展开 (8次) | 手动展开循环8次,更好利用SIMD | ~0.70 | ~28.6x |
| 8. 改变结合顺序 | 手动分组计算以增加指令级并行 | ~0.65 | ~30.8x |
最终,我们实现了超过 40倍 的性能提升。

核心要点与最佳实践总结
本节课中我们一起学习了从编译器选项到底层手工调整的一系列程序优化技术。以下是需要牢记的核心要点:
- 信任但验证编译器:始终先使用高级别优化(如
-O2,-O3)。这是性价比最高的优化。 - 理解编译器的局限:编译器无法优化具有潜在副作用的函数调用、通过指针的别名访问等。需要程序员通过领域特定知识提供帮助。
- 性能分析是关键:永远基于性能剖析数据(如使用
perf工具)进行优化,避免盲目优化非热点代码。 - 偏好局部变量,慎用指针:局部变量生命周期清晰,利于编译器优化到寄存器。原始指针难以进行别名分析,会阻碍优化。
- 利用现代CPU特性:循环展开可以帮助编译器生成 SIMD 向量化指令,这是实现超线性加速的关键。
- 代码可读性与性能的平衡:最精巧的手动优化代码可能随着编译器升级而变得不再最优。写出清晰、简单的代码,通常能为编译器的优化器提供最好的基础。
- 优化是迭代和情境化的:没有放之四海而皆准的优化规则。性能特征因处理器架构、编译器版本和具体问题而异,需要不断测量和实验。

通过本课的学习,你应该掌握了系统化分析和改进程序性能的基本方法论,并能够在实际项目中应用这些技术来挖掘硬件潜力。
006:高级编译器使用 🚀





在本节课中,我们将学习如何通过使用不同的编译器、启用链接时优化和配置文件引导优化等高级技术,来帮助编译器生成性能更佳的代码。我们将通过实际演示,对比不同编译器和优化选项的效果。

编译器选择:Clang vs. GCC
上一节我们介绍了基本的编译器优化。本节中,我们来看看不同的编译器如何影响代码性能。除了默认的GCC,Clang(基于LLVM)是另一个强大的选择。
Clang是一个C语言前端,它不直接生成机器码,而是生成LLVM中间表示。LLVM随后负责编译和生成最终的机器码。LLVM IR采用单静态赋值形式,使其相对可读。
以下是如何在构建系统中指定使用Clang编译器:
CC=clang meson setup build_clang
我们使用上节课的代码进行测试。以下是使用GCC(O2优化级别)时,不同版本combined函数的性能数据(周期/元素)回顾:
combined 1(使用指针和函数调用): ~10.0combined 2(将vec_length移出循环): 略有改善combined 3(改用数组索引并移出vec_start): ~1.7combined 4(使用局部变量): ~1.5- 后续手动循环展开后,GCC才实现向量化,达到约0.55。

使用Clang(O2优化级别)编译相同代码,结果如下:
combined 3版本时,Clang自动实现了向量化,性能达到约1.7。- 在某些手动展开版本中,Clang的表现略优于GCC。
Clang的LLVM IR在中间表示层面就支持向量化操作。我们可以通过工具查看生成的LLVM IR,其中可以看到如 <4 x i32> 这样的向量化加载和运算指令。这表明Clang在某些情况下能比GCC更早、更智能地进行向量化优化。

链接时优化

链接时优化是一种强大的全局优化技术。它允许编译器在链接阶段,看到所有模块的代码后再进行优化,从而做出更明智的决策,例如跨模块内联函数。

在Meson构建系统中,启用LTO非常简单:
meson setup build_lto -Db_lto=true
我们测试了在GCC下启用LTO的效果:
- 在
combined 1版本中,性能直接从 ~10.0 提升到了 ~1.7。这是因为编译器能够内联vec_length和get_vec_element等函数,消除了函数调用开销并暴露了更多优化机会。 - 对于后续已经较优的版本,LTO带来的提升不明显。
- 缺点:LTO会增加编译时间和内存消耗,对于大型项目可能影响显著。LLVM提供了“ThinLTO”来缓解此问题。此外,在调试时不应使用LTO,因为它可能优化掉部分代码。
Clang与链接时优化的组合
既然Clang本身表现不错,那么结合LTO效果如何呢?
我们同时使用Clang并启用LTO进行编译。结果令人惊讶:
- 对于
combined 4及之后的版本,Clang+LTO将整个计算循环优化掉了,执行时间趋近于零。 - 原因在于,编译器通过全局分析发现,
main函数中combined函数的结果从未被使用,且没有其他指针别名指向目标内存,因此判定整个计算是无效的,可以安全删除。
这展示了在拥有完整程序视图时,编译器所能进行的激进优化。相比之下,GCC即使启用LTO也未能做到这一点。这提醒我们:
- 在开发调试时,务必使用非优化(
-O0)或调试构建,否则可能无法跟踪被优化掉的代码。 - 编写代码时,合理使用局部变量通常有助于编译器分析。

配置文件引导优化
PGO通过将程序实际运行时的行为数据反馈给编译器,来指导其做出更好的优化决策,例如更准确地预测分支走向、优化代码布局等。

在Meson中使用PGO分为两步:
- 生成配置文件:
meson setup build_pgo -Db_pgo=generate cd build_pgo ninja # 运行代表性工作负载以生成 .gcda 文件 ./benchmark - 使用配置文件重新编译:
meson configure -Db_pgo=use ninja
我们在GCC上测试了PGO的效果:
- 对于
combined 4版本,性能从 ~1.5 显著提升至 ~0.6。 - 编译器利用运行时信息,更早地成功应用了向量化等优化。
- 注意:需要提供具有代表性的工作负载来生成配置文件,否则可能误导编译器。同时,要确保程序正常退出以生成完整的性能数据文件。


总结与核心建议


本节课中我们一起学习了多种高级编译器使用技巧:
- 尝试不同编译器:Clang(LLVM)在某些场景下可能比GCC更智能,特别是自动向量化方面。
- 启用链接时优化:这为编译器提供了全局视野,常能带来显著的性能提升,尤其是对于多文件项目。建议在发布构建中默认启用。
- 使用配置文件引导优化:通过提供真实的运行时数据,可以进一步解锁编译器的优化潜力。

核心建议:
- 避免过早优化:首先应专注于选择正确的数据结构和算法。编译器无法替你完成这些工作。
- 信任编译器:你的任务是编写清晰、正确的代码,并为编译器提供尽可能多的信息(如通过LTO和PGO)。手工进行微优化(如手写汇编或复杂的循环展开)通常是最后的手段,且容易出错。
- 基准测试:任何优化前后都必须进行测量,以验证其实际效果。

记住:最快的计算是根本不需要执行的计算。在编译器力所能及的范围内,充分利用这些高级功能;然后将你的精力集中在编译器无法处理的、更高层次的优化上。
007:存储层级 🧠


在本节课中,我们将要学习计算机系统中的存储层级结构,特别是CPU缓存的工作原理。理解这些概念对于编写高性能的C语言程序至关重要。

概述
存储层级是计算机系统中不同速度和容量的存储设备的层次结构。从快速的CPU寄存器到慢速但容量巨大的硬盘,每一层都在速度与容量之间进行权衡。本节课我们将重点关注CPU缓存,它是位于CPU和主内存之间的高速存储器,能显著提升程序性能。
上一节我们介绍了性能优化的基本概念,本节中我们来看看如何利用存储层级,特别是CPU缓存,来提升程序效率。
存储层级结构
计算机内存是一个巨大的层次结构,在容量和速度之间进行权衡。
- 寄存器:位于层次结构的顶端,速度最快,容量最小,直接位于CPU内部的计算单元旁。
- CPU缓存:通常分为三级(L1、L2、L3),作为主内存的缓存。
- 主内存(RAM):容量更大,但速度比缓存慢得多。
- 非易失性存储:如NVMe固态硬盘、传统固态硬盘和机械硬盘,容量极大但速度较慢。
- 磁带驱动器:用于超大规模、低成本的数据归档存储。
本节课我们将集中讨论CPU缓存。
缓存基础机制
任何类型的缓存都通过按块传输内存来工作,而不是一次传输一个字节。
缓存机制的核心思想是:较小、较快、较昂贵的内存(缓存)持有较大、较慢、较便宜内存(如主内存)中数据块的一个子集。当CPU需要访问数据时,如果数据已在缓存中(命中),则可以直接快速获取;否则(未命中),则需要从主内存中加载数据块到缓存中,这会产生性能开销。
以下是缓存命中和未命中的过程示意:
- 缓存命中:CPU请求的数据块(例如块14)已在缓存中,数据直接从缓存传输给CPU。
- 缓存未命中:CPU请求的数据块(例如块12)不在缓存中。缓存需要从主内存中获取该块,将其加载到缓存中,并可能根据替换策略淘汰一个现有的块(受害者),最后再将数据提供给CPU。
缓存需要两种策略:
- 放置策略:决定一个新的数据块应放入缓存的哪个位置。
- 替换策略:当缓存已满且需要放入新数据时,决定淘汰哪个旧数据块(例如最近最少使用策略)。
缓存性能指标
衡量缓存性能有几个关键指标:
- 未命中率:内存访问中未在缓存中找到的比例。计算公式为:
未命中率 = 未命中次数 / 总访问次数。通常L1缓存的未命中率在3%到10%,L2则低于1%。 - 命中时间:将缓存行(数据块)交付给处理器所需的时间,包括确定该行是否在缓存中的时间。L1缓存约为1-4个时钟周期,L2为25-20个周期,L3为30个周期以上。
- 未命中惩罚:由于未命中而需要从主内存获取数据所增加的额外时间,通常为50到400个周期。
命中与未命中之间的时间差异巨大(可达100倍),因此即使未命中率仅有微小变化(例如从1%升至3%),平均访问时间也可能翻倍,对程序性能产生显著影响。
未命中的类型
未命中主要分为三种类型:
- 冷启动未命中:首次访问某块内存时必然发生,因为缓存初始为空。预取技术可以帮助缓解。
- 冲突未命中:由缓存的放置策略引起。当多个数据对象映射到同一个缓存组(
set),而该组容量有限时,即使缓存整体未满,也会发生冲突和替换。现代缓存的组织方式(组相联)减轻了此问题。 - 容量未命中:当程序活跃使用的工作集大小超过缓存总容量时发生。这是当前最主要的问题,因为缓存容量有限。
局部性原理
缓存之所以有效,是因为程序具有局部性,即程序倾向于使用最近使用过的或地址相近的数据和指令。

- 时间局部性:最近被引用过的项很可能在不久的将来再次被引用(例如,循环中反复更新同一个变量)。
- 空间局部性:地址相近的项很可能在时间上被集中引用(例如,顺序执行指令或顺序访问数组元素)。
大多数程序访问都表现出良好的局部性,这使得缓存能够高效工作。
缓存组织结构
现代CPU缓存通常组织为一个二维结构。
一个缓存由多个组构成,每个组内可以有多个条目。每个条目存储一个内存块的数据。决定缓存大小的三个参数是:
- 块大小:每个数据块的大小(B字节)。
- 组数:缓存中组的数量(S)。
- 相联度:每个组中可以存放的条目数(E)。

缓存总容量可以通过公式计算:缓存大小 = S × E × B。
当CPU给出一个内存地址时,它被划分为三部分:
- 块偏移:用于定位块内的具体字节(使用
log2(B)位)。 - 组索引:用于确定地址属于哪个组(使用
log2(S)位)。 - 标签:地址中剩余的高位部分,与存储在缓存条目中的标签进行比较,以确认是否命中。
缓存映射方式
根据相联度E的不同,缓存有三种主要映射方式:
- 直接映射缓存:每个组只有一个条目(E=1)。一个内存块只能放在一个特定的组中。容易发生冲突未命中。
- 组相联缓存:每个组有多个条目(E>1,常见如8路、16路)。一个内存块可以放在对应组中的任何一个空闲条目里。这减少了冲突未命中。
- 全相联缓存:只有一个组(S=1),但该组包含所有条目。一个内存块可以放在缓存中的任何位置。实现复杂,通常只用于小容量缓存(如TLB)。

现代CPU的L1、L2缓存通常是组相联的。


实际缓存参数探查
在Linux系统上,可以使用命令查看CPU缓存的详细信息。
以下是几个有用的命令:
lscpu:查看CPU概要信息,包括缓存大小。lstopo或lstopo-no-graphics:以图形或文本形式显示系统的拓扑结构,包括缓存层次。- 查看
/sys/devices/system/cpu/cpu0/cache/目录下的文件:可以获取特定CPU核心上每个缓存(index0对应L1d,index1对应L1i,以此类推)的详细信息,如级别、类型、大小、组数、相联度和行大小(块大小)。
例如,行大小通常为64字节,这与内存传输的基本单位一致。
缓存写策略
当CPU执行写操作时,需要处理缓存一致性问题。主要有两种策略:
- 写命中:
- 直写:同时更新缓存和主内存。简单但慢。
- 写回:只更新缓存,并标记该缓存行为“脏”。当该行被淘汰时,才写回主内存。这是更常见的策略,性能更好。
- 写未命中:
- 写分配:先将目标内存块加载到缓存中,然后在缓存中更新。适用于后续可能再次访问该数据的情况。
- 非写分配/绕写:直接写入主内存,不加载到缓存。适用于一次性写入且后续不再访问的数据。

现代CPU缓存通常采用写回 + 写分配的组合策略。
使用Perf测量缓存未命中率
perf 是Linux下强大的性能分析工具,可以用来测量程序的缓存未命中率。
例如,可以测量L1数据缓存(L1-dcache)的加载未命中事件:
perf stat -e L1-dcache-load-misses,L1-dcache-loads ./your_program
这将输出加载次数和未命中次数,从而可以计算未命中率。类似的事件也存在于指令缓存(L1-icache)和其他缓存层级。
perf list 命令可以列出所有可监控的性能事件。
总结

本节课中我们一起学习了计算机存储层级的核心概念,特别是CPU缓存。我们了解了缓存的基本工作原理、性能指标、局部性原理的重要性,以及缓存是如何通过组、块和标签来组织的。我们还探讨了不同的缓存映射方式和写策略,并学习了如何在实际系统中查看缓存参数和使用 perf 工具来度量缓存效率。理解这些知识是进行高性能C语言系统编程的基础,能够帮助您编写出对缓存友好的高效代码。在接下来的课程中,我们将学习如何应用这些知识来优化具体的程序,例如矩阵乘法。
008:内存优化示例
在本节课中,我们将通过一个具体的哈希表实现示例,来探讨如何通过优化内存访问模式来提升程序性能。我们将分析几种不同的哈希表实现,并观察它们对CPU缓存命中率的影响。

概述


上一节我们介绍了CPU缓存的基本原理。本节中,我们将通过一个实际的哈希表代码示例,看看如何应用这些知识来优化内存访问。我们将比较几种不同的哈希表实现,并分析它们各自的性能表现。

初始哈希表实现
首先,我们来看一个基础的哈希表实现。它使用多个全局数组来存储键、值和用于处理冲突的“下一个”索引。
以下是其核心数据结构:
int *keys;
int *values;
int *next;
int *buckets;
int num_buckets;
哈希表的工作原理如下:
keys、values、next是三个独立的数组,每个索引对应一个条目。buckets数组代表哈希表本身,每个桶存储一个索引,指向keys/values/next数组中的某个条目。- 当发生哈希冲突时,通过
next数组形成链式索引(类似链表,但使用数组索引而非指针)。
插入函数 insert 的逻辑是:
- 计算键的哈希值并确定桶的索引。
- 如果桶为空(值为-1),则直接将新条目放入。
- 如果桶不为空,则沿着
next链寻找,直到找到空位或相同的键(用于更新)。
查找函数 find 的逻辑类似,沿着链搜索指定的键。
当我们对这个实现进行基准测试时,其末级缓存(LLC)的未命中率大约在50%左右。
尝试优化:缓存行友好的哈希表
接下来,我们尝试一个旨在提高缓存友好性的版本。核心思想是让每个哈希表条目存储多个键值对,使其能填满一个缓存行。
以下是其核心数据结构:
int (*keys)[LINE_SIZE];
int (*values)[LINE_SIZE];
int *next;
int *buckets;
这个版本的主要变化是:
keys和values现在是二维数组,每个主索引对应一个缓存行(例如8个键或值)。- 它减少了桶的数量,但增加了每个桶的容量(
LINE_SIZE),试图保持总内存使用量大致不变。 - 在发生冲突时,它会先尝试填满当前缓存行内的空位,只有当整行都满时才创建新的行。
运行此版本后,我们发现一个有趣的现象:虽然缓存未命中率上升了,但每个查找操作的平均CPU周期数却下降了。这提示我们,优化效果可能受到桶数量改变(影响冲突率)等因素的干扰,比较并不完全公平。
更传统的链表实现
为了进行更清晰的对比,我们实现了一个更传统的、基于链表的哈希表。
其核心数据结构定义如下:
typedef struct entry {
int key;
int value;
struct entry *next;
} entry_t;
typedef struct {
int num_buckets;
entry_t **buckets;
} hashtable_t;
插入逻辑如下:
- 计算哈希值找到对应的桶。
- 如果桶为空,则分配一个新节点并放入。
- 如果桶不为空,则遍历链表。如果找到相同键,则更新值;否则,将新节点插入链表头部。
对这个版本进行基准测试,其负载未命中率约为7%,末级缓存未命中率仍在50%左右。
优化链表实现:缓存行对齐的条目
我们的优化思路是:修改链表节点(entry)的结构,使其能容纳多个键值对,并确保其大小与缓存行对齐。
优化后的条目结构如下:
typedef struct entry {
int keys[LINE_SIZE];
int values[LINE_SIZE];
struct entry *next;
int length; // 当前存储的有效键值对数量
int padding; // 用于填充,确保结构体大小为64字节
} entry_t;
插入逻辑调整为:
- 找到对应的桶和链表。
- 遍历链表上的每个
entry。 - 在每个
entry内部,遍历其keys数组(长度为length)。 - 如果找到键,则更新值。
- 如果未找到但该
entry未满(length < LINE_SIZE),则将其加入。 - 如果已满,则移动到链表下一个
entry。 - 如果链表遍历完毕仍未插入,则分配一个新的、缓存行对齐的
entry。
我们使用 aligned_alloc(64, sizeof(entry_t)) 来确保每个 entry 在64字节边界上对齐。
然而,运行这个版本得到了令人意外的结果:性能反而下降了!虽然缓存未命中率显著降低(从7%降至3.8%),但每个操作的周期数增加了。原因在于,为了获得缓存友好性,每个条目的大小从16字节增加到了64字节。在保持桶数量不变的情况下,哈希表的总内存占用变成了原来的4倍,这本身就会对性能产生负面影响。
关键微调:访问顺序的重要性
我们发现了另一个微妙的性能问题。在之前的插入逻辑中,当一个新的 entry 被创建并添加到链表时,它被加在了链表头部。这意味着,对于一个已满的桶,最先被访问的将是那个可能只包含一个元素的新 entry,然后才通过指针跳转到那个存满了有效数据的旧 entry。
优化策略很简单:确保链表始终指向那个最满的 entry。这样,在大多数访问中,程序首先检查的就是包含最多数据的缓存行,从而减少了不必要的指针追逐。
仅仅做了这个调整后,我们重新运行测试。这次,在缓存未命中率进一步降低到2%左右的同时,我们也获得了一个小幅的性能提升。这证明了在优化时,细节至关重要。
高级参考:工业级哈希表实现
如果你对这个话题感兴趣,可以研究像 cuco 这样的工业级哈希库。它们进行了更深层次的优化,例如:
- 在条目中只存储键的签名(
key),而将值(value)分开存放,这样在搜索时可以在一个缓存行内检查更多键。 - 使用编译器属性(如
__attribute__((aligned(64))))来确保结构体对齐。 - 实现本地空闲槽缓存、环形缓冲区等复杂机制来管理内存。
- 考虑并发访问,集成自旋锁等线程安全机制。

总结
本节课中我们一起学习了如何通过实际代码示例来分析和优化内存访问模式。我们比较了多种哈希表实现,并观察到:
- 简单的数据结构改变(如将多个元素打包进缓存行)可以降低缓存未命中率。
- 但优化时必须进行公平比较,注意控制变量(如总内存使用量)。
- 微妙的实现细节,如链表节点的访问顺序,会对性能产生显著影响。
- 高性能编程通常需要在多个因素(缓存友好性、内存占用、算法复杂度)之间进行权衡。

内存优化是一个需要仔细测量和实验的领域,一个地方的改进可能会在另一个地方带来意想不到的开销。
009:缓存优化

在本节课中,我们将深入学习缓存优化的核心概念。我们将从一个简单的例子开始,分析缓存未命中率,并探讨如何通过改变数据访问模式来显著提升程序性能。特别是,我们将重点研究矩阵乘法,并引入一种名为“分块”的强大优化技术。
上一节我们尝试了缓存优化,但效果并不理想。今天,我们将更换例子,专注于展示仅通过优化缓存访问能实现的效果。
🧠 核心概念:局部性
在任何性能编程课程中,一个重要的建议是:编写具有局部性的代码。我们之前讨论过两种主要类型:
- 时间局部性:如果你使用了某块内存,很可能很快会再次使用它。
- 空间局部性:你希望访问相邻的内存地址,这样系统可以高效协作。计算机喜欢顺序访问。
为了实现良好的局部性,我们需要设计优秀的算法,并可能专注于循环变换或我们今天将介绍的其他技术。
📐 C语言中二维数组的内存布局
首先,每个人都应该了解C语言中二维数组是如何在内存中布局的。C语言采用行主序,这意味着同一行的元素在内存中是连续存放的。你可以将其视为一个一维数组。

对于一个二维数组 table[row][col],将其转换为一维数组的公式是:
1D_index = row * NUMBER_OF_COLUMNS + col

因此,table[0][0] 是一维数组的第一个元素,table[0][1] 是第二个,依此类推。
🧪 分析一个简单的缓存模型
在本讲座中,大多数时候我们将假设一个简单的缓存模型,并计算缓存未命中率。


我们将使用的例子是一个非常简单的缓存:
- 块大小/行大小:8字节。因此,如果使用整型数组,一个缓存行可以容纳两个
int。 - 关联性:2路组相联,但只有一个组。这实际上等同于全相联。
- 缓存大小:总共只有2个块。
- 替换策略:最近最少使用。

在编程时,尤其是使用数组时,你应该始终问自己以下几个问题:
- 一个缓存块能容纳多少个元素?
- 我的数据结构能放入缓存吗?
- 我是否随时间重复使用缓存块?
- 我以什么顺序访问这些块?
所有这些因素都会影响你的程序性能。
🔍 分析一维数组访问
让我们从一个非常简单的数组开始分析。假设 n = 4,我们创建一个包含4个元素的整型数组 a,然后有一个 for 循环来读取 a 的元素。
对于我们的缓存,我们应该能计算出这种情况下的未命中率(假设数组是缓存对齐的)。未命中率是 未命中次数 / 访问总次数。
以下是访问过程分析:
- 访问第一个元素
a[0]:未命中。缓存会加载包含a[0]和a[1]的块。 - 访问第二个元素
a[1]:命中。 - 访问第三个元素
a[2]:未命中。缓存会加载包含a[2]和a[3]的块。 - 访问第四个元素
a[3]:命中。
在这个例子中,我们有2次未命中和4次访问,因此未命中率是 50%。
将其推广,如果缓存只能容纳两个元素,那么未命中率总是 50%。如果我们的缓存块能容纳4个元素(假设缓存对齐),未命中率将是 25%。

缓存未对齐的影响

如果我们不假设缓存对齐,情况会变糟。如果数组起始地址没有对齐到缓存行边界,访问 a[0] 可能只加载了它自己,而 a[1] 在下一个缓存行。这会导致更多的未命中。在上面的4元素例子中,未命中率可能高达 75%。因此,缓存对齐 非常重要。
🔁 嵌套循环下的访问
现在,让我们在外部再添加一个循环。我们假设整个数组可以放入缓存。
内部循环的未命中率仍然是50%。但是,在第一次遍历外部循环后,整个数组已经加载到缓存中。在后续的外部循环迭代中,所有访问都将是命中。
因此,总的未命中率公式为:1 / (2 * P),其中 P 是外部循环的次数。运行外部循环的次数越多,命中率就越高。
如果数据不能放入缓存,那么每次外部循环迭代时,缓存内容都可能被替换,未命中率将回到 50%。所以,数据能否放入缓存 对于具有多重循环的程序性能至关重要。

🧮 二维数组与访问顺序


如果我们有一个二维数组,并且按行主序访问(先行后列),只要整个数组能放入缓存,其未命中率与顺序访问一维数组相同。
但是,如果我们改变访问顺序,按列主序访问(先列后行),情况就不同了。只要整个数组能放入缓存,未命中率仍然可以接受。然而,一旦数组太大无法放入缓存,按列访问就会导致灾难性的后果。
例如,对于一个4x4的数组,按列访问的序列(转换为一维索引)是:a[0], a[4], a[8], a[12], a[1]... 由于每次访问的地址跨度很大,且缓存很小,每次访问都可能落在不同的缓存行上,导致100%的未命中率。这就是为什么循环交换(确保按行访问)如此重要。编译器通常会帮你做这个优化,但检查一下总是好的。
⚡ 矩阵乘法的缓存分析
现在,让我们分析一个更复杂的例子:矩阵乘法。标准的朴素矩阵乘法代码如下:
for (i = 0; i < n; i++) {
for (j = 0; j < n; j++) {
for (k = 0; k < n; k++) {
C[i][j] += A[i][k] * B[k][j];
}
}
}

对于矩阵 A,我们按行访问(A[i][k]),具有良好的空间局部性。但对于矩阵 B,我们按列访问(B[k][j]),这非常糟糕,特别是当矩阵很大时。
分析表明,在这种访问模式下,对于矩阵 B 的访问几乎每次都是未命中。即使缓存很大,未命中率也被限制在 50% 左右,无法更好。
🧱 解决方案:分块优化
为了改善缓存重用,关键在于尝试在加载一个数据块后,尽可能多地重复使用它。我们需要同时处理行和列。
解决方案是分块。我们不是一次性遍历整个矩阵,而是将矩阵划分为更小的子块(Tile)。确保这些子块能同时放入缓存。然后,我们在这个子块上完成所有必要的计算,充分利用已加载到缓存中的数据,然后再移动到下一个子块。
以下是分块矩阵乘法的伪代码思路:
#define TILE_SIZE 16 // 根据缓存大小选择
for (i = 0; i < n; i += TILE_SIZE) {
for (j = 0; j < n; j += TILE_SIZE) {
for (k = 0; k < n; k += TILE_SIZE) {
// 计算子块 (i, j),使用子块 (i, k) 和 (k, j)
for (ii = i; ii < i + TILE_SIZE; ii++) {
for (jj = j; jj < j + TILE_SIZE; jj++) {
for (kk = k; kk < k + TILE_SIZE; kk++) {
C[ii][jj] += A[ii][kk] * B[kk][jj];
}
}
}
}
}
}
虽然代码看起来有六层循环,更复杂,但它访问内存的方式对缓存友好得多。对于矩阵 A 和 B 的子块,我们都能充分利用每个缓存行,将未命中率从接近100%降低到约 1 / TILE_SIZE。

性能演示
在实际演示中,对于小矩阵(如16x16),朴素方法和分块方法性能差异不大,因为数据都能放入缓存。但当矩阵增大(如128x128或更大)时,分块技术的优势变得极其明显,性能可以提升数倍甚至一个数量级,同时L1缓存未命中率大幅下降。
分块技术确保了我们只在缓存中保留当前正在处理的数据块,从而最大限度地利用了缓存容量和带宽。
📝 总结
本节课中,我们一起学习了缓存优化的核心思想:
- 理解局部性:时间局部性和空间局部性是高效缓存利用的基础。
- 分析访问模式:通过简单的缓存模型,我们可以估算代码的缓存未命中率。行主序访问通常优于列主序访问。
- 识别性能瓶颈:在像矩阵乘法这样的算法中,对第二个矩阵的列访问是主要的性能瓶颈。
- 应用分块技术:通过将大数据集分解为能放入缓存的小块,并在此块上完成尽可能多的工作,我们可以显著减少缓存未命中,从而大幅提升程序性能。这是一种以空间换时间(更准确地说,是以循环复杂度换缓存效率)的强大优化手段。


记住,即使代码看起来更复杂(如六重循环),如果它能更好地匹配硬件特性(如缓存层次结构),也可能带来巨大的性能收益。编译器并非总能自动完成这类优化,因此掌握手动分析和优化技巧对于高性能编程至关重要。
010:虚拟内存与预取 🧠



在本节课中,我们将学习虚拟内存系统的工作原理及其对程序性能的影响,并探讨如何通过预取技术来隐藏内存访问延迟,从而提升程序运行速度。

虚拟内存回顾

上一节我们介绍了缓存层次结构,本节中我们来看看操作系统如何通过虚拟内存管理物理内存。每个进程都使用虚拟内存,而非直接使用物理内存。这使得进程认为自己可以访问整个内存地址空间,无论机器上实际的物理内存有多少。
内核将物理内存划分为页,典型的页大小是 4 KB。然后,内核将虚拟页映射到物理页。
虚拟地址到物理地址的转换过程涉及多级页表查询。对于一个39位的虚拟地址空间,通常需要三级页表查询。这意味着一次内存访问可能需要四次内存操作:三次用于查询页表,最后一次才是实际的数据访问。这会使内存访问速度显著变慢。

为了加速这一过程,CPU引入了TLB。TLB是一个专门用于缓存虚拟页号到页表条目映射关系的高速缓存。如果TLB命中,CPU可以直接使用缓存的映射来访问物理内存,从而避免了耗时的页表遍历。

以下是虚拟内存的一些核心优势:
- 解耦:将进程使用的内存与物理内存解耦。
- 内存共享:内核可以通过写时复制或显式映射,在进程间共享内存,用于进程间通信。
- 惰性分配:进程可以请求大量内存,但内核只在首次访问对应页面时才实际分配物理页。
- 交换空间:当物理内存不足时,内核可以将不活跃的页面换出到磁盘的交换空间,从而允许进程使用比物理内存更大的地址空间(尽管速度会变慢)。
TLB与工作集
TLB通常与L1和L2缓存集成。TLB显著加快了虚拟内存访问速度。TLB能够覆盖的内存范围称为TLB覆盖范围。例如,如果页大小为4KB,TLB有64个条目,那么其覆盖范围就是 64 * 4 KB = 256 KB。
你的程序的工作集是指在执行一段指令期间所需访问的页面数量。如果工作集大于TLB的覆盖范围,就会发生TLB未命中,导致程序性能急剧下降。如果工作集甚至大于机器的总物理内存,就会发生颠簸,即页面需要频繁地在内存和磁盘之间交换,性能会变得极差。
进程内存布局
一个典型的进程地址空间布局如下,它被划分为多个页:
- 页0:通常无效,用于捕获空指针解引用等错误(引发段错误)。
- 代码段:存放程序指令。
- 数据段:存放全局变量。
- 栈:用于函数调用,通常包含保护页以检测栈溢出。
- 堆:用于动态内存分配,是一大块连续的内存区域。
- 其他区域:操作系统保留的用于存储进程信息的空间(如蹦床、陷阱帧)。
内存管理接口
在C语言中,我们通常使用 malloc 和 free 来管理堆内存。在底层,malloc 可能会使用 sbrk 系统调用来调整堆的大小。然而,对于大块内存,malloc 的实现通常会转而使用更灵活的系统调用:mmap。

mmap 允许你将文件内容映射到虚拟内存中,或者创建匿名的内存映射。这使得文件I/O操作更高效,并且支持进程间共享内存。另一个有用的系统调用是 madvise,它可以向内核提供关于内存使用模式的提示,例如:
MADV_SEQUENTIAL:提示内核你将要顺序访问内存,内核可以提前释放已访问过的页面。MADV_HUGEPAGE:建议内核使用大页(例如2MB或1GB的页)来映射该区域,以减少TLB压力。
预取技术

了解了内存管理的基础后,我们来看看如何通过预取来优化性能。预取是指在数据被实际使用之前,就提前将其加载到缓存中,从而隐藏内存访问的延迟。

考虑以下代码序列:
instruction1;
instruction2;
instruction3;
instruction4;
load_from_memory(x); // 如果x不在缓存中,这里会发生缓存未命中
instruction5;
如果我们在 instruction1 之后就知道将来会使用 x,可以插入一个预取指令:
instruction1;
prefetch(x); // 提示CPU开始将x加载到缓存
instruction2;
instruction3;
instruction4;
load_from_memory(x); // 希望此时x已在缓存中,成为命中
instruction5;
这样,x 的加载延迟就被 instruction2、instruction3 和 instruction4 的执行时间所隐藏。
然而,预取要有效,必须满足以下几个条件:
- 有空闲的内存带宽:否则预取会加剧带宽竞争。
- 预取必须准确:预取了不用的数据是浪费。
- 预取必须及时:不能太早(数据可能在被使用前被换出缓存),也不能太晚。
- 不能驱逐有用的缓存数据:预取不应踢出即将被使用的数据。
- 收益大于开销:错误的预取可能使程序更慢。
现代CPU通常内置了硬件预取器,它们能自动检测访问模式(如顺序访问、固定步长访问)并进行预取,对于大多数规整的访问模式效果很好。
但是,对于看似“随机”的访问模式,硬件预取器可能失效。例如,在二分查找中,每次访问的地址跳跃很大且方向难以预测。这时,我们可以使用编译器内置的预取指令来提供帮助。在GCC/Clang中,可以使用 __builtin_prefetch 函数:
__builtin_prefetch(address, rw, locality);
address:要预取的数据地址。rw:0表示预取用于读(默认),1表示用于写。locality:数据的时间局部性提示。0(无)、1(L3)、2(L2)、3(L1)。

在二分查找的例子中,当我们计算中点 mid 后,下一步要么访问左半部分的中点,要么访问右半部分的中点。虽然我们不知道具体是哪一个,但我们可以同时预取这两个可能的地址,这样就有50%的命中率,远好于硬件预取器的盲目猜测,从而带来性能提升。

性能分析要点

在进行性能优化时,请记住以下要点:
- 关注适当的缓存层级:根据工作集大小,重点优化L1或L2缓存命中率。同时,不仅要看未命中率,还要关注总的负载次数。
- 警惕TLB未命中:尽量让工作集集中在尽可能少的页面上。如果数据访问导致频繁的TLB未命中,性能会严重下降。可以使用大页来缓解。
- 善用硬件预取:对于顺序或固定步长的访问,硬件预取器通常做得很好。
- 谨慎使用软件预取:仅在访问模式难以预测、且你拥有比硬件更多信息时(如二分查找),才考虑使用
__builtin_prefetch。错误的使用很容易导致性能倒退。

总结


本节课中我们一起学习了虚拟内存系统和预取技术。我们回顾了虚拟内存如何通过页表和TLB工作,探讨了工作集与TLB性能的关系。接着,我们介绍了 mmap 和 madvise 等内存管理接口。最后,我们深入研究了预取技术,了解了其工作原理、适用条件,并通过二分查找的例子演示了如何正确使用软件预取来提升性能。记住,性能优化需要仔细测量和分析,盲目应用技术可能会适得其反。
011:动态内存

概述
在本节课中,我们将要学习动态内存管理。我们将探讨为什么需要动态内存、内存对齐的规则、以及如何实现像 malloc 和 free 这样的内存管理函数。我们还将了解内存碎片化问题,并学习几种不同的内存分配策略,如隐式空闲链表、显式空闲链表和伙伴分配器。这些知识将帮助你理解内存分配器的工作原理,并为后续的编程实践打下基础。
为什么需要动态内存分配?
我们可能无法在编译时知道所需内存的大小。例如,程序可能需要根据用户输入或从文件读取的数据来分配内存。主内存是宝贵的资源,我们不希望浪费它,否则可能会导致内存不足。

首先,我们需要回答一个问题:为什么不能总是使用栈来分配内存?栈上分配的变量在其作用域结束时会被自动释放。如果所有变量的生命周期都相同,并且只存在于一个函数内,那么栈分配是快速且高效的。然而,我们并不总是知道内存的使用寿命。如果内存的使用寿命超过了函数的作用域,我们就必须动态地分配它。
内存布局
一个典型进程的内存布局如下:内核会为自己分配一些虚拟地址空间,用户无法访问。栈通常位于顶部,有固定的大小,并向低地址方向增长。内存映射区域用于加载动态库,通常位于堆和栈之间。堆是一个大的内存区域,malloc 等函数主要使用堆进行分配。此外,还有未初始化的全局变量(BSS段)、已初始化的全局变量(数据段)以及程序代码(文本段)。

内存对齐
对齐是指数据在内存中的起始地址是某个数K的倍数。例如,如果要求8字节对齐,那么地址必须是0、8、16等,即能被8整除。
为什么需要对齐?如果数据结构没有对齐,可能会跨越缓存行的边界。例如,一个整数如果起始于一个奇数地址,它可能一部分在一个64字节的缓存块中,另一部分在另一个缓存块中,这会导致性能下降。CPU通常希望数据按字大小对齐,未对齐的访问可能需要两次内存读取。
编译器通常会为我们处理对齐。malloc 返回的地址也是对齐的。例如,在64位系统上,malloc 通常返回16字节对齐的地址,以满足最严格的对齐要求。
我们可以使用 __attribute__((aligned(n))) 来建议编译器将结构体按n字节对齐。
以下是结构体对齐的具体规则:
- 结构体的起始地址必须与其最大成员的对齐要求一致。
- 结构体成员在内存中的顺序与声明顺序一致。
- 编译器可能会在成员之间插入填充字节以满足每个成员自身的对齐要求。
例如,考虑以下结构体:
struct S1 {
char c; // 1字节
int i[2]; // 每个int 4字节,共8字节
};
int 需要4字节对齐。因此,编译器会在 char c 之后插入3字节的填充,使得数组 i 从4字节对齐的地址开始。整个结构体的大小为12字节,对齐要求为4字节。
如果结构体成员顺序安排不当,会导致空间浪费。将大小相似的成员声明在一起可以减少填充。
内存管理API
malloc 函数用于分配内存。它接受一个表示字节数的 size 参数。如果成功,它返回一个指向新内存块的指针。如果 size 为0,行为由实现定义。如果失败,它返回 NULL 并设置 errno。
free 函数用于释放内存。它接受一个由 malloc、calloc 或 realloc 返回的指针,并将该内存块返回给可用内存池。调用 free 后,指针不应再被使用。
realloc 函数用于调整已分配内存块的大小。它接受一个旧指针和一个新的 size 参数。它可能尝试在原地调整大小,也可能分配新内存、复制数据并释放旧内存。
分配器的目标与约束
内存分配器管理着一系列内存块。每个块可以是可变大小的、连续的。分配器需要处理任意的分配和释放请求序列。
分配器的目标包括:
- 吞吐量:处理
malloc和free请求的速度要快。 - 内存利用率:尽量减少浪费的空间(碎片化)。
这两个目标通常是相互冲突的。更智能的分配策略可以提高内存利用率,但可能会降低分配速度。
我们需要衡量内存利用率。聚合负载(P) 是用户通过 malloc 当前正在使用的内存总量(已分配但未释放)。堆大小(Hk) 是分配器从系统获取的总内存量。峰值内存利用率(U) 是聚合负载的最大值除以达到该峰值时的堆大小。我们希望U尽可能接近1。
内存碎片
碎片化是浪费的空间,分为两种:
- 内部碎片:分配块内部未使用的空间。例如,由于对齐或分配器的最小块大小限制,分配给用户的内存块可能大于其请求的大小。
- 公式:
内部碎片 = 分配块大小 - 用户请求大小
- 公式:
- 外部碎片:堆中分散的、空闲的、但由于不连续而无法满足当前分配请求的小块内存。
- 它取决于未来的请求模式,难以测量。
实现一个简单的分配器
要实现 malloc 和 free,我们需要解决几个问题:
- 如何知道要释放多少内存?
free只接收一个指针。 - 如何追踪哪些内存是空闲的?
一个常见的方法是在每个内存块之前存储一个头部(header)。头部可以存储块的大小以及一个标记位(指示块是已分配还是空闲)。由于大小通常是偶数,我们可以利用最低有效位来存储分配状态。
// 假设字大小为8字节(64位系统)
typedef size_t word_t;
// 从头部获取块大小(掩码掉最低位)
#define GET_SIZE(p) (*(word_t*)(p) & ~0x7)
// 从头部获取分配状态
#define GET_ALLOC(p) (*(word_t*)(p) & 0x1)
// 设置头部(大小和状态)
#define SET_HEADER(p, size, alloc) (*(word_t*)(p) = (size) | (alloc))
隐式空闲链表
在这种方法中,我们通过遍历所有内存块(包括已分配和空闲的)来寻找空闲块。我们从堆的起始位置开始,利用每个块的头部信息中的大小字段,跳到下一个块。
寻找空闲块的策略:
- 首次适配:从开始搜索,选择第一个足够大的空闲块。
- 下次适配:从上一次搜索结束的地方开始搜索。
- 最佳适配:搜索整个链表,选择满足要求的最小空闲块。
当找到一个空闲块时,如果它比需求大,我们将其分割:一部分用于分配,剩余部分形成一个新的空闲块。
释放一个块时,我们只需将其标记为空闲。但为了减少外部碎片,我们需要合并相邻的空闲块。为了能够向后合并,我们可以在每个块的尾部也存储一个相同的头部信息(称为脚部)。这样,释放一个块时,我们可以检查其前后相邻块的状态,如果空闲则进行合并。
隐式空闲链表的分配时间是总块数(已分配+空闲)的线性函数,这可能很慢。
显式空闲链表
为了加快分配速度,我们只将空闲块组织成链表。空闲块本身的空间可以用来存储前驱和后继指针。这样,分配时只需要遍历空闲链表,而不需要检查已分配的块。
释放块时,我们需要将其插入到空闲链表中。插入策略可以是LIFO(插入链表头部,常数时间)或按地址顺序插入(可能减少碎片,但需要搜索)。
合并操作依然需要,并且更复杂,因为我们需要从显式空闲链表中正确地添加或删除块。
分离空闲链表
我们可以维护多个空闲链表,每个链表专门负责某一大小范围(或“大小类”)的块。例如,可以为小对象(如<=32字节)设置一个专用链表。
简单分离存储:每个大小类的块大小固定。分配时,如果对应链表不为空,则分配第一块。如果为空,则向系统申请一大块内存,将其分割成固定大小的块,加入链表。释放时,直接将块放回对应链表。这种方法分配和释放都很快,且无需合并。但可能导致内部碎片(向上取整)和外部碎片(不同大小类的内存不能互通)。
分离适配:分配时,先搜索对应大小类的链表。如果找到,可能进行分割,并将剩余部分放入更小的合适链表中。如果没找到,则搜索更大的大小类链表,或向系统申请新内存。释放时,进行合并,并可能将合并后的块移到其他大小类的链表中。这是许多现代分配器(如glibc malloc)使用的策略,在速度和内存利用率之间取得了良好平衡。
伙伴分配器
伙伴分配器是分离适配的一种特例,它只使用2的幂次方作为块大小。分配时,将请求大小向上舍入到2的幂。如果对应大小的空闲链表为空,则寻找更大的块,并递归地将其对半分割,直到得到所需大小的块。每次分割产生的两个块互为“伙伴”。
释放一个块时,检查其“伙伴”块是否也为空闲。如果是,则合并两者,形成一个更大的块。然后继续检查更大块的伙伴,直到无法合并为止。通过地址计算,可以快速找到伙伴块的位置。
伙伴分配器的优点是合并快速、简单,外部碎片较少。缺点是内部碎片可能较大(因为向上取整到2的幂)。
其他考虑因素
- 分配模式:大多数程序的内存分配请求集中在少数几种大小上。优化这些常见情况可以显著提升性能。
- 局部性:将同时分配的对象放在内存中相邻的位置,可以提高缓存命中率,并在释放时便于合并。
- 线程安全:在多线程环境中,分配器需要锁或其他同步机制来保护共享数据结构,这可能会成为性能瓶颈。现代分配器通常使用线程本地缓存或arena等方法来减少竞争。
总结

本节课我们一起学习了动态内存管理的核心概念。我们了解了为什么需要动态内存、内存对齐的重要性,以及 malloc、free 等API的基本用法。我们深入探讨了内存分配器的目标与挑战,特别是碎片化问题。接着,我们学习了多种内存分配策略的实现细节:从简单的隐式空闲链表,到更高效的显式空闲链表和分离空闲链表,最后是伙伴分配器。每种策略都在分配速度、内存利用率和实现复杂度之间有不同的权衡。理解这些底层机制将帮助你编写更高效、更可靠的C语言程序,并能够根据具体需求选择或调整内存管理策略。
012:现代内存分配器

在本节课中,我们将要学习现代内存分配器的设计原理,特别是PHK Malloc和垃圾收集器的工作原理。我们将探讨如何高效地管理内存,减少碎片化,并了解在多线程环境下的性能考量。
概述
内存分配是系统编程的核心。一个高效的分配器可以显著提升程序性能。本节将介绍PHK Malloc的两级设计、垃圾收集的基本概念,以及针对多线程优化的分配器策略。
PHK Malloc 设计
上一节我们介绍了基础的内存分配概念,本节中我们来看看PHK Malloc的具体实现。PHK Malloc采用两级设计:页面分配器和子页面分配器。
页面分配器负责管理整个堆,以页为单位进行分配。它维护一个页面目录(Page Directory)和一个空闲链表(Free List)。
- 页面目录:一个数组,用于快速查找每个页面的状态(例如,是起始页、跟随页还是空闲页)。访问是常数时间
O(1)。 - 空闲链表:一个链表,记录所有连续的空闲页面块,并按地址顺序排列以方便合并。
当需要分配N个连续页面时,分配器在空闲链表中查找。如果找到,则更新页面目录中的标志位(起始页和跟随页)。如果找不到,则通过 sbrk 或 mmap 扩展堆。释放时,通过页面目录常数时间找到对应页面块,更新目录,并将该块重新加入空闲链表进行合并。
子页面分配器
对于小于一页(例如4096字节)的分配请求,PHK Malloc使用子页面分配器。它的核心思想是将一个页面分割成多个大小相同的块。
子页面分配器在元数据头中为每种块大小(通常是2的幂次方)维护一个链表。每个链表指向第一个被分割成该大小的页面。
以下是子页面分配的关键数据结构概念:
// 页面信息结构(简化)
struct page_info {
size_t chunk_size; // 本页面中每个块的大小
bitmap_t allocation_map; // 位图,标记每个块是否已分配
struct page_info *next; // 指向下一个具有相同块大小的页面
};
当请求分配一个32字节的块时:
- 查找头部元数据中对应32字节的链表。
- 遍历链表,找到第一个有空闲块(位图中对应位为0)的页面。
- 在位图中标记该块为已分配(翻转为1),并返回指向该内存的指针。
- 如果该页面所有块都被分配,则将其从链表中移除。
- 如果链表为空(没有可用的页面),则向页面分配器申请一个新页面,初始化其
page_info和位图,然后进行分配。
释放一个块时:
- 找到该块所属页面的
page_info。 - 在位图中将对应位标记为空闲(翻转为0)。
- 如果该页面之前是满的,现在有了空闲块,则将其重新加入对应大小的链表。
- 如果释放后页面所有块都空闲,则可将整个页面返还给页面分配器。
这种设计的优点是:
- 子页面分配永远不会跨页。
- 相同大小的对象可能位于同一页面,有利于缓存。
- 通过按地址顺序分配,更有可能在末尾释放整个页面,从而收缩堆。
垃圾收集
手动管理内存(malloc/free)容易出错,例如悬空指针、双重释放和内存泄漏。垃圾收集器(Garbage Collector, GC)旨在自动回收不再使用的内存。
垃圾收集器将内存视为有向图,其中节点是内存块,边是指针。从根节点(如寄存器、全局变量、栈上的局部变量)开始,遍历所有可达节点。不可达的节点被视为“垃圾”,可以被安全回收。
标记-清除算法
最基本的垃圾收集算法是标记-清除(Mark-and-Sweep)。
- 标记阶段:从所有根节点开始,深度优先或广度优先遍历对象图,标记所有可达的对象。
- 清除阶段:遍历堆中所有对象,释放那些未被标记的对象(即垃圾),并清除所有对象的标记位以备下次收集。
标记-清除算法需要暂停整个应用程序(Stop-the-World),并且时间开销与堆大小成正比。优化版本(如Baker算法)通过维护已分配块的列表,使清除阶段只扫描已分配块,而非整个堆。
引用计数
另一种常见方法是引用计数。每个内存块维护一个计数器,记录指向它的指针数量。
- 当新的引用指向该块时,计数器加一。
- 当引用失效时,计数器减一。
- 当计数器减到零时,表示没有任何引用指向该块,可以立即释放。
C++中的 std::shared_ptr 就使用了引用计数。它的主要问题是无法处理循环引用(两个或多个对象相互引用,但外部已无引用指向它们),这会导致内存泄漏。此外,在多线程环境下,原子地增减计数器会有额外开销。
应用程序感知的分配器
通用的内存分配器可能无法满足特定程序的模式。开发者可以利用对程序行为的了解,实现自定义的高效分配策略。
内存对象的生命周期通常有几种模式:持续增长型、平台稳定型、短期峰值型和多阶段型。块大小的分布也往往集中在少数几种尺寸上。
以下是几种常见的自定义分配模式:
- 竞技场(Arena)分配器:适用于生命周期相同的对象组。它一次性申请一大块内存(多个页面),所有分配都在这块内存中进行。当这组对象全部不再需要时,一次性释放整个竞技场。编译器常用此模式。
- Slab分配器:适用于大量相同大小的对象。它预先分配一个或多个页面,并将其分割成固定大小的槽位。分配和释放通过位图或空闲链表管理,速度极快。操作系统内核常用此模式管理如inode、网络包等对象。
性能考量与多线程
在现代多核系统中,分配器的设计需要考虑更多因素。
- 缓存行共享:如果两个不同线程使用的对象位于同一缓存行,即使它们互不干扰,硬件也会为了保持缓存一致性而产生“假共享”(False Sharing),拖累性能。因此,分配器有时需要添加填充(Padding)来避免不同线程的对象共享缓存行,但这会增加内部碎片。
- 锁竞争:传统的分配器使用全局锁来保护数据结构,这在多线程下会成为瓶颈,导致性能无法随核心数增长而扩展。
JE Malloc 设计
JE Malloc 是一种为多核性能优化的分配器,被 Firefox 等大型应用使用。
其核心思想是使用多个竞技场(Arena):
- 将堆内存划分为多个独立的竞技场。
- 通过轮询或哈希等方式,让不同线程尽量从不同的竞技场分配内存。
- 每个线程还维护一个本地缓存(Thread Cache),用于快速分配小对象(如小于32KB),大部分分配无需加锁。
- 这样大大减少了锁竞争和假共享问题。
性能测试表明,在多核环境下,JE Malloc 的吞吐量通常显著高于传统的 Glibc malloc。
总结
本节课中我们一起学习了现代内存分配器的关键设计。
- PHK Malloc 通过页面和子页面的两级设计,高效地管理不同大小的内存请求,减少了外部碎片。
- 垃圾收集器(如标记-清除和引用计数)提供了自动内存管理的方案,但有其适用场景和开销。
- 理解程序的内存访问模式有助于设计应用程序感知的分配器(如竞技场和Slab分配器),从而大幅提升性能。
- 在多核时代,分配器需要仔细处理缓存行共享和锁竞争问题,JE Malloc 通过竞技场和线程本地缓存等设计,实现了更好的可扩展性。

选择或设计内存分配器时,需要在空间利用率、分配速度、多线程扩展性等方面做出权衡,最佳方案通常取决于具体的应用场景。
013:期中复习 📚

在本节课中,我们将对期中考试涉及的核心知识点进行系统性的回顾。考试内容分为六个主要部分:简答题/判断题、编译器优化、程序优化、缓存层次结构、虚拟内存和动态内存管理。我们将逐一梳理这些主题,确保大家能够清晰地理解每个概念。
考试概述 📝
期中考试为闭卷考试,总分为15分。考试包含简答题和判断题,内容相对直接。考试地点在PB楼地下室,时间为周四晚上7:30。考试期间,除了一份列有GCC编译器选项的简单提示外,不允许使用任何辅助材料。
编译器优化 🔧
上一节我们介绍了考试的整体结构,本节中我们来看看编译器优化部分。你需要能够识别并解释几种基本的编译器优化技术。
以下是需要掌握的核心编译器优化技术列表:
- 常量折叠:在编译时计算常量表达式的结果,例如将
3 * 5直接替换为15。 - 公共子表达式消除:识别并提取多个表达式中重复计算的子表达式,只计算一次。
- 常量传播与复制传播:如果一个变量的值是已知常量,则用该常量替换后续对该变量的引用。如果执行了
y = x,则后续代码中的y可以用x替代。 - 死代码消除:移除程序中永远不会被执行到的代码。
- 循环交换:改变嵌套循环的顺序,以更好地利用缓存局部性。
- 循环融合:将两个独立的循环合并为一个循环。
- 循环不变量外提:将循环内值不变的表达式移到循环外部,避免重复计算。
- 内联展开:将函数调用处直接替换为函数体,消除函数调用的开销。

程序优化 💻
编译器有时会因保守的内存假设而无法进行某些优化。这时,程序员需要手动进行程序优化。这部分内容与第5讲中的优化过程类似。

你需要能够应用诸如循环展开等技术来提升程序性能。核心思路是减少循环开销,增加指令级并行性。一个典型的优化步骤是:首先识别性能瓶颈(如内层循环),然后通过展开循环来减少分支判断和递增操作。



缓存层次结构 🧠


缓存是提升程序性能的关键。你需要理解缓存的基本工作原理,并能够进行计算。

给定一个缓存配置,例如:32位地址空间,2KB大小,4路组相联,缓存行大小为8字节,你需要能够计算出标记位、索引位和偏移位的位数。


计算步骤如下:
- 偏移位:由缓存行大小决定。8字节 = 2^3,所以偏移位为 3 位。
- 索引位:由缓存总大小、相联度和行大小共同决定。
- 缓存总大小 = 2KB = 2^11 字节。
- 总缓存行数 = 缓存总大小 / 行大小 = 2^11 / 2^3 = 2^8 行。
- 组数 = 总行数 / 相联度 = 2^8 / 4 = 2^8 / 2^2 = 2^6 组。
- 因此,索引位为 6 位。
- 标记位:剩余的地址位。32位地址 - 索引位(6) - 偏移位(3) = 23 位。
此外,你还需要能够分析简单的数组访问模式,并估算其缓存命中率或未命中率。
虚拟内存与动态内存 💾

虚拟内存部分,重点在于理解地址转换和TLB的作用。给定一个虚拟地址,你需要能划分出虚拟页号和页内偏移。TLB本质上是一个用于加速地址转换的专用缓存。
动态内存部分,需要掌握:
- 结构体内存布局与对齐规则:能够计算一个结构体在内存中的实际大小和布局,并能通过调整成员顺序来优化空间。
- 动态内存分配器:理解隐式空闲链表、显式空闲链表和分离空闲链表这三种分配策略的基本思想及其在分配/释放操作上的时间复杂度差异。
总结 🎯

本节课我们一起回顾了期中考试的核心内容。我们涵盖了从编译器自动优化到程序员手动优化的技巧,深入探讨了缓存的工作原理和计算方法,并简要回顾了虚拟内存与动态内存管理的关键概念。请务必理解这些基础原理,并通过练习巩固。祝大家考试顺利!

如有任何最后时刻的疑问,欢迎在办公时间前来咨询。
014:线程与同步

在本节课中,我们将学习并行编程的核心概念:线程与同步。我们将回顾进程与线程的区别,探讨如何创建和管理线程,并深入理解如何通过互斥锁、信号量和条件变量等机制来防止数据竞争和实现线程间的同步。
进程与线程回顾
上一节我们介绍了单核程序的优化。本节中,我们来看看如何在多核机器上实现并行化。现代计算机都拥有多个核心,因此我们需要理解如何在单台机器上使用线程和进程进行并行计算。
进程
进程拥有独立的虚拟内存空间、文件描述符表等资源。默认情况下,一个进程运行一个线程。进程是资源分配的基本单位。
线程
线程是进程内的执行单元,它包含虚拟寄存器集和一个独立的栈。一个进程可以包含多个线程,它们共享进程的虚拟内存、全局变量等资源。如果进程终止,其内部的所有线程也会随之终止。
用户线程与内核线程
用户线程由用户空间的库管理,如果一个用户线程发生阻塞(如系统调用),会阻塞整个进程。内核线程由操作系统内核管理,阻塞一个内核线程不会影响同一进程内的其他线程。现代编程通常使用内核线程(如POSIX线程,即pthreads)来实现真正的并行。
线程创建与管理
以下是使用POSIX线程(pthreads)创建和管理线程的基本方法。
创建线程
使用 pthread_create 函数创建新线程。
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
thread: 指向线程标识符的指针。attr: 线程属性,通常设为NULL使用默认值。start_routine: 线程启动后执行的函数。arg: 传递给start_routine函数的参数。
等待线程结束
使用 pthread_join 函数等待指定线程结束,并获取其返回值。
int pthread_join(pthread_t thread, void **retval);
线程分离
如果不需要等待线程结束或获取其返回值,可以将其设置为分离状态,线程结束后会自动释放资源。
int pthread_detach(pthread_t thread);
示例:Hello World
以下是一个简单的线程示例。
#include <stdio.h>
#include <pthread.h>
void* print_hello(void* arg) {
printf("Hello, World!\n");
return NULL;
}
int main() {
pthread_t thread;
pthread_create(&thread, NULL, print_hello, NULL);
pthread_join(thread, NULL); // 等待线程结束
return 0;
}
数据竞争与互斥锁
当多个线程并发访问同一内存位置,且至少有一个是写操作时,就会发生数据竞争。数据竞争会导致未定义行为和难以调试的错误。
互斥锁
互斥锁用于保护临界区,确保同一时间只有一个线程可以执行该段代码,从而防止数据竞争。
以下是使用互斥锁的基本步骤。
-
声明并初始化互斥锁
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; // 或动态初始化 pthread_mutex_init(&mutex, NULL); -
在临界区前后加锁和解锁
pthread_mutex_lock(&mutex); // 临界区代码 pthread_mutex_unlock(&mutex); -
销毁互斥锁
pthread_mutex_destroy(&mutex);
锁的粒度
- 粗粒度锁:使用一个锁保护大量数据或代码。简单但并发性差,可能成为性能瓶颈。
- 细粒度锁:使用多个锁分别保护不同的数据。能提高并发性,但设计更复杂,需小心避免死锁。
线程同步
互斥锁解决了数据竞争,但有时我们还需要控制线程的执行顺序,即同步。常见的同步机制有信号量和条件变量。
信号量
信号量是一个整型计数器,具有两个原子操作:
sem_post(V操作):信号量值加1。sem_wait(P操作):信号量值减1。如果值为0,则调用线程阻塞,直到值变为正数。
初始化信号量
#include <semaphore.h>
sem_t sem;
sem_init(&sem, 0, initial_value); // 第二个参数0表示线程间共享
条件变量
条件变量允许线程在某个条件不满足时主动阻塞,并在条件可能满足时被唤醒。它必须与一个互斥锁配合使用。
使用条件变量的基本模式
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
// 等待线程
pthread_mutex_lock(&mutex);
while (condition_is_false) {
pthread_cond_wait(&cond, &mutex); // 释放mutex并阻塞,被唤醒后重新获取mutex
}
// 执行操作
pthread_mutex_unlock(&mutex);
// 通知线程
pthread_mutex_lock(&mutex);
// 改变条件
pthread_cond_signal(&cond); // 唤醒一个等待线程
// 或 pthread_cond_broadcast(&cond); // 唤醒所有等待线程
pthread_mutex_unlock(&mutex);

屏障
屏障用于同步多个线程,要求所有参与线程都到达屏障点后才能继续执行。
初始化与使用屏障
pthread_barrier_t barrier;
pthread_barrier_init(&barrier, NULL, num_threads); // num_threads: 需要同步的线程数
// 在每个线程中调用
pthread_barrier_wait(&barrier); // 到达屏障,等待其他线程
生产者-消费者问题
生产者-消费者是一个经典的同步问题。生产者向固定大小的缓冲区放入数据,消费者从缓冲区取出数据。需要同步确保:
- 生产者不会向已满的缓冲区放入数据。
- 消费者不会从空的缓冲区取出数据。
我们可以使用两个信号量来解决这个问题:
empty_slots:初始值为缓冲区大小,表示空槽位数。filled_slots:初始值为0,表示已填充的槽位数。
伪代码逻辑
// 生产者
sem_wait(&empty_slots); // 等待空槽位
// 将数据放入缓冲区
sem_post(&filled_slots); // 增加已填充槽位计数
// 消费者
sem_wait(&filled_slots); // 等待有数据的槽位
// 从缓冲区取出数据
sem_post(&empty_slots); // 增加空槽位计数
总结

本节课中我们一起学习了并行编程的基础。我们回顾了进程与线程的核心区别,掌握了使用pthreads库创建和管理线程的方法。我们深入探讨了数据竞争的危害,并学习了如何使用互斥锁来保护临界区。此外,我们还介绍了信号量、条件变量和屏障这三种重要的线程同步机制,并使用信号量优雅地解决了经典的生产者-消费者问题。理解这些概念是编写正确、高效并行程序的关键。
015:并行架构

在本节课中,我们将深入探讨并行架构的实际应用,特别是缓存一致性、内存操作性能以及内存一致性模型。这些概念对于编写高效、正确的并行程序至关重要。
缓存一致性
上一节我们回顾了并行化的基本概念,本节中我们来看看如何在实际系统中实现数据一致性。当多个核心拥有各自的私有缓存时,如何确保它们看到的内存值是一致的?这就是缓存一致性要解决的问题。
缓存一致性确保所有处理器对同一内存地址有一致的视图。其目标是使系统的行为表现得像只存在一份数据副本,同时保留缓存带来的性能优势。
为了在硬件层面实现一致性,通常使用一种称为MSI(修改、共享、无效)的协议。缓存中的每个块都有一个状态标记:
- 无效:数据不在该核心的缓存中,或已过期。
- 共享:数据在该核心缓存中,与主内存一致,且可能与其他核心共享。
- 修改:数据在该核心缓存中,已被修改,与主内存不一致,且是唯一的最新副本。
后来为了优化性能,又增加了独占状态。当数据以独占状态被一个核心读取时,意味着它是唯一拥有该数据副本的核心,且数据与主内存一致。当这个核心后续写入该数据时,无需与其他核心通信,可以直接修改,性能更高。
以下是不同操作下状态转换的例子:
- 核心A读取X,然后核心B读取X:核心A将X读入缓存,状态设为独占。核心B读取时,发现核心A有副本,于是核心A的状态变为共享,核心B也将X读入缓存,状态设为共享。
- 核心B读取X,然后核心A写入X:核心B将X读入缓存,状态设为独占。核心A要写入时,发送“读独占”请求,使核心B的缓存无效,然后核心A将X读入并修改,状态设为修改。
- 核心A写入X,然后核心B读取X:核心A发送“读独占”请求,将X读入并修改,状态设为修改。核心B读取时,会触发核心A将数据写回主内存,然后核心A和核心B的状态都变为共享,核心B读取更新后的值。
所有这些状态转换和通信都由硬件自动完成,但对程序员来说,理解其开销非常重要。
内存操作性能与伪共享
访问内存的成本差异巨大。最佳情况是数据在本地L1缓存中,大约需要10个CPU周期。最坏情况是数据在RAM中,速度可能慢30倍以上。在多核多插槽的系统中,核心间通信的成本还取决于它们之间的物理距离和拓扑结构。
程序员需要特别关注一个由缓存一致性引发的问题:伪共享。
现代CPU的缓存行通常是64字节,可以容纳多个变量。如果两个线程各自频繁修改两个不同的变量,但这两个变量恰好位于同一个缓存行上,就会发生伪共享。
即使线程操作的是完全独立的数据,缓存一致性协议也无法区分。当一个核心修改了缓存行上的任何一个字节,它都会导致其他核心中整个缓存行失效。这会导致缓存行在不同核心的缓存之间频繁“乒乓”移动,性能急剧下降,甚至比直接访问主内存还要慢。
如何解决伪共享?
解决方案是确保被不同线程频繁访问的变量位于不同的缓存行上。这可以通过在变量之间插入填充字节来实现。例如:
struct Data {
int thread_a_var;
char padding[60]; // 假设缓存行大小为64字节
int thread_b_var;
};
编译器通常不会自动处理这个问题,需要程序员根据目标平台的缓存行大小手动调整。一些内存分配器(如tcmalloc、jemalloc)会为不同线程分配不同内存池,也有助于缓解伪共享。
内存一致性模型
缓存一致性保证了单个内存地址的读写顺序。内存一致性模型则定义了不同内存地址的读写操作,在其他处理器看来应该以何种顺序生效。这是更复杂的问题。
最直观的模型是顺序一致性。它要求任何执行结果都等同于所有内存操作按某种全局顺序依次执行,且每个处理器的操作顺序与其程序顺序一致。这对程序员来说最容易理解,但会严重限制硬件优化(如乱序执行、写缓冲),导致性能低下,因此没有现代高性能CPU采用严格的顺序一致性。
现实中的CPU为了性能会进行各种优化,这可能导致反直觉的结果。考虑以下代码:
// 初始值: a = 0, b = 0
// 线程 1 (P0) // 线程 2 (P1)
a = 1; while (b == 0);
b = 1; assert(a == 1);
在顺序一致性模型下,断言永远不会失败。因为如果P1看到b == 1,那么P0必然已经执行了a = 1。然而,在实际的硬件上,由于写缓冲区和延迟的缓存失效等优化,断言有可能失败。
- 写缓冲区:核心写入数据时,可能先放入一个本地的写缓冲区,然后立即继续执行,而不等待该写入操作传播到其他核心的缓存。这可能导致写操作对其他核心“不可见”。
- 延迟的缓存失效:核心收到使某个缓存行失效的请求时,可能先发送确认回复,然后再实际执行失效操作,以减少发送方的等待时间。
为了确保关键操作的顺序,程序员需要使用内存屏障。
- 写屏障:确保在该屏障之前的所有写操作的结果,在该屏障之后的任何写操作开始之前,对其他核心可见。
- 读屏障:确保在该屏障之前的所有读操作完成后,才执行该屏障之后的任何读操作。
在高阶语言如C++中,可以使用原子操作并指定内存顺序(如std::memory_order_seq_cst)来获得顺序一致性语义,编译器会插入必要的屏障。但在底层C代码中,可能需要显式使用内置函数(如__sync_synchronize())来插入内存屏障。
总结
本节课中我们一起学习了并行架构中几个关键且相互关联的概念。
- 缓存一致性是硬件机制,用于维护多核系统中缓存数据的一致性,但其通信开销不容忽视。
- 伪共享是缓存一致性带来的一个典型性能陷阱,需要通过数据布局优化(如填充)来避免。
- 内存一致性模型定义了多线程程序中内存操作的可见性规则。现代CPU为了性能,并不提供严格的顺序一致性,这可能导致程序出现违反直觉的行为。
- 内存屏障是程序员用来在关键位置强制内存操作顺序的工具,是编写正确高性能并发代码的重要手段。

理解这些底层原理,有助于我们写出既能利用硬件并行能力,又能保证正确性的高效程序。在接下来的课程中,我们将继续探索更多并行编程的实践技巧。
016:锁 🔒

在本节课中,我们将深入探讨锁的实现原理、性能成本以及如何设计更高效的锁。我们将从基础的硬件原子指令开始,逐步分析自旋锁、票锁、队列锁(如MCS锁)等不同锁机制的工作原理和优化策略。
锁的实现概述
上一节我们回顾了进程间通信(IPC)和线程间共享内存的基本概念。本节中,我们来看看如何实现锁来确保多线程环境下的互斥访问。
在多处理器系统中,仅靠禁用中断或上下文切换无法防止真正的并行访问。因此,我们需要硬件提供的原子指令来构建锁的基础。
以下是几种关键的硬件原子指令:
- Test-and-Set:原子地测试一个内存位置的值,并将其设置为1。
- Fetch-and-Increment:原子地读取一个内存位置的值并递增它。
- Compare-and-Swap:原子地比较一个内存位置的值,如果匹配则将其替换为新值。
- Fetch-and-Store:原子地读取一个内存位置的值并写入一个新值。
这些指令保证了操作的原子性,即它们要么完全执行,要么完全不执行,不会产生数据竞争。
自旋锁
自旋锁是最简单的锁实现之一。其核心思想是线程通过循环(“自旋”)不断尝试获取锁,直到成功为止。
以下是使用Test-and-Set指令实现自旋锁的伪代码:
// 锁变量,0表示空闲,1表示被占用
int lock = 0;
void acquire_spinlock() {
// 当锁被占用时,持续尝试获取
while (test_and_set(&lock) == 1) {
// 自旋等待
}
}
void release_spinlock() {
// 释放锁,只需简单地将锁变量置0
lock = 0;
}
在acquire函数中,test_and_set指令会原子地将锁变量设置为1。如果它返回的旧值是0,则表示当前线程成功获取了锁。如果返回1,则表示锁已被其他线程持有,当前线程需要继续循环尝试。在release函数中,由于持有锁的线程是唯一能执行此操作的线程,因此可以安全地将锁变量直接设为0。
自旋锁适用于预期等待时间非常短的场景,或者在中断处理程序等不允许阻塞的上下文中。然而,当多个线程激烈竞争同一个锁时,所有线程都在不断地执行test_and-set操作,这会导致缓存行在多个CPU核心间频繁地无效化和同步,产生大量的缓存一致性流量,严重降低系统性能。
改进的自旋锁策略
为了缓解自旋锁在高竞争下的性能问题,我们可以采用一些优化策略。
指数退避
指数退避策略在尝试获取锁失败后,让线程等待一段时间再重试,且等待时间随失败次数指数增长。
以下是该策略的优缺点:
- 优点:可以减少对共享内存位置的频繁访问,从而降低缓存一致性开销。
- 缺点:无法保证公平性,可能导致线程饥饿。
Test-and-Test-and-Set
这种策略先通过普通的读操作(不触发缓存一致性协议)自旋检查锁状态,只有当锁可能空闲时,才尝试执行原子的Test-and-Set操作。
以下是其伪代码:
void acquire_ttas() {
while (1) {
// 第一阶段:仅读取,不修改
while (lock == 1) {
// 自旋等待
}
// 第二阶段:尝试原子获取
if (test_and_set(&lock) == 0) {
break; // 成功获取锁
}
}
}
这种方法减少了昂贵的原子操作次数,但所有线程仍然在竞争同一个内存位置,缓存问题依然存在。
票锁
票锁旨在解决公平性问题,它模拟了排队叫号系统,保证了先到先服务(FIFO)的顺序。
票锁使用两个计数器:
next_ticket:下一个可用的票号(原子递增)。now_serving:当前正在服务的票号。
以下是票锁的伪代码:
int next_ticket = 0;
int now_serving = 0;
void acquire_ticket_lock() {
// 原子地取一个号
int my_ticket = fetch_and_increment(&next_ticket);
// 等待叫到自己的号
while (now_serving != my_ticket) {
// 自旋等待
}
}
void release_ticket_lock() {
// 叫下一个号
now_serving++;
}
在acquire中,每个线程原子地获取一个唯一的递增票号。然后,它循环检查now_serving是否等于自己的票号。在release中,持有锁的线程只需递增now_serving即可。票锁保证了公平性,但所有等待线程仍在读取同一个now_serving变量,缓存争用问题仍未完全解决。
队列锁(MCS锁)
队列锁(以MCS锁为例)的核心思想是让每个等待线程在自己的本地内存位置上自旋,从而彻底消除缓存行在多核间的“乒乓”效应。
MCS锁为每个线程维护一个节点(qnode),其中包含:
locked:指示该线程是否应继续等待。next:指向队列中下一个等待线程的节点。
此外,还有一个全局的tail指针,指向队列的末尾。
以下是MCS锁acquire操作的简化步骤:
- 初始化本地节点:
my_node->next = NULL; my_node->locked = 1; - 使用原子操作将全局
tail指针指向自己的节点,并获取前驱节点。 - 如果前驱节点不为空,则将前驱节点的
next指向自己的节点,然后在自己的locked字段上自旋等待。 - 如果前驱节点为空,则表示自己是第一个,直接获得锁。
以下是MCS锁release操作的简化步骤:
- 检查自己的
next字段。 - 如果
next为空(可能没有等待者),则尝试原子地将tail从指向自己改为NULL。如果失败,说明有新线程刚加入队列,需要等待它链接上来。 - 如果
next不为空,则直接将后继节点的locked字段设为0,唤醒它。
MCS锁的优点在于:
- 缓存友好:每个线程只在自己的
locked变量上自旋,无全局争用。 - 公平:隐式维护了FIFO队列。
- 高性能:显著减少了多核间的缓存一致性流量。
其代价是需要额外的内存来维护每个线程的节点(空间复杂度约为O(P),P为线程数)。Linux内核等高性能系统中广泛使用了MCS锁或其变种。
实际锁实现与并行编程技巧
在实际应用中,锁的实现往往更加复杂。例如,Pthreads库中的互斥锁(pthread_mutex_t)通常采用混合策略:先尝试一段时间的自适应自旋(类似Test-and-Test-and-Set),如果失败则将线程挂起并放入等待队列,同时可能支持优先级继承、超时等高级特性。
了解了锁的原理后,我们可以应用一些并行编程技巧来提升性能:
计数器优化
对于被大量线程频繁递增的计数器,如果精确值并非时刻关键,可以采用以下优化:
- 每线程局部计数器:每个线程更新自己的局部计数器,定期或最终需要时再汇总。这避免了频繁的全局锁竞争。
- 近似计数:在某些容忍误差的场景(如统计数据),甚至可以允许最终汇总时存在轻微的数据竞争,以换取极致性能。
数据布局优化
根据数据的访问模式优化其内存布局:
- 分离读写:将频繁修改的数据与主要读取的数据分开存放,避免修改时使大量只读数据所在的缓存行无效。
- 避免伪共享:确保被不同线程独立访问的变量位于不同的缓存行中(通常通过填充字节实现)。
- 锁与数据分离:尽量让锁变量和它保护的数据不在同一个缓存行,防止锁的争用影响数据访问。
使用读写锁
对于“读多写少”的数据结构,使用读写锁(如pthread_rwlock_t)可以允许多个线程同时读取,而写入时则独占访问,从而提高并发度。
总结

本节课中我们一起学习了锁的底层实现与性能优化。我们从基础的硬件原子指令和简单的自旋锁出发,分析了其在高竞争下的性能瓶颈。接着,我们探讨了通过指数退避、Test-and-Test-and-Set策略来缓解问题。然后,我们介绍了保证公平性的票锁,以及能彻底解决缓存争用、性能优异的队列锁(如MCS锁)的实现原理。最后,我们了解了一些实际的锁实现(如Pthreads互斥锁)和提升并行程序性能的数据布局与编程技巧。理解这些内容有助于我们编写出更高效、可扩展的多线程程序。
017:无锁化编程

在本节课中,我们将要学习如何避免使用锁进行同步,即无锁化编程。我们将探讨锁带来的挑战,介绍非阻塞同步的基本概念,并深入讲解一种在Linux内核中广泛使用的高性能技术——读-拷贝-更新(RCU)。
锁的挑战回顾
上一节我们介绍了各种锁机制。本节中,我们来看看使用锁时面临的一些主要挑战。虽然锁是防止数据竞争、确保数据一致性的简单方法,但它们也带来了一系列问题。
以下是锁的几个主要问题:
- 死锁:一组线程相互等待对方持有的资源,导致所有线程都无法继续执行。
- 优先级反转:低优先级线程持有锁时,高优先级线程因等待该锁而被阻塞,但低优先级线程又无法被调度运行以释放锁。
- 缺乏容错性:如果持有锁的线程崩溃或被长时间延迟(如发生缺页错误),其他所有线程都将被阻塞。
- 队列化(Convoying):线程偶尔访问共享数据,大部分时间无竞争。但如果一个线程被延迟,其他线程会开始堆积等待,从而人为地制造出竞争场景。
- 开销昂贵:即使在没有竞争的情况下,获取和释放锁的操作(如原子指令、缓存一致性协议)也可能需要上百个CPU周期,如果临界区本身很小,锁的开销占比会非常高。
随着内存层次结构变深、CPU流水线变长以及乱序执行优化,锁操作(尤其是需要内存屏障时)可能显著拖慢流水线,其性能开销已成为一个重要问题。
非阻塞同步基础
鉴于锁的种种问题,一个核心思路是:如果不需要,就不要使用锁。这就是非阻塞同步,或称无锁编程。
在锁定模型中,线程通过互斥访问共享资源。在非阻塞同步中,所有线程可以并发访问对象,通过原子指令(如比较并交换 CAS)来检测冲突。如果操作失败(例如,因为其他线程已修改数据),则回滚并重试。
以下是一个使用 CAS 实现原子计数器的简单示例:
int atomic_increment(int *counter) {
int old_value;
do {
old_value = *counter; // 读取当前值
// 尝试将值从 old_value 原子地更新为 old_value + 1
} while (!compare_and_swap(counter, old_value, old_value + 1));
return old_value + 1; // 返回新值
}
对于更复杂的操作(如修改数据结构中的多个字段),可以将所有修改准备在私有内存中,然后通过一个原子操作(如交换指针)来“提交”更改。
无锁编程的陷阱:ABA问题
让我们尝试为链表实现的栈设计一个无锁版本。一个直观的想法是:在 push 和 pop 操作中使用 CAS 来原子地更新栈顶指针 head。
然而,这种方法存在一个经典问题:ABA问题。
问题描述:
- 线程1读取栈顶指针
head,值为A,并记下A->next为B。 - 线程1被挂起。
- 线程2执行
pop,移除了A,接着又移除了B,然后又将A推回栈中。此时栈结构为A -> N -> C,但A的next指针已指向新节点N。 - 线程1恢复运行。它看到的
head仍然是A,它保存的next指针仍是B。它执行CAS(head, A, B)。 CAS成功(因为head当前确实是A),线程1将head设置为B。这导致栈顶变为孤立的B节点,丢失了节点N和C。
解决方案:
一种常见方案是使用带标签的指针。每次修改指针时,同时原子地增加一个与之关联的版本号。这样,即使地址 A 被重用,其版本号也不同,CAS 操作会因为版本号不匹配而失败。不过,这需要CPU架构支持双字(指针+版本号)的原子操作,并且需要妥善处理旧版本内存的回收问题。
读-拷贝-更新(RCU)
对于许多应用场景,读取到稍旧但一致的数据是可以接受的。读-拷贝-更新(RCU)正是利用了这一点,它允许读者在完全无锁的情况下进行,从而获得极高的读取性能。
核心思想:
- 发布-订阅机制:写者创建数据的新版本,然后通过一个原子指针赋值操作“发布”它。
- 多版本维护:在回收旧数据之前,必须确保所有可能持有旧数据引用的读者都已经退出其“读临界区”。
- 宽限期:这是一个时间间隔,在此之后,可以保证所有读者都已不再引用旧数据。之后,写者可以安全地回收旧版本。
在Linux内核中的使用:
为了简化编程并正确处理内存屏障,Linux内核提供了专用API:
- 写者使用
rcu_assign_pointer()来发布新指针。 - 读者使用
rcu_dereference()来安全地读取指针。 - 读者用
rcu_read_lock()和rcu_read_unlock()标记读临界区(在内核中,这通常只是禁用抢占)。 - 写者使用
synchronize_rcu()来等待一个宽限期结束,之后即可安全释放旧数据。
RCU链表更新示例:
// 读者
rcu_read_lock();
list_for_each_entry_rcu(node, &head, list) {
// 安全地访问 node->data
}
rcu_read_unlock();
// 写者(删除节点)
p = search_for_node_to_delete();
q = kmalloc(sizeof(*q)); // 创建新节点或进行修改
*q = *p; // 复制
// ... 修改 q ...
list_replace_rcu(&p->list, &q->list); // 原子替换
synchronize_rcu(); // 等待所有读者退出临界区
kfree(p); // 安全释放旧节点
性能优势:
RCU在读多写少的场景下性能接近线性扩展,因为它消除了读者之间的所有竞争。虽然写者会变慢(需要等待宽限期),但总体吞吐量通常得到极大提升。在Linux内核中,RCU的使用比例正在逐年增长,以替代部分锁机制。
工具选择指南
本节课中我们一起学习了无锁化编程的动机和主要技术。最后,我们来总结一下如何选择合适的同步工具:
- 只读或允许读旧数据:如果算法能处理并发读和单个更新,且不介意数据稍旧,RCU 是最佳选择,能提供近乎线性的读取扩展性。
- 简单的更新密集型数据结构:对于链表、队列等,可以考虑使用 CAS 实现无锁算法。但需小心处理ABA等问题,通常建议使用成熟的库实现。
- 复杂的数据结构或逻辑:当正确性至关重要且实现无锁方案过于复杂时,直接使用互斥锁(Mutex) 是更简单可靠的选择。能工作的代码远优于无法正确运行的复杂代码。

记住,你有许多工具可供选择,应根据具体问题选择最合适的一种。目标是编写出正确、高效且易于维护的并发代码。
018:Map Reduce


概述
在本节课中,我们将学习Map Reduce编程模型。这是一种用于处理海量数据的分布式计算框架,由Google提出并广泛应用。我们将了解其核心思想、工作原理以及如何通过定义简单的map和reduce函数来处理复杂的大数据任务。


课程背景与当前进展
到目前为止,我们的课程涵盖了CPU架构、编译器优化、缓存优化、动态内存管理等单线程性能主题。随后,我们探讨了线程同步、并行架构、更好的锁机制以及如何避免锁,主要关注如何在多核系统上利用这些技术来提升性能。

现在,我们将进入分布式计算领域。虽然这本身是一门更深入的课程,但系统程序员常常需要参与设计和构建这类系统。
大数据分析的挑战
在互联网规模下,像Google或Facebook这样的公司需要处理数百万TB甚至PB级别的数据。这被称为大数据分析。我们需要解决如何索引、分析、存储数据,并以低延迟提供服务的问题。
例如,Google搜索虽然可能变慢,但至少是快速的。那么,我们如何在互联网规模的数据上执行简单的计算呢?比如grep、排序或单词计数。如果你想统计“potato”这个词在互联网上出现了多少次,就需要处理海量数据。
Google搜索的工作原理
当用户进行Google搜索时,查询被发送到Web服务器,然后Web服务器将查询转发给一组索引服务器。索引服务器内部类似于一本书的索引,它是一个巨大的查找表,告诉你哪些页面包含哪些单词。之后,查询会转到文档服务器,获取网站的描述信息,最终呈现为搜索结果。
我们将重点关注索引服务器,以及如何用数据填充它们。
构建索引的挑战
构建索引主要有两个挑战:
- 网页索引:需要爬取和分析互联网上的所有网页,这是一个巨大的数据量。最终输出是关键词及其对应的URL列表。
- 网页排名(PageRank):在获得URL列表后,需要确定哪些页面最重要。PageRank算法通过分析指向目标页面的链接数量和质量来进行排序。
一个简单的索引算法可能如下所示:
for page in all_pages:
for word in page:
if word not in hashmap:
hashmap[word] = []
hashmap[word].append(page.url)
然而,面对数十亿网页,我们无法在一台机器上运行这个程序。我们需要在多台机器上并行处理。
分布式索引的思路
我们可以将网页列表分配给不同的节点(服务器)进行处理。例如,一台服务器处理ESPN.com和NBA.com的内容,另一台处理新闻网站的内容。最后,我们将来自不同机器的结果合并。
但这里存在一个问题:如果只有一个合并节点,它会成为瓶颈。因此,我们可能需要多个合并服务器,每个负责合并特定关键词的结果。例如,一个服务器合并“NBA”和“Trump”的结果,另一个合并“NFL”的结果。最后再进行一次最终合并。
然而,这种方法存在几个问题:
- 负载不均衡:如果某个合并服务器分配到的关键词特别多,它会拖慢整个流程。
- 故障处理:在多机环境中,机器故障很常见。如果索引器(mapper)或合并器(reducer)失败,需要重启并等待。
- 幂等性:操作需要是幂等的,即相同的输入总是产生相同的输出,没有奇怪的副作用。
- 数据倾斜:如果单词频率分布不均,会导致某些服务器负载过重。
Map Reduce 的核心理念
Google的MapReduce框架的核心思想是:将数据处理逻辑与复杂的分布式系统细节分离。
应用程序员只需要关心两个操作:
- Map操作:处理输入键值对,生成一组中间键值对。
- Reduce操作:将所有具有相同中间键的值合并起来,产生最终输出。
而系统框架(如Hadoop)则负责处理数据分区、负载均衡、故障恢复、机器间通信等所有复杂细节。这样,系统程序员的任务是构建和维护这个强大的框架,让其他开发者能轻松使用。
Map Reduce 编程模型
程序员编写两个函数:
- Map函数:输入是
(key, value),输出是一系列(intermediate_key, intermediate_value)。 - Reduce函数:输入是
(intermediate_key, [list of intermediate_values]),输出是合并后的最终值(intermediate_key, final_value)。
许多问题都可以用这个模型表达。例如,统计互联网上每个单词的出现次数:
- Map:对于每个网页,输出
(word, 1)对(表示该单词在此页出现一次)。 - Reduce:对于每个单词,将其所有的
1累加,得到(word, total_count)。
MapReduce的一个重大突破是 “将计算移动到数据附近”。与其在网络上移动PB级的数据,不如将map和reduce操作发送到已经存储数据的服务器上执行。
Map Reduce 工作流程
- 输入分片:框架将输入数据分割成多个片段。
- Map阶段:多个工作节点并行处理各自的数据分片,执行用户定义的Map函数,生成中间键值对。
- Shuffle与分区:框架根据中间键将所有数据重新分区和排序,确保相同键的数据被发送到同一个Reduce节点。
- Reduce阶段:Reduce节点接收属于自己负责的键的所有值,执行用户定义的Reduce函数,生成最终输出。
- 输出:最终结果被写入文件系统。
整个流程由一个主节点(Master)协调,它监控工作节点的状态,处理节点故障。
示例:反向网络链接
我们可以用MapReduce找出指向某个特定URL的所有链接(反向链接)。
- Map:输入
(source_url, page_content)。找出该页面中的所有出站链接(target_url)。对于每个target_url,输出(target_url, source_url)。 - Reduce:输入
(target_url, [list of source_urls])。输出(target_url, merged_list_of_source_urls)。这样我们就得到了每个目标URL的所有来源链接列表,可用于计算其权威性。
容错与优化
- 工作节点故障:主节点通过定期心跳检测节点故障。一旦发现,会在其他可用节点上重新执行失败的任务。
- 主节点故障:主节点会定期将状态检查点保存到可靠存储。如果主节点失败,可以从检查点恢复。主节点是单点故障,但实践中其故障率远低于工作节点。
- 处理“慢节点”:在任务阶段接近结束时,框架可能会将剩余任务复制一份发送给空闲节点执行。哪个先完成就采用哪个的结果,这可以防止个别慢节点拖慢整体进度。
- Combiner优化:在Map阶段,如果同一个节点上生成了多个相同的中间键,可以先在本地进行部分合并(Combiner),减少需要网络传输的数据量。

总结
本节课我们一起学习了MapReduce分布式计算框架。我们了解了其产生的背景——处理互联网级别的海量数据,以及其核心设计思想:通过让程序员仅定义map和reduce两个函数,由系统框架自动处理分布式环境下的数据分区、任务调度、故障恢复等复杂问题。我们还通过单词计数和反向链接的示例,理解了MapReduce的编程模型和工作流程。最后,我们探讨了框架为实现高效可靠运行所采用的一些关键容错和优化机制,如节点故障处理、慢任务备份执行等。MapReduce是理解现代大规模数据处理系统的重要基石。
019:最终复习

在本节课中,我们将对课程涵盖的所有核心内容进行一次快速的最终复习,帮助你为期末考试做好准备。我们将回顾从CPU架构、编译器优化到并行编程和锁机制等所有关键主题。
课程内容概览
课程内容涵盖了从顺序程序优化到并行系统编程的广泛领域。以下是主要章节的回顾。
顺序程序优化
上一节我们介绍了课程的整体结构,本节中我们来看看课程的开端:顺序程序优化。我们首先探讨了CPU架构和性能分析。
CPU架构基础
为了让CPU运行得更快,现代处理器采用了多种技术。
以下是几种关键技术:
- 流水线:让CPU的不同阶段同时工作,提高指令吞吐率。
- 分支预测:预测程序分支的走向,减少流水线停顿。
- 乱序执行:动态重新安排指令执行顺序,以更好地利用CPU资源。
- 指令级并行:单个指令可以同时发出多个操作。
- 同步多线程:通过从多个线程获取指令来保持流水线满载。
以Intel处理器发展史为例,其演进路径大致如下:从无流水线(每条指令耗时即其CPI),到引入流水线和分支预测(使CPI接近1),再到采用超标量架构(增加并行执行单元),最后加入乱序执行和同步多线程以最大化利用流水线。
性能剖析
在开始优化之前,必须先进行性能剖析,避免过早优化(这是万恶之源)。我们使用了多种工具。
以下是几种剖析工具:
- GProf / GCov:GCov主要用于代码覆盖率分析,而非严格意义上的性能剖析。
time命令:测量程序的墙上时钟时间,即用户感知的时间。perf工具:可以获取大量硬件性能事件细节,如缓存未命中。
性能提升受限于阿姆达尔定律。该定律公式为:
Speedup = Old Time / New Time
即使我们能将程序中某部分(例如占50%运行时间的部分)优化到零耗时,整体加速比上限也仅为2倍。
编译器优化
编译器能够自动进行多种优化,但不同编译器在不同方面的能力有差异。使用-O1级别优化时,GCC或Clang会进行基础优化。
以下是-O1包含的典型优化:
- 常量传播与折叠
- 公共子表达式消除
- 复制传播
- 死代码消除
- 循环不变代码外提
- 函数内联(针对非常小的函数)
更高级别的优化(如-O2、-O3)可能包括更智能的指令调度、循环展开和向量化。我们发现Clang在自动向量化方面通常比GCC做得更好。作为程序员,我们的职责是编写清晰、可读的代码,并帮助编译器消除优化障碍(例如启用链接时优化)。优化的重点应放在最内层循环上,因为它们是代码中的热点。
缓存性能
上一节我们讨论了编译器如何帮助我们优化,本节中我们来看看对性能影响巨大的另一个因素:内存层次结构,特别是缓存。
局部性原理
缓存之所以有效,是因为程序通常表现出两种局部性。
以下是两种主要的局部性类型:
- 时间局部性:程序倾向于重复访问相同的内存位置。
- 空间局部性:程序倾向于访问彼此相邻的内存位置。
硬件预取器会尝试利用这些模式。通常,我们应编写具有良好局部性的代码(例如连续访问数组),而不是尝试手动预取,因为硬件预取器通常更高效。对于大型数据结构,可能需要调整其内存布局以提高缓存利用率(例如,将频繁一起访问的键值对存储在一起,而非分成两个数组)。
缓存优化实践
为了利用缓存,我们需要关注缓存行(通常为64字节)。我们讨论了矩阵乘法中的分块技术,通过将计算限制在能放入缓存的数据块内,来重用已加载到缓存中的数据,从而显著提升性能。
动态内存管理
上一节我们探讨了如何优化缓存使用,本节中我们来看看程序运行时另一个关键系统:动态内存分配器。
我们学习了三种主要的内存分配器设计。
以下是三种分配器模型:
- 隐式空闲链表:在内存块头部和尾部放置大小标记,分配时需要遍历所有块(包括已分配和空闲块)来寻找空闲空间。
- 显式空闲链表:额外维护一个连接所有空闲块的链表,使得分配时只需搜索空闲块,速度更快。
- 分离空闲链表:为不同大小的块维护不同的空闲链表,可以更快地找到合适大小的块。
分配策略包括首次适配、最佳适配和最差适配。当释放内存块时,需要与相邻的空闲块合并(合并操作)。我们还简要介绍了ptmalloc(glibc默认)和jemalloc等现代分配器如何利用线程局部存储和页面管理来提升多线程性能。
多线程与同步
上一节我们了解了内存如何管理,本节中我们来看看如何让多个线程协同工作。多线程编程的核心是数据共享与同步。
默认情况下,线程间内存是共享的,这可能导致数据竞争。数据竞争的定义是:两个或更多线程并发访问同一内存位置,且至少有一个是写操作。为防止数据竞争,我们需要同步原语,如互斥锁,它确保互斥访问。但粗粒度的锁(单个锁保护大量数据)会引入争用,降低性能。细粒度的锁(多个锁保护不同数据区域)能提升并发度,但可能带来死锁风险。
我们使用Pthread库(或C++的std::thread,其底层也使用Pthread)来创建和管理线程。在并行化循环时,需要注意迭代间的依赖关系。如果每次迭代依赖于前一次的结果,简单的并行化会导致读取旧值,此时需要使用屏障等同步机制来协调线程。
并行架构:一致性与连贯性
上一节我们介绍了多线程基础,本节中我们深入探讨在多核环境下保证内存视图正确性的底层机制。这是理解并行性能的关键。
在多核系统中,每个核心通常有私有的L1和L2缓存,并共享L3缓存。因此,我们需要确保不同核心看到的内存是一致的。这里需要区分两个重要概念。
以下是两个核心概念:
- 缓存连贯性:关注同一个内存位置在不同核心的缓存中的值是否一致。
- 内存一致性:关注不同内存位置的读/写操作在所有核心看来执行的顺序。
缓存连贯性与MESI协议
缓存连贯性通常由MESI协议实现。该协议为缓存行定义四种状态:修改、独占、共享、无效。最初的MSI协议(无独占状态)会导致不必要的缓存失效。添加独占状态后,当只有一个核心持有某缓存行时,它可以在本地修改而无需通知其他核心,从而提升性能。
伪共享是连贯性导致的一个典型性能问题。它发生在两个核心访问同一缓存行中不同且独立的数据变量时。根据MESI协议,这会导致缓存行在核心间来回无效和传输,即使数据本身并未共享,也会严重损害性能。解决方法包括添加填充字节,确保变量位于不同的缓存行。
使用perf工具可以监测到因伪共享导致的缓存未命中率飙升。在多插槽NUMA系统中,跨插槽通信的延迟更高,此时可以通过设置线程的CPU亲和性,让通信频繁的线程运行在物理上接近的核心上,以优化性能。
内存一致性
顺序一致性是最直观的模型,它要求所有核心的内存操作看起来像是按某个单一全局顺序执行的,且每个核心的操作按其程序顺序出现。但这会禁用CPU的许多优化(如乱序执行、写缓冲区),性能代价很高。
实际硬件使用更宽松的内存模型。当需要强制排序时,可以使用内存屏障。
- 读屏障:确保屏障前的所有读操作先于屏障后的所有读操作完成。
- 写屏障:确保屏障前的所有写操作先于屏障后的所有写操作完成。
- 全屏障:确保屏障前的所有内存操作先于屏障后的所有内存操作完成。
高级语言(如C++)的原子操作和Pthread锁内部已经包含了必要的屏障。但如果在底层进行无锁编程,就必须仔细考虑内存序。
锁的实现与优化
上一节我们了解了内存模型,本节中我们来看看如何基于原子指令实现高效锁。锁的性能至关重要。
自旋锁
最简单的锁是自旋锁,线程在获取锁失败时会忙等待。我们首先实现了基于test-and-set指令的自旋锁,但它会持续产生写操作,导致缓存行无效。改进后的test-and-test-and-set锁先通过普通读操作自旋,直到锁可能可用时才尝试原子操作,减少了缓存流量。
票号锁通过两个计数器(当前服务号和下一个票号)实现。线程取号后自旋等待服务号等于自己的票号。它的主要优点是公平性,能大致按照请求顺序分配锁。
队列锁(如MCS锁)将等待线程组织成链表。每个线程自旋于自己本地的标志变量上,而非一个全局变量。这极大地减少了缓存一致性流量,因为每个线程只访问自己的缓存行,性能更好。
避免锁:无锁编程
上一节我们探讨了如何实现更好的锁,本节中我们来看看能否完全避免锁。锁的争用和开销有时难以承受,无锁编程提供了另一种思路。
无锁同步通过原子指令(如比较并交换CAS、获取并增加fetch-and-add)直接操作共享数据。其常见模式是:在本地准备新状态,然后尝试用原子操作(如CAS)将指向旧状态的指针更新为指向新状态。
无锁编程可能遇到ABA问题:一个线程读取值A,在此期间,另一个线程将值改为B后又改回A。当第一个线程执行CAS时,会发现值仍是A,从而错误地认为数据未变并执行更新。这可能导致数据不一致。解决方案通常包括使用带版本号的指针。
RCU是一种用于读多写少场景的高效同步机制。其核心思想是:写者复制要修改的数据结构,更新副本,然后通过原子指针切换使新版本对读者可见。旧版本的回收会延迟到确保所有读者都离开后才进行。这允许读者和写者并发执行,极大地提升了读性能。
MapReduce与课程总结
最后,我们简要介绍了MapReduce编程模型。它用于大规模数据分析,程序员只需定义map和reduce函数,系统自动处理分布式执行、容错、负载均衡等复杂细节。这体现了系统编程的一个目标:将复杂性隐藏在易用的接口之下。
技术演进与核心收获
摩尔定律在单核频率上已接近极限,未来性能提升将依赖并行化(多核、分布式)。我们课程主要关注单机并行。
尽管技术不断变化,但一些核心原则始终有用:
- 优化瓶颈是关键。
- 缓存(CPU缓存、内存缓存、乃至Web缓存如Memcached、CDN)将始终存在并影响性能。
- 并行化是永恒的课题。
- 最快的操作是什么都不做。在优化前,先问是否真的需要执行该操作。
- 视情况而定。没有放之四海而皆准的优化方案。
比掌握具体技术更重要的是理解原理。例如,理解为什么缓存连贯性会影响性能,而不仅仅是知道如何避免伪共享。保持怀疑和好奇,探究工具是否真的帮到了你,底层究竟发生了什么,这将帮助你在未来不断变化的技术环境中保持优势。
总结

本节课中我们一起回顾了高性能系统编程课程的全部核心内容:从CPU架构与剖析、编译器优化、缓存性能、动态内存管理,到多线程同步、并行架构的内存模型、各种锁的实现与优化,以及无锁编程技术。希望这次复习能帮助你巩固知识,为期末考试做好充分准备。恭喜你完成了课程的主要内容!
020:Rust 语言入门

在本节课中,我们将学习 Rust 语言的基础知识。Rust 是一种旨在成为更好 C++ 的下一代系统编程语言,其核心思想是只编译正确的程序。编译器会进行大量检查来确保程序的正确性。
Rust 的核心优势
Rust 编译器主要检查三个核心概念,这带来了三大优势:
- 所有权:防止内存问题,无需垃圾回收。它确保对象在任何时刻都只被一个所有者拥有,编译器会强制执行这一点。
- 借用检查器:防止并发问题,如数据竞争。它只允许存在一个可变引用,这意味着可以有任意多个读取者,但同一时间只能有一个写入者。如果违反此规则,编译器会报错。
- 生命周期:确保引用始终有效。
Hello, World!
Rust 的 “Hello, World!” 程序看起来很简单,除了 println! 后面的感叹号。
fn main() {
println!("Hello, World!");
}
在 Rust 中,println! 技术上是一个宏。宏总是以感叹号结尾。与 C 语言中只是文本替换的预处理器宏不同,Rust 的宏与编译器协同工作,因此能提供有意义的错误信息,并能做很多高级操作,同时避免了错误的文本替换。

类型系统
和 C 或 C++ 一样,Rust 是静态类型语言。然而,它的编译器很智能,默认会进行大量的类型推断。你可以省略类型,让编译器去推断。
这有点像 C++ 中的 auto 关键字,但你不必写 auto,因为这是默认行为。唯一必须显式声明类型的地方是函数参数。
Rust 的原始类型都有明确的尺寸,无需像在 C 中那样记住 short、int 等类型的大小。
- 有符号整数:以
i开头,后跟位数,例如i8,i16,i32,i64,i128,以及isize(指针大小,取决于架构)。 - 无符号整数:以
u开头,例如u8,u16等。 - 浮点数:以
f开头,例如f32,f64。 - 字符:
char类型是一个 Unicode 标量值,占用 4 字节,可以表示任何 Unicode 字符,包括表情符号。 - 布尔值:
bool,值为true或false。 - 单元类型:
(),类似于void类型,其唯一可能的值是空元组。
如果你想为字面量指定类型,可以添加后缀。例如,默认数字是 i32 类型,如果你想确保字面量 5 是 u16 类型,可以写成 5u16。
变量与可变性
Rust 的一个特点是变量默认是不可变的。
在 Rust 中,使用 let 语句定义变量。例如:
let x = 42;
默认情况下,x 是不可变的。如果你尝试修改它,编译器会报错。
x = 0; // 编译错误!
如果你想让变量可变,必须显式声明:
let mut x = 42;
x = 0; // 现在可以了
另一个有用的特性是变量遮蔽。你可以在同一作用域内用 let 重新声明同名变量,甚至可以改变其类型。
let x = 42;
let x = "现在我是字符串了"; // 遮蔽了之前的 x
这在你需要改变变量类型时很有用,避免了起 x_str 这类新名字的麻烦。

函数

Rust 是一种基于表达式的语言。函数的返回值是最后一个表达式的值,无需显式使用 return 关键字(除非需要提前返回)。
fn get_val() -> i32 {
42 // 隐式返回 42
}
这与下面的写法等价:
fn get_val() -> i32 {
return 42;
}
基于表达式的风格让编写函数变得更简洁。
结构体与方法

和 C/C++ 一样,你可以定义结构体。

struct Point {
x: f64,
y: f64,
}
要为结构体实现方法,需要使用 impl 块。
impl Point {
fn distance(&self) -> f64 {
(self.x.powi(2) + self.y.powi(2)).sqrt()
}
}
这里的 &self 是一个对自身的引用,类似于 C++ 中的 this 指针。方法体最后的表达式就是返回值。
创建结构体实例:
let point = Point { x: 3.0, y: 4.0 };
println!("距离是: {}", point.distance());
通常,Rust 中会隐藏结构体字段,并通过一个关联函数(通常叫 new)来构造实例。
impl Point {
fn new(x: f64, y: f64) -> Point {
Point { x, y } // 字段名与变量名相同时可简写
}
}
let point = Point::new(3.0, 4.0);
所有权与借用
上一节我们介绍了结构体,本节中我们来看看 Rust 最核心的概念:所有权与借用。
在之前 distance 方法中的 &self,是一个不可变借用。这意味着你可以同时拥有任意多个对数据的不可变引用。
你也可以拥有一个可变借用(&mut self),但同一时间只能有一个可变借用。这由编译器强制执行,以防止数据竞争。
如果你不使用引用,而是直接传递值,会发生所有权转移。
fn foo(p: Point) { // 这里 p 获得了 Point 的所有权
println!("Point is {}, {}", p.x, p.y);
} // p 离开作用域,被丢弃
let point = Point::new(1.0, 2.0);
foo(point); // point 的所有权转移给 foo 函数
// println!("{}", point.x); // 编译错误!point 已不再有效
编译器会在编译时捕获这类“使用已移动值”的错误,防止了 use-after-free 等问题。
如果类型实现了 Copy 特征,那么在赋值或传参时会进行隐式拷贝,而不是移动所有权。对于简单类型(如整数),Rust 会自动为其实现 Copy。
特征
特征类似于其他语言中的接口,但更灵活。不同的特征可以有同名方法,编译器会根据上下文推断。
例如,如果我们希望 Point 能被拷贝,可以实现 Clone 和 Copy 特征。
#[derive(Clone, Copy)] // 使用 derive 属性让编译器自动生成实现
struct Point {
x: f64,
y: f64,
}
#[derive(...)] 是一个编译器指令,可以为简单的结构体自动生成特征实现。另一个有用的特征是 Debug,它可以让你方便地打印结构体内容。
#[derive(Debug)]
struct Point {
x: f64,
y: f64,
}
let p = Point::new(3.0, 4.0);
println!("{:?}", p); // 打印调试信息
泛型
Rust 没有 C++ 那样的模板,而是使用泛型,类似于 Java。
fn max<T: PartialOrd>(a: T, b: T) -> T {
if a > b {
a
} else {
b
}
}

这里的 <T: PartialOrd> 表示函数对类型 T 是泛型的,并且 T 必须实现 PartialOrd 特征(该特征定义了比较操作 >)。这比 C++ 模板的编译错误信息更清晰。
在底层,编译器会为每种实际使用的类型生成一份特化后的函数代码。
使用特征实现多态
Rust 不直接支持传统的类继承多态,但通过特征可以实现类似的功能。
假设我们想表示会“说话”的动物:
trait Speak {
fn speak(&self);
}
struct Cat;
impl Speak for Cat {
fn speak(&self) {
println!("Meow");
}
}

struct Dog;
impl Speak for Dog {
fn speak(&self) {
println!("Woof");
}
}
Cat 和 Dog 是零大小类型,在运行时不占空间。
现在,我们可以写一个泛型函数来处理任何实现了 Speak 特征的类型:
fn call_speak_static<T: Speak>(s: &T) {
s.speak();
}
let cat = Cat;
let dog = Dog;
call_speak_static(&cat);
call_speak_static(&dog);
这会为 Cat 和 Dog 各生成一份 call_speak_static 的代码,是静态分派,速度快。
如果你需要运行时多态(动态分派),可以使用特征对象:
fn call_speak_dynamic(s: &dyn Speak) {
s.speak();
}
这里的 &dyn Speak 是一个“胖指针”,包含数据指针和虚函数表。这类似于 C++ 中带有虚函数的基类指针,性能稍慢,但只需要一份函数代码。
枚举与模式匹配
Rust 的枚举比 C/C++ 强大得多。枚举变体可以携带数据。
enum Owned {
Valid(String), // 有效时携带一个字符串
Invalid, // 无效
}
let x = Owned::Valid(String::from("success message"));
let y = Owned::Invalid;
可以使用 if let 进行条件匹配和解构:
if let Owned::Valid(s) = &x {
println!("x is valid: {}", s);
}
match 表达式提供更全面的模式匹配:
match &x {
Owned::Valid(s) => println!("Valid: {}", s),
Owned::Invalid => println!("Invalid"),
}
编译器会确保你处理了枚举的所有可能变体。
这种“可能有值,可能没有”的模式非常常用,因此标准库提供了 Option<T> 枚举:
enum Option<T> {
Some(T),
None,
}
类似地,用于错误处理的 Result<T, E> 枚举也很常用:
enum Result<T, E> {
Ok(T),
Err(E),
}
Rust 没有异常机制,函数通过返回 Result 类型来强迫调用者处理错误。? 操作符可以简化错误传播。
智能指针与线程

Box<T> 是 Rust 中的独占指针(类似于 std::unique_ptr)。

let six = Box::new(6);
println!("{}", six); // 解引用是自动的
Box 在离开作用域时会自动释放。你也可以显式调用 drop 来释放。
drop(six);
// println!("{}", six); // 编译错误!six 已被释放
所有权可以转移给函数或线程:
use std::thread;
let data = Box::new(42);
let handle = thread::spawn(move || {
println!("在线程中: {}", data);
});
handle.join().unwrap();
// data 在这里已不可用,所有权已移入线程
move 关键字强制闭包获取所用变量的所有权。编译器确保被移动的值在原始上下文中不再被使用,从而安全地在线程间传递数据。
生命周期
生命周期是 Rust 确保引用始终有效的机制。编译器会分析引用存活的时间。
一个典型的错误是返回局部变量的引用:
fn stack() -> &i32 {
let x = 42;
&x // 编译错误!不能返回局部变量的引用
}
编译器会报错,指出返回的引用指向函数内局部数据,该数据在函数结束后就无效了。这防止了悬垂指针。
在结构体包含引用时,需要使用生命周期注解来明确关系:


struct Vertex<'a> {
value: i32,
edges: Vec<&'a Vertex<'a>>, // edges 中的引用必须比 Vertex 实例存活更久(或一样久)
}
'a 是一个生命周期参数,它表明 Vertex 实例和其 edges 中的引用具有某种关联的生命周期。
内部可变性与线程安全
有时,我们需要通过不可变引用来修改数据,这称为内部可变性。Rust 提供了几种工具,适用于不同场景。
假设我们有一个图节点 Vertex,我们想修改它的值:
struct Vertex {
value: i32,
edges: Vec<Arc<Vertex>>, // 使用 Arc 共享所有权
}
如果我们写一个函数来递增节点及其所有邻居的值,会遇到借用检查的问题。
以下是四种实现内部可变性的主要类型:
Cell<T>:用于实现了Copy特征的类型。通过get获取拷贝,通过set设置新值。单线程使用。RefCell<T>:用于任何类型。在运行时执行借用规则,通过borrow_mut()获取可变引用。如果违反规则(如两个可变引用同时存在),程序会panic。单线程使用。Mutex<T>:互斥锁。线程安全。通过lock()方法获取锁,返回一个守卫,在守卫离开作用域时自动释放锁。RwLock<T>:读写锁。允许多个读取者或一个写入者。线程安全。
对于我们的多线程图节点例子,我们需要 Mutex 来保护数据,并使用 Arc(原子引用计数)来在线程间安全地共享所有权。
use std::sync::{Arc, Mutex};
use std::thread;
struct Vertex {
value: Mutex<String>, // 用 Mutex 保护 String
edges: Vec<Arc<Vertex>>,
}
fn increment_all(v: Arc<Vertex>) {
// 获取锁,修改值
let mut val = v.value.lock().unwrap();
val.push_str("+");
// 递归处理邻居
for edge in &v.edges {
increment_all(edge.clone());
}
} // val 离开作用域,锁自动释放

fn main() {
// 创建节点并用 Arc 包装
let a = Arc::new(Vertex {
value: Mutex::new(String::from("A")),
edges: vec![],
});
let b = Arc::new(Vertex {
value: Mutex::new(String::from("B")),
edges: vec![a.clone()],
});
let c = Arc::new(Vertex {
value: Mutex::new(String::from("C")),
edges: vec![a.clone()],
});
// 在线程中调用
let c_clone = c.clone();
let handle1 = thread::spawn(move || {
increment_all(c_clone);
});
let b_clone = b.clone();
let handle2 = thread::spawn(move || {
increment_all(b_clone);
});
handle1.join().unwrap();
handle2.join().unwrap();
// 打印结果
println!("A: {:?}", a.value.lock().unwrap());
}
虽然编写过程中可能需要与编译器“斗争”,但最终结果是,编译器帮助我们创建了一个内存安全且无数据竞争的程序。
总结
本节课中我们一起学习了 Rust 语言的基础。Rust 通过所有权、借用检查和生命周期系统,在编译期确保程序的内存安全和线程安全。它的核心特点包括:变量默认不可变、强大的模式匹配、基于特征的多态和泛型、丰富的错误处理机制(Option/Result),以及为并发编程提供的安全抽象(Mutex、Arc 等)。
从 C/C++ 转向 Rust 的初期可能会感到有些束缚,因为编译器非常严格。但这种严格性旨在消除一整类的运行时错误。如果你确信自己的代码是安全的,但编译器无法证明,可以使用 unsafe 关键字来绕过检查,但相应的代码就需要你自行确保其正确性。

对于编写高性能且安全的系统软件,尤其是并发程序,Rust 是一个非常值得考虑的选择。

浙公网安备 33010602011771号