读前:这一章要回答什么
系统必须快的时候,怎样拿到性能,又不把前面十九章辛苦换来的简洁设计赔进去?
前面所有章节都围绕一个目标:让软件尽可能简单、好懂。很多 C/C++ 程序员读到这里会有一个疑问:这些主张是不是只适合"不在乎快慢"的代码?深模块多了一层调用,通用接口多了一层判断,系统还能快吗?
这一章正面回答这个疑问,而且答案有点出人意料:作者认为简洁和高性能并不冲突,简单的代码通常反而更快。复杂的代码慢,往往是因为它做了多余或重复的工作。全章按一条时间线展开:平时怎样选设计(20.1)→ 系统真的慢了先做什么(20.2)→ 找不到根本性修复时怎样改写一段热点代码(20.3),最后用作者自己的存储系统 RAMCloud 里的一个类做完整示范(20.4)。
它和第 21 章是一对:性能是"什么最重要"的一个具体例子。当性能真的重要时,设计就该围绕它来组织。
- 20.1 节那份"昂贵操作"清单:注意作者除了给绝对时间,还常常换算成"相当于多少条指令的时间"。同一节后半段有一条"什么时候值得为性能增加复杂度"的分级规则,读时把它整理成三种情况。
- 20.2 节的最后两句很容易被跳过:改动如果没有带来可测量的提升,就撤回,除非它让系统更简单。想想你见过多少"优化"在没测过的情况下留在了代码里。
- 20.3~20.4 节:先读懂"理想代码"是怎么构造出来的,再去数一数 Buffer 例子里原来的关键路径检查了几个条件,新代码又靠什么一次排除了三种特殊情况。还要留意关于
totalLength的那段说明:作者为什么故意在关键路径上多留了一点工作。
核心观点
走中间路线
逐条语句抠性能会拖慢开发、增加复杂度,很多"优化"也没用;完全不管性能,又会让低效散落各处,最后无从下手。
选"天然高效"的设计
天然高效(naturally efficient):知道哪些操作贵,就能在同样简洁的方案里挑便宜的那个:不需要顺序时用哈希表,结构体直接放进数组而不是放指针。
先测量,改完再测
直觉不可靠,老手也一样。测量要深入到少数几个具体位置,并留下基线;改完没有可测的提升就撤回。
优先找根本性修复
根本性修复(fundamental fix):加缓存、换算法、换架构,往往比改写代码有效得多,而且可以用前面各章的方法干净地实现。
最后手段:围绕关键路径
关键路径(critical path)指最常见情况下必须执行的代码。先想象这条路径上"最少要执行的代码",即理想代码(the ideal),再找一个最接近它、结构又干净的设计。
一次判断挡住所有特殊情况
理想的关键路径开头只有一个 if;特殊情况去路径之外处理,那里写得简单就好,不必追求快。
简单通常更快
定义掉的特殊情况不需要检查;深类每次调用做的事更多,浅层越多,层间穿越的开销越多。
The most important overall lesson from this chapter is that clean design and high performance are compatible.— 第 20.5 节
哪些操作贵:数量级要心里有数
"天然高效"的前提是知道什么贵、什么便宜。下表把书中 20.1 节给出的数量级整理出来(书中数据),只有一处书中没给数字,由本站补充并标明(本站补充)。
| 操作 | 书中给出的代价 | 来源 | 设计上的含义(本站整理) |
|---|---|---|---|
| 数据中心内网络往返 | 10–50 µs,相当于数万条指令的时间 | 书中数据 | 别在循环里逐个发请求;能批量就批量 |
| 广域网往返 | 10–100 ms | 书中数据 | 跨地域的交互要尽量少、尽量异步 |
| 磁盘 I/O | 通常 5–10 ms,相当于数百万条指令的时间 | 书中数据 | 关键路径上避免同步读写磁盘 |
| 闪存 I/O | 10–100 µs | 书中数据 | 比磁盘快两个数量级,但仍是网络往返级别 |
| 新型非易失内存 | 可能快到 1 µs,但仍约等于 2000 条指令的时间 | 书中数据 | "很快的存储"也远比内存访问慢 |
| 动态内存分配 | 书中只说分配、释放和垃圾回收都有明显开销,没有给数字 | 本站补充 | 本站在一台笔记本上测得逐个 make_unique 约 40 ns/个(见下文微基准,含释放和遍历) |
| 缓存未命中 | 从 DRAM 取数据到片上缓存,要几百条指令的时间 | 书中数据 | 数据要连续摆放;很多程序的性能由缓存未命中和计算一起决定 |
表里最有用的不是某个具体数字,而是它们之间差了多少个数量级。人对"微秒"和"毫秒"没有直觉,下面这个小工具把它们放到同一根对数轴上,还可以换成"如果一条指令花 1 秒"的人类时间来感受。
延迟刻度尺
换算方法:书中说 1 µs 约等于 2000 条指令的时间,本站据此取 1 条指令 ≈ 0.5 ns;"几百条指令"按 200–500 条画;动态内存分配的范围是本站粗估(书中没有数字)。这些只是建立直觉用的数量级,硬件一换数字就会变,所以作者才强调要自己写微基准去测。
这张表的大部分行,OSTEP 都用整章讲过它们为什么这么慢:磁盘驱动器里的寻道和旋转延迟,闪存 SSD 的读写特性,TLB 与缓存局部性,分布式系统里的网络往返。OSTEP 讲的是这些代价从哪来,这一章讲的是写代码时怎样提前绕开它们。
心智模型:从"选设计"到"改热点"
20.1~20.3 节其实描述了一个有先后顺序的决策过程。大多数时候你只会停在第一步;越往下走,越少见,代价也越高。
原创例子:一条"关闭的"日志
书中 20.4 节的例子是 RAMCloud 的 Buffer 类:在缓冲区末尾分配一小段空间是它最常见的操作,原来的路径穿过三层签名相同的浅方法,检查了 6 个条件;重写后只剩一个方法、一次判断,速度约快一倍,类的代码量反而少了 20%。建议读原书看完整过程。这里换一个 C++ 程序员几乎都写过的场景,用同样的思路走一遍。
场景:一个键值服务的 GET 路径上有一行 debug 日志,每个请求都会经过。线上 debug 级别是关闭的,所以最常见的情况是"这条日志什么都不用做"。这就是关键路径。
enum Level { kDebug, kInfo, kWarn, kError, kOff };
class Logger {
public:
void log(Level level, const std::string& module, const std::string& msg) {
std::string line = formatLine(level, module, msg); // 时间戳 + 级别 + 模块 + 正文
if (!shouldLog(level, module)) return;
for (auto& sink : sinks_) sink->write(line);
}
private:
bool shouldLog(Level level, const std::string& module) const {
if (!enabled_) return false;
if (sinks_.empty()) return false;
Level threshold = globalLevel_;
auto it = moduleLevels_.find(module); // 每次调用都按名字查一遍
if (it != moduleLevels_.end()) threshold = it->second;
return level >= threshold;
}
std::string formatLine(Level, const std::string& module, const std::string& msg) const;
bool enabled_ = true;
Level globalLevel_ = kInfo;
std::map<std::string, Level> moduleLevels_; // 按模块覆盖全局级别
std::vector<std::unique_ptr<Sink>> sinks_;
};
// 调用处:GET 路径,每个请求都经过
logger.log(kDebug, "kv.get", "lookup key=" + key);
每一行单独看都合理,合起来却很浪费:调用处先拼好消息字符串(第 27 行),进入 log 后又读时钟、拼整行(第 6 行),然后才判断要不要输出;判断本身还要过两个开关、做一次按字符串的 map 查找(第 15 行)。在最常见的情况下,这些工作全部白做。
// 一个模块一个通道。通道在启动时创建一次,之后一直复用。
class LogChannel {
public:
// 关键路径。线上大多数 debug/info 日志是关闭的,这时只做这一次比较。
bool enabled(Level level) const {
return level >= threshold_.load(std::memory_order_relaxed);
}
void write(Level level, std::string_view msg); // 前提:enabled(level) 为真
private:
friend class Logger;
std::string name_;
// 四个条件合成一个值:总开关关闭或没有输出目标时为 kOff;
// 否则是本模块的覆盖级别,没有覆盖就用全局级别。
std::atomic<Level> threshold_{kOff};
};
// 注意:expr 只在该级别打开时才求值,不要在里面写有副作用的表达式。
#define LOG(ch, level, expr) \
do { if ((ch).enabled(level)) (ch).write((level), (expr)); } while (0)
// 关键路径之外:配置变化时(很少发生)重算所有通道。写得直白即可。
void Logger::recomputeThresholds() {
for (auto& ch : channels_) {
Level t = globalLevel_;
if (auto it = moduleLevels_.find(ch->name_); it != moduleLevels_.end()) t = it->second;
if (!enabled_ || sinks_.empty()) t = kOff;
ch->threshold_.store(t, std::memory_order_relaxed);
}
}
// 调用处
static LogChannel& kvGetLog = logger.channel("kv.get");
LOG(kvGetLog, kDebug, "lookup key=" + key); // 关闭时连字符串都不拼
做法和书中的思路一致:先写出"理想代码"(关闭时只比较一次),再围绕它调整数据结构。四个条件(总开关、有无输出目标、全局级别、模块覆盖)被预先合成一个 threshold_,一次比较就能排除所有"不用输出"的情况;按名字查找挪到了启动时;格式化挪到了判断之后。特殊情况的处理全在 recomputeThresholds 里,它很少运行,所以写成最直白的样子。
| 关闭的 debug 日志,关键路径上的工作 | 改之前 | 改之后 |
|---|---|---|
| 条件判断 | 5(开关、输出目标、查找结果、级别,外加 map 内部的字符串比较) | 1 |
| 非内联的函数调用 | log、formatLine、shouldLog、map::find、读时钟…… | 0 |
| 堆分配(拼字符串) | 通常 2 次以上 | 0 |
| 本站实测(见下文微基准) | 约 90 ns | 不到 1 ns |
代价也要说清楚。第一,新增了一个必须维护的不变量:只要四个输入中的任何一个变了,就必须调用 recomputeThresholds,漏掉一处就是 bug。所以这四个输入的修改应该全部收进 Logger 内部,不能让外面直接改。第二,宏让调用处的求值规则变得不明显(expr 可能根本不被求值),这条必须写进注释(第 18 章说的"不明显的代码")。第三,调用者要先拿到通道对象。对一条每秒执行几十万次的路径,这些代价值得;对一个每分钟跑一次的管理命令,就不值得。
动手测一测:一个最小的微基准
作者说,了解什么贵,最好的办法是写微基准(micro-benchmark):单独测一个操作的代价。下面这个程序测了三件事:有序 map 和哈希表的查找、结构体数组和指针数组、以及上面日志例子的改前改后。复制下来就能编译运行。
展开 bench.cpp(约 100 行)
// bench.cpp —— 几个最小的微基准。g++ -O2 -std=c++17 bench.cpp -o bench && ./bench
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <map>
#include <memory>
#include <random>
#include <string>
#include <unordered_map>
#include <vector>
// 把 f 跑 n 次,返回平均每次的纳秒数
template <class F> double nsPerOp(long n, F f) {
auto t0 = std::chrono::steady_clock::now();
for (long i = 0; i < n; i++) f(i);
std::chrono::duration<double, std::nano> d = std::chrono::steady_clock::now() - t0;
return d.count() / n;
}
volatile long sink; // 防止编译器把结果整个优化掉
// 3 号测试用:一条"关闭的"debug 日志,改前与改后(只保留关键路径上的工作)
enum Level { kDebug, kInfo, kWarn, kError, kOff };
struct OldLogger {
bool enabled = true;
Level global = kInfo;
std::map<std::string, Level> moduleLevels{{"kv.put", kDebug}, {"net", kWarn}, {"raft", kInfo}};
std::vector<int> sinks{1};
bool shouldLog(Level lv, const std::string& module) const {
if (!enabled || sinks.empty()) return false;
Level th = global;
auto it = moduleLevels.find(module);
if (it != moduleLevels.end()) th = it->second;
return lv >= th;
}
void log(Level lv, const std::string& module, const std::string& msg) {
auto ts = std::chrono::system_clock::now().time_since_epoch().count();
std::string line = std::to_string(ts) + " " + module + " " + msg; // 先格式化
if (!shouldLog(lv, module)) return; // 再判断
sink = (long)line.size();
}
};
struct LogChannel {
std::atomic<Level> threshold{kInfo};
bool enabled(Level lv) const { return lv >= threshold.load(std::memory_order_relaxed); }
};
// 1 号测试:n 个随机键,有序 map 与哈希表各查 1,000,000 次
void lookupBench(long n) {
std::mt19937_64 rng(42);
std::vector<long> keys(n);
for (auto& k : keys) k = (long)(rng() >> 1);
std::map<long, long> tree;
std::unordered_map<long, long> hash;
for (long k : keys) { tree[k] = k; hash[k] = k; }
std::shuffle(keys.begin(), keys.end(), rng);
const long Q = 1'000'000;
double tMap = nsPerOp(Q, [&](long i) { sink = tree.find(keys[i % n])->second; });
double tHash = nsPerOp(Q, [&](long i) { sink = hash.find(keys[i % n])->second; });
std::printf("%8ld 个键:map %6.1f ns,unordered_map %5.1f ns\n", n, tMap, tHash);
}
int main() {
// 1. 有序 map 与哈希表:随机查找。规模不同,差距也不同
lookupBench(1'000);
lookupBench(1'000'000);
// 2. 结构体数组与指针数组:分配 + 遍历 N 个元素
const long N = 1'000'000;
struct Order { long id, qty, price; };
double tPtr = nsPerOp(10, [&](long) {
std::vector<std::unique_ptr<Order>> v;
v.reserve(N);
for (long i = 0; i < N; i++) v.push_back(std::make_unique<Order>(Order{i, 1, 2}));
long s = 0; for (auto& o : v) s += o->qty * o->price;
sink = s;
}) / N;
double tVal = nsPerOp(10, [&](long) {
std::vector<Order> v;
v.reserve(N);
for (long i = 0; i < N; i++) v.push_back(Order{i, 1, 2});
long s = 0; for (auto& o : v) s += o.qty * o.price;
sink = s;
}) / N;
std::printf("vector<unique_ptr<Order>>:%5.1f ns / 元素\n", tPtr);
std::printf("vector<Order>: %5.1f ns / 元素\n", tVal);
// 3. 一条关闭的 debug 日志:改前 vs 改后
OldLogger oldLog;
LogChannel ch;
double tOld = nsPerOp(N, [&](long i) { oldLog.log(kDebug, "kv.get", "lookup key=" + std::to_string(i)); });
double tNew = nsPerOp(N, [&](long i) {
if (ch.enabled(kDebug)) sink = (long)("lookup key=" + std::to_string(i)).size();
});
std::printf("关闭的日志,改前:%5.1f ns\n", tOld);
std::printf("关闭的日志,改后:%5.1f ns\n", tNew);
}
g++ -O2 -std=c++17 bench.cpp -o bench && ./bench
1000 个键:map 7.8 ns,unordered_map 3.9 ns
1000000 个键:map 821.5 ns,unordered_map 45.9 ns
vector<unique_ptr<Order>>: 41.5 ns / 元素
vector<Order>: 1.3 ns / 元素
关闭的日志,改前: 93.8 ns
关闭的日志,改后: 0.4 ns
示例输出,你的机器上数字会不同(本站在 AMD Ryzen 7 8745H、WSL2、g++ 14.2 上运行;多跑几次,个别数字的波动可达 20%)。
几点值得停下来想一想:
- 差距取决于规模。1000 个键时,
map只比哈希表慢一倍;100 万个键时差了将近 20 倍。书中说哈希表"很容易快 5~10 倍",这是经验值。大数据量下差距更大,主要因为树的每一层都可能是一次缓存未命中,正好对应上表最后一行。 - 逐个分配很贵。同样 100 万个三字段结构体,逐个
make_unique比直接放进vector慢约 30 倍,而两种写法几乎一样简单。这就是"天然高效":不用多付复杂度。 - 微基准有陷阱。编译器可能把你要测的代码整个优化掉(程序里用
volatile防了一下),循环里的数据可能一直在缓存里,结果比真实场景好看。所以微基准适合建立"数量级"直觉,真实系统的瓶颈仍然要在真实负载下测量。
边界与反方:什么时候别生搬硬套
"过早优化是万恶之源"常被用来为"完全不管性能"辩护。作者两个极端都不赞成:逐条语句抠性能会拖慢开发、徒增复杂度,而且很多"优化"根本没用;完全不管,低效会散落在各处,系统很容易慢 5~10 倍,到时候找不到任何一个改了就见效的地方。本站补充:Knuth 那句话的原文本来也有后半句:不要错过那关键的 3%。两人的立场其实很接近。
作者给了一个分级规则:如果更快的方案只多一点复杂度,而且藏在实现里、不影响接口,可能值得(但要记住复杂性是一点点累积的);如果它会带来大量实现复杂度,或者让接口变复杂,就先用简单方案,真出了性能问题再说;如果有清楚的证据表明这里的性能至关重要,那就一开始就做快的方案。RAMCloud 一开始就决定绕过内核直接和网卡通信,依据是之前的测量已经表明走内核太慢。
作者明确说,围绕关键路径重写现有代码应当是最后手段,不该经常发生。先找根本性的修复(加缓存、换算法、换架构)。另外,"理想代码"只是目标,不是要原样照抄:为了保持干净的抽象,在理想代码上加一点东西是可以的,例如多一次对通用哈希表类的方法调用。
Buffer 的例子里,作者故意在分配路径上多维护一个总长度,因为"取总长度"也是常见操作,不能为了一条路径饿死另一条。上面日志例子里的 recomputeThresholds 也是同一类取舍:把成本挪到很少执行的路径上。本站补充:作者说的是简单的代码"往往"更快,不是定律。SIMD、无锁结构这类优化天生复杂;如果测量证明非用不可,就把它们关进一个深模块,让复杂性只出现在一个地方。
以下不在书里,来自书中给出的读者讨论组。书里有一个"逐个取出查询结果"的接口例子(13.5 节)。有读者担心:一次只取一个对象,在分布式存储上岂不是每取一个就要一次远程调用?应当改成批量接口。Ousterhout 回答说这是个误会(2021-06):这个接口在 RAMCloud 里就是这样实现的,而实现在底下做了两件事:只要可能,一次远程调用取回多个对象;同时向多台索引服务器和存储服务器并发发请求。机制不简单,但对使用者完全不可见。Ousterhout 拿带缓冲的 I/O 作类比:程序逐字符读输入时,很少真的每次都进内核。
这是本章"简单的设计通常也快"的一个补充:批量和并发是实现的事,不必长在接口上。先问"能不能在简单接口的下面把它做快",做不到再考虑把批量暴露给使用者。这也是第 8 章"把复杂性往下拉"在性能上的用法。
练一练
下面三道题来自训练场。先自己判断再看解析,答题记录会进入间隔复习。
再加一道,取材于读者讨论组(对应上面"边界与反方"里的"讨论组"一栏):
动手:项目任务
读完本章和第 18 章后,去做 minikv 的任务 m08。minikv 的一次 GET 命中要穿过 Server::handleGet → Database::get → Store::get → Index::get 四层。先用 make bench 测出这条路径的时间花在哪儿,再围绕它重新设计:去掉多余的工作(同一个键查两次、值被拷贝两次、昂贵的格式化),同时把带 bool 参数的调用、藏着副作用的方法改成一目了然的代码。提示:测量结果可能和你的直觉不一样,花时间最多的未必是查找,那几层透传在 -O2 下也许根本测不出开销。这正是 20.2 节说的"先测量,别靠猜"。改完再测一次,和基线比。
任务 m08:先用 make bench 找出 GET 关键路径上的时间花在哪儿,再去掉多余的工作,并把不明显的代码改清楚;改完再测。详见 任务页。
cd project/minikv && make bench自测题
availableAppendBytes 或上面日志例子的 threshold_ 那样,引入一个把多个条件合在一起的变量。小结与读后一句话
- 干净的设计和高性能并不冲突;复杂的代码之所以慢,常常是因为做了多余或重复的工作。
- 平时:知道哪些操作贵(网络、存储、分配、缓存未命中),在同样简洁的方案中选天然高效的那个。
- 慢了:先深入测量,找少数具体热点,留下基线;改完再测,没有提升就撤回。
- 优先找根本性修复;实在没有,才围绕关键路径重新设计:想象理想代码,一次判断分流所有特殊情况。
- 新增变量、预先计算是常用手段,但要清楚它们带来的新不变量。
第 21 章会把这一章当成例子推广:性能只是"重要的东西"之一。一旦确定什么最重要,设计就该围绕它来组织,并把不重要的东西藏起来。
读后一句话:用你自己的话写下这一章最重要的一个观点,最好结合你见过的某次"优化"。