CPU 的设计目标只有一个:让单条指令流执行得尽可能快。
不是让一万件事同时发生(那是 GPU 的目标),而是让你现在正在做的这一件事——这条 SQL 查询、这个 HTTP 请求处理、这段业务逻辑——在最短时间内完成。CPU 几十年来的所有复杂性:多级缓存、乱序执行、分支预测、超线程……全都是为了这个目标的不同权衡。
后端工程师不需要知道如何设计 CPU,但理解这个资源模型,会让你在做性能优化时不走弯路。
一、内存层级:为什么同样是「读数据」,快慢差 200 倍
CPU 每秒能执行几十亿条指令,但内存(DRAM)每次读写需要约 60-100 纳秒。对一个 3GHz 的 CPU 来说,60ns 意味着等待了 180 个时钟周期——这段时间本可以执行 180 条指令,全部浪费在等内存上。
为了弥补这个鸿沟,CPU 在寄存器和内存之间引入了多层缓存:
这几个数字值得记住:
- L1 Cache 命中:约 4 个时钟周期
- L2 Cache 命中:约 12 个时钟周期
- L3 Cache 命中:约 40 个时钟周期
- 主内存(DRAM):约 200 个时钟周期
L1 和 DRAM 之间差了 50 倍。这个差距解释了为什么「缓存友好的代码」能带来数量级的性能提升。
二、Cache Line:数据的最小搬运单位
CPU 从内存读数据时,不是按字节读的,而是按 Cache Line 读的——每次搬运一整块,通常是 64 字节。
这意味着:当你访问内存中某个位置的数据,它附近 64 字节的数据会一起被载入缓存。
这个设计利用了「空间局部性」假设:程序访问了某个内存地址,很可能接下来会访问它附近的地址。对数组遍历来说,这个假设完全成立——每次取 64 字节,可以顺序访问其中 8 个 `int64` 或 16 个 `int32`,都是缓存命中。
但链表完全不一样。链表节点分散在内存各处,每次沿 `next` 指针跳转,都可能跳到一个新的 Cache Line,触发一次缓存未命中,等待 200 个周期。这就是「链表比数组慢」的根本原因——不是链表多了指针跳转,而是指针跳转破坏了缓存局部性。
后端工程师的实际启示:
- 序列化扫描比随机访问快,在大量数据上差距可达 10 倍以上
- 数组比链表快,不是算法问题,是缓存问题
- 结构体字段的排列顺序会影响缓存利用率——经常一起访问的字段应该在内存里相邻
三、超线程:一个核假装成两个
现代 CPU 支持超线程(Hyper-Threading):操作系统看到的是 16 核 32 线程,但物理核只有 16 个。
超线程的工作原理:当一个线程在等待缓存未命中(200 个周期的空档)时,CPU 切换到另一个线程继续执行,把等待时间"填满"。两个线程共享同一个物理核的执行单元,但各自有独立的寄存器组和指令队列。
这有几个重要的实际含义:
超线程不是真正的并行。两个超线程争用同一个 L1/L2 Cache。如果两个线程都是 CPU 密集型且缓存使用率高,它们会互相污染对方的缓存,实际性能比单线程更差。
超线程对 I/O 密集型任务效果好。线程大量时间在等待(等锁、等网络、等磁盘),CPU 利用率本来就低,超线程能有效填满这些空档。
这就是为什么你的 Java 应用跑 32 线程和跑 64 线程性能差不多,甚至 64 线程更慢——线程数超过物理核数之后,超线程带来的边际收益递减,而线程调度开销在增加。
四、NUMA:多路服务器上的隐形性能陷阱
高性能服务器通常是多路 CPU——两个或四个物理 CPU 封装(Socket)安装在同一块主板上。每个 Socket 有自己的内存控制器,直连自己的内存条。
这个架构叫 NUMA(Non-Uniform Memory Access,非一致内存访问)。
访问本地内存(和当前 CPU 直连的内存):延迟约 60-100ns。访问远端内存(跨 Socket,通过 QPI/UPI 链路访问另一个 CPU 的内存):延迟约 150-300ns,慢 2-3 倍。
NUMA 对后端服务的影响:
进程绑核(CPU Affinity)。把某个进程绑定在特定的 CPU 核上,并把它使用的内存分配在同一个 NUMA 节点,避免跨节点访问。Linux 的 numactl 命令可以做到这一点。Redis 官方文档里明确建议在多路服务器上使用 numactl 进行绑核。
JVM 的 NUMA 感知。Java 的 G1/ZGC 垃圾回收器支持 NUMA-aware 内存分配(-XX:+UseNUMA),会优先在当前线程所在节点分配内存,减少跨节点访问。
不感知 NUMA 的代价。操作系统默认按轮询方式给进程分配内存和 CPU,一个进程的线程可能运行在 Socket 0 但访问 Socket 1 上的内存。在内存密集型服务上,这可能导致 30-50% 的性能损失,而且日志里没有任何报错,只是慢。
五、乱序执行和分支预测:CPU 在猜你接下来要做什么
现代 CPU 不是按程序写的顺序执行指令的。当一条指令在等待数据(缓存未命中),CPU 会把后面不依赖这条指令结果的其他指令提前执行——这叫乱序执行(Out-of-Order Execution)。
更激进的是分支预测(Branch Prediction):遇到 if 语句时,CPU 不等条件判断结果,直接猜一个分支继续执行。如果猜对了,没有任何代价;猜错了,需要回滚(Flush Pipeline),损失约 10-20 个时钟周期。
CPU 通过统计历史来猜分支:如果过去 100 次这个 if 都走了 true 分支,预测器会猜下次也走 true。这对规律性强的代码(排序好的数组、固定模式的循环)预测准确率可以达到 99%+。但对随机数据(随机访问的哈希表、不规则的业务逻辑)预测准确率低,性能下降明显。
这解释了一个常见的性能现象:对一个数组排序后再遍历判断,有时比直接遍历未排序数组快——因为排序使分支预测准确率大幅提升。
六、对后端工程师的实际意义
把上面几节综合起来,几个有实际价值的结论:
顺序访问比随机访问快得多。批量扫描数据库记录、顺序读取文件、遍历数组——这些操作的性能远比你想象的好,因为缓存局部性极佳。反过来,大量随机 I/O(随机读小文件、频繁的小范围数据库查询)会同时破坏磁盘和 CPU 缓存的局部性。
线程不是越多越好。CPU 密集型服务,线程数超过物理核数后性能不再提升甚至下降;I/O 密集型服务,超线程能帮你提高 CPU 利用率,但线程数也有上限(线程调度本身有开销)。一般 I/O 密集型服务,线程数设为物理核数的 2-4 倍是合理的起点。
多路服务器需要 NUMA 感知。如果你的服务部署在双路或四路服务器上,而且有明显的内存访问压力(缓存服务、数据库、消息队列),NUMA 绑定是一个值得检查的优化点。不需要改代码,配置层面可以解决。
缓存容量决定了热数据的上限。一个 16 核服务器的 L3 Cache 通常是 32-64MB。如果你的热数据能放进 L3,访问速度比放在内存快 5 倍。这影响了缓存预热策略——启动时要尽快把热数据加载进内存,让 L3 缓存自然填满。
七、总结
- CPU 的设计目标:最小化单任务延迟。所有复杂性都服务于这一点
- 缓存层级:L1(~4 周期)→ L2(~12 周期)→ L3(~40 周期)→ DRAM(~200 周期)。缓存未命中是性能的最大杀手
- Cache Line:最小搬运单位 64 字节。顺序访问缓存友好,随机访问缓存不友好
- 超线程:一个物理核模拟两个逻辑核,通过填补等待空档提升吞吐量。对 CPU 密集型任务收益有限
- NUMA:多路服务器上跨 Socket 访问内存慢 2-3 倍,通过绑核可以规避
- 分支预测:规律性访问预测准确率高,随机访问会导致频繁 Pipeline Flush
下一篇讲 GPU 的资源模型——它的设计目标和 CPU 完全相反:不追求让一件事做得快,而是让一万件事同时发生。理解了 CPU 的限制,才能真正理解 GPU 为什么要那样设计。