161条计算机底层原理:缓存行、虚拟内存与系统调用实验拆解

发布时间:2026/9/17 8:20:40
161条计算机底层原理:缓存行、虚拟内存与系统调用实验拆解 做了十多年后端和系统方向我踩过最多的坑不是业务逻辑写错而是对计算机底层原理一知半解——明明代码看着没问题CPU 却跑出反直觉的耗时明明内存够用进程却在半夜被系统杀掉。所以后来我干脆花了大半年时间把散落在各种手册、源码、实验里的东西整理成了一套《计算机底层通俗教程》一共 161 条每条只讲清楚一个点。这篇文章不是给你列目录而是把我做这套内容时的取舍、验证方法、以及那些看起来懂了其实没懂的地方全部摊开讲一遍。计算机底层原理之所以让人觉得难不是概念本身有多玄而是中间那几层抽象没打通你写的一行代码到电流在晶体管里翻转中间隔了编译、指令、缓存、页表、调度好几道关卡任何一道卡住都会变成玄学问题。这篇内容适合三类人刚入行一两年、想搞清楚性能问题根因的开发者面试前需要把知识点串成线的同学以及做技术分享、需要一套可靠讲解框架的同行。读完你至少能拿到一套可复现的实验方法而不是一堆背完就忘的名词。1. 为什么把这套内容拆成161条1.1 碎片化学习的真实痛点我最初的想法不是做 161 条而是写一本从晶体管到分布式的连续长文。写到第三章就写不下去了原因很现实底层知识是网状的不是线性的。你讲缓存绕不开内存层级和局部性讲局部性又绕不开编译器优化和数据结构布局讲数据结构布局又得回到字节对齐和 ABI 调用约定。只要按章节走就一定会出现这一章需要用到第五章内容的情况读者在第三个来回之后就迷路了。后来我改成条目制每条独立成篇只说一件事前后用链接互相引用。好处是阅读路径可以自由组合想搞清楚为什么我的循环改成另一种写法快了三倍直接跳到缓存行和分支预测那两条就行不用先啃完整章电路基础。还有一个隐性好处是我自己受益的——条目制逼着你把每个概念讲到可以验证的程度。写长文时你可以用众所周知糊过去写独立条目时读者看完就会问那怎么验证所以每一条我都必须配一个能跑出结果的最小实验。这里有个取舍要说明条目制牺牲了叙事的连贯性换来了检索能力和验证密度。如果你是想从头系统学一遍建议按分层顺序读如果是为了解决手头问题按关键词跳读就好这两种用法我都试过跳读的效果反而更好因为带着问题读的记忆留存率高得多。1.2 161 这个数字是怎么定下来的161 不是我一开始定的目标而是收敛出来的结果。我用了三个约束来筛选条目第一每个概念必须能回答一个具体的为什么第二必须能在一个屏幕内讲完核心结论第三必须能设计出一个五分钟内跑完、结果可观测的实验。三条都满足就收进来缺一条就拆成两条或者干脆删掉。举个被删掉的例子操作系统的发展历史——听着像底层内容但它不满足第一条回答不了任何具体的为什么答案只能是因为当时硬件受限属于背景知识放在附录足够了。反过来被拆开的例子是虚拟内存原本我当作一条写写到一半发现里面塞了地址空间、页表、缺页中断、TLB、写时复制五个可以独立成条的点每个都有自己的实验方法于是拆成了五条。最终分布是这样的入门级的数字与电路部分占比最小因为绝大多数人不需要手推门电路操作系统和并发部分占比最大因为线上问题百分之七八十都落在这一块。这个比例是我根据自己排查过的故障类型反推的不是按教科书的篇幅分配的。2. 161条的整体骨架与分层逻辑2.1 六层地图与条目分布整套内容按从物理到应用分成六层每层内部的条目大致按依赖顺序排列层与层之间用编号区间隔开这样读者看到一个编号大概就知道它在讲什么层级的东西。具体分布如下层级覆盖主题范围条目数编号区间第一层 数字与电路二进制、补码、逻辑门、触发器、时钟、锁存20#001-#020第二层 指令与 CPU指令集、寄存器、流水线、分支预测、乱序执行、SIMD30#021-#050第三层 内存与存储缓存层级、缓存行、TLB、DRAM、磁盘、SSD、DMA、页缓存25#051-#075第四层 程序与编译栈帧、调用约定、字节对齐、编译链接、动态库、ABI25#076-#100第五层 操作系统进程线程、调度、虚拟内存、中断、系统调用、文件系统、IO35#101-#135第六层 并发与网络锁、原子操作、内存屏障、协程、TCP/IP、零拷贝、IO 多路复用26#136-#161把 161 条摊成这么一张表最大的价值是让你知道自己卡在哪一层。我见过不少同学能熟练背出 TCP 三次握手的状态迁移却说不清一次 write 调用是怎么从用户态走到网卡驱动的这就是典型的上层熟练、下层空白。反过来也有人死磕晶体管却不知道线上服务在压测时抖动是因为页表切换这属于投资方向错了。这张表可以用来自查随机挑十条你能不能在两分钟内讲出它解决什么问题、用什么工具验证答不上来的那层就是你接下来该补的地方。2.2 每条内容的统一结构为了保证 161 条读起来像一套东西而不是 161 篇不相干的随笔我给每条定了一个固定骨架一共四块一句话结论、为什么是这样、生活化类比、最小验证实验。一句话结论放在最前面用一句不超过四十字的话把答案给出来。比如缓存行那条结论是CPU 读内存不是一个字节一个字节读的而是一次搬一整块这块通常 64 字节。读者哪怕只读这一句也比读完三段废话强。为什么是这样是正文主体解释设计动机。这一步特别重要因为底层的绝大部分设计都是在某个约束下做的妥协。比如为什么缓存行是 64 字节而不是 16 字节或者 1024 字节答案藏在空间局部性的命中率和一次搬运的延迟成本之间的平衡里你把这层讲清楚读者以后遇到类似设计就能自己推。生活化类比是我花时间最多的地方。类比不能随便找必须能延伸。还是缓存行我用的是从图书馆借书管理员不会只给你某一页而是把整本书推过来——这个类比的好处是它能自然延伸出伪共享两个人分别改同一本书的不同页书在两人之间来回传谁都写不安生。类比的检验标准很简单能不能用同一个类比把相邻的两三条也讲通。能的留下不能的换掉。最小验证实验放在最后通常五到二十行代码或者一条命令。我刻意把实验控制在五分钟内出结果因为太长大家就不做了不做就等于没验证等于又回到背概念的老路。2.3 最容易写翻车的几类条目做这套内容的过程中我盘点过哪几类条目最容易写错、写虚、写偏结论有这么几类。第一类是看似简单其实有多层前提的条目典型的是浮点数。很多人知道 0.1 0.2 不等于 0.3但讲不明白为什么。如果你只说二进制表示不了 0.1这是不准确的——二进制同样表示不了 1/3但 1/3 在十进制里也有限位表达。真正的答案是IEEE 754 的 double 用 53 位有效数字表示一个二进制小数0.1 的二进制展开是无限循环的被截断后产生的误差在加法中累积最后落在 0.30000000000000004 上。这层不讲清楚读者就会误以为浮点数不精确是某种随机的玄学。第二类是名词很唬人但内核很简单的条目比如内存屏障。这个词听着高深本质上就是告诉编译器和 CPU这条指令前后的内存操作不许换顺序。难的是理解为什么要禁止换序——因为乱序执行和写缓冲区会为了性能重排指令单线程下看不出来多线程下就出问题。抓住为什么要重排这个起点整条就顺了。第三类是依赖具体硬件、不同平台结果不一样的条目比如缓存层级数量、TLB 大小、分支预测器实现。这类条目的处理原则是只讲量级和趋势不给绝对值所有实测数字必须标注测试环境和平台。我在每条实测数据下面都会写清楚机器型号、核心数、内核版本因为这行业里最没用的东西就是一个来路不明的性能数字。第四类是和上层框架高度耦合的条目比如垃圾回收。这类内容我必须先撇清具体语言实现只讲三色标记、写屏障这些通用机制再单独说明不同实现怎么落地。否则条目就变成了某个框架的文档摘抄失去通用性。3. 代表条目实操拆解3.1 补码与二进制为什么 -1 是全 1这条编号 #012是我认为整套内容里性价比最高的一条因为它能一次性解释掉很多看起来诡异的现象为什么 32 位有符号整数范围是负的多一个、为什么位运算加法和普通加法是一套硬件、为什么溢出后的结果和你算的不一样。先给结论在 32 位补码里-1 的表示是 0xFFFFFFFF也就是三十二个 1。推导过程是这样的我们要求一个数加上 1 等于 0那么在模 2³² 的意义下0 减 1 就等于 2³² − 1也就是全 1。再换个角度看位权最高位第 31 位的权重不是 2³¹而是 −2³¹其余位的权重是正常的正数。那么 0xFFFFFFFF 展开就是 −2³¹ 加上2³⁰ 2²⁹ … 2⁰而后半部分等于 2³¹ − 1两项相加正好是 −1。这个位权视角是理解补码的钥匙。你可以自己动手验证把一个有符号整数的最高位权重改成负的其他位不变然后按普通二进制求和得到的就是它的补码解释值。反过来从十进制转补码也可以这么算不用去背取反加一的口诀。当然取反加一在实践中更快但它的正确性依据就是上面这个位权定义口诀只是结论。我自己写这条时配了一个小实验用几行代码把边界情况都打出来看#include stdio.h #include stdint.h int main(void) { int32_t a -1; int32_t min INT32_MIN; printf((-1) hex 0x%08X\n, (uint32_t)a); printf(INT_MIN hex 0x%08X, dec %d\n, (uint32_t)min, min); printf(INT_MIN - 1 %d (overflow wraps)\n, min - 1); return 0; }跑出来的结果里最值得注意的是INT_MIN - 1它会回绕成 INT32_MAX。请注意这是典型的未定义行为在真实硬件上的表现编译器有权假设它不会发生所以实际项目里不要依赖这个结果做判断实验只是用来观察硬件行为。注意有符号整数溢出在 C 和 C 里是未定义行为编译器可能把它优化成任何东西。上面实验的意义是理解硬件怎么算不是鼓励你在生产代码里依赖回绕。这条还有个延伸点值得记无符号整数的减法在底层和有符号是同一套加法电路只是解释方式不同。所以判断是否溢出这件事如果用无符号数写可以直接看结果是否小于被减数这个技巧在很多底层代码里能看到。3.2 缓存行与伪共享一次实测对比编号 #058 这条是我自己第一次真正感受到底层知识能直接省钱的地方。结论先给CPU 从内存取数据是以固定大小的块为单位的这个块叫缓存行x86 平台上通常是 64 字节。两个线程如果分别修改同一块 64 字节里的不同变量硬件为了保证一致性会让这块数据在两个核之间来回搬性能可能掉一个数量级。先确认平台参数这一步千万别跳过不同架构差别不小getconf LEVEL1_DCACHE_LINESIZE cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size两条命令一般都会给出 64。为什么是 64 而不是更大往大了走一次搬运的数据多空间局部性利用得更充分但搬运延迟变长、浪费的带宽也多往小了走取一次用不上等于白跑一趟内存。64 字节大致对应一次内存突发传输能高效搬走的量是延迟和命中率之间算出来的平衡点。接下来是实验。核心是把一个长循环里的写操作放在两种内存布局下对比一种两个变量紧挨着另一种让它们各自占满一个缓存行。#include pthread.h #include stdio.h #include stdint.h #include time.h #define ITER 100000000L struct packed_vars { volatile long a; volatile long b; }; struct padded_vars { volatile long a; char pad_a[56]; volatile long b; char pad_b[56]; }; static struct packed_vars p; static struct padded_vars q; static double now_sec(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, ts); return ts.tv_sec ts.tv_nsec / 1e9; } static void *bump_a(void *arg) { volatile long *p (volatile long *)arg; for (long i 0; i ITER; i) *p *p 1; return NULL; } static void *bump_b(void *arg) { volatile long *p (volatile long *)arg; for (long i 0; i ITER; i) *p *p 1; return NULL; } static double run(volatile long *x, volatile long *y) { pthread_t t1, t2; double t0 now_sec(); pthread_create(t1, NULL, bump_a, (void *)x); pthread_create(t2, NULL, bump_b, (void *)y); pthread_join(t1, NULL); pthread_join(t2, NULL); return now_sec() - t0; } int main(void) { printf(packed : %.3f s\n, run(p.a, p.b)); printf(padded : %.3f s\n, run(q.a, q.b)); return 0; }编译时记得开优化gcc -O2 -pthread bench.c -o bench。我这台八核机器上跑出来的量级是packed 大约 9 秒出头padded 大约 0.6 秒差了十五倍左右。这里必须强调具体数字随 CPU 型号、核心绑核情况、是否开了超线程变化很大你要看的是倍数关系不是绝对值。如果你的两个线程被调度到同一个物理核上差距会小很多所以实验前最好用taskset把两个线程绑到不同物理核结果才明显。taskset -c 0,2 ./bench perf stat -e cache-misses,cache-references,LLC-load-misses,LLC-loads taskset -c 0,2 ./benchperf那行会告诉你差异出在哪packed 版本的缓存失效次数会高出一个量级因为每次写都要把对方的缓存行抢过来。这也就解释了为什么很多高并发框架里计数器数组要做填充对齐——不是为了好看是真的能省时间。提示伪共享的判断标准很简单——多个线程频繁写、且这些变量在内存上距离小于一个缓存行。修法有三种填充对齐、每个线程写自己的局部变量最后再合并、或者干脆把写操作改成批量化。关于填充还有个细节值得说单纯在两个变量之间加 64 字节填充不一定有效因为变量在结构体里的起始偏移可能不是 64 的倍数填充之后两个变量还是可能落在同一行里。稳妥的做法是用__attribute__((aligned(64)))或者alignas(64)直接对齐变量本身把对齐这件事交给编译器。我早期就吃过这个亏加了填充但没对齐测了半天毫无变化白折腾一晚上。3.3 虚拟内存与页表把地址翻译讲成图书馆编号 #071 到 #075 是连着的五条讲虚拟内存这一整块。为什么会拆这么细因为它是我见过最容易以为自己懂了的领域大家都知道每个进程有独立的地址空间但很少有人能说清一次内存访问到底经过了几道翻译、每道翻译的成本是多少、什么时候会突然慢下来。我的讲法是从一个类比切入把虚拟地址想象成图书馆的索书号物理内存想象成书架上的实际位置。索书号是稳定的书可以挪位置读者不用关心图书馆的索引卡片就是页表索引卡片本身也可能放不下所以要分级先查大类卡再查小类卡最后才定位到具体书架。映射到硬件上x86-64 的四级页表就是四级索引虚拟地址的低 12 位是页内偏移4KiB 页面对应 12 位剩下 36 位被切成四段每段 9 位依次索引四级表。第四级表的基地址存在一个专门的寄存器里进程切换时这个寄存器要换掉这就是为什么进程切换比线程切换贵——线程共享地址空间不用换页表。那么每次内存访问都要走四级查表吗显然不能那样慢得没法用。所以有 TLB也就是地址翻译缓存专门存最近用过的虚拟页到物理页的映射。TLB 命中只要几个周期未命中就得走完整查表代价可能是几十到上百个周期如果再触发缺页中断就要进内核、可能要读磁盘那就是微秒甚至毫秒级了。性能问题排查里很大一部分抖动都出在这条路径上。这里必须补充一个反直觉的点TLB 是按页缓存的不是按字节缓存。也就是说一个进程如果频繁访问大量分散在不同页里的内存TLB 命中率会急剧下降哪怕总访问量不大性能也会很差。这解释了不少现象——大数组顺序遍历很快因为 TLB 项少、命中率高而链表、树这类节点分散的结构遍历同样多的元素会慢很多除了缓存不友好TLB 也在拖后腿。验证方法也不复杂几个命令就能看到现状getconf PAGESIZE grep -i huge /proc/meminfo cat /proc/self/smaps | head -30第一条给出常规页大小通常是 4096 字节。第二条能看到大页的使用情况大页的意义就在于一个 TLB 项能覆盖 2MiB 而不是 4KiB直接把 TLB 容量放大五百倍对内存密集型程序提升明显。第三条能让你看到自己进程各个内存段的分布包括哪些是匿名映射、哪些是文件映射。注意大页不是开了就一定快。它有两个代价一是内存碎片必须连续物理内存二是分配和回收的成本更高。对于小内存进程开大页基本没收益还可能因为内存浪费影响其他服务。还有一个绕不开的概念是写时复制。创建子进程时操作系统不会立刻把父进程的所有内存复制一份而是让两边共享同一批物理页标记成只读。谁先写谁触发一个保护性异常内核再为它单独复制一页。这个机制让创建进程的成本大幅下降也让进程池这种设计成为可能。但它的副作用也很实在如果父进程持有大量内存子进程创建后如果两边都频繁写内存占用会迅速翻倍膨胀。我在实际项目里见过因为这个导致容器被限制策略干掉的案例排查时看到内存曲线突然翻倍第一反应就是去看 fork 之后的写行为。3.4 系统调用与上下文切换用 strace 量出来编号 #083 这条讲的是一次系统调用到底有多贵我认为它是理解用户态和内核态边界的最佳切入点。结论是一次普通系统调用的开销在几百纳秒量级随平台和调用类型波动很大看起来不贵但如果你在循环里调用它瞬间就能把程序拖垮。为什么会有这个开销因为用户态程序运行在受限的权限级别不能直接操作硬件和内核数据结构。要走系统调用程序先把参数放到约定好的寄存器里然后执行一条特殊指令触发陷入CPU 切换到更高权限级别跳转到内核预设的入口内核保存现场、校验参数、执行操作、再恢复现场、返回用户态。这一进一出CPU 流水线被打断、缓存和 TLB 受到扰动成本就出来了。自己动手量一下比看任何说法都直观。准备一个极简程序一次批量写和一次逐字节写对比#include unistd.h #include string.h int main(void) { char buf[1024]; memset(buf, x, sizeof(buf)); for (int i 0; i 100000; i) { write(1, buf, sizeof(buf)); } return 0; }用strace -c统计系统调用次数和时间分布用strace -T -tt -e tracewrite看每次调用的耗时strace -c ./prog /dev/null strace -T -tt -e tracewrite ./prog /dev/null 2 trace.log head -5 trace.log输出里每次 write 后面的尖括号数字就是本次调用耗时。你会发现即便是最简单的写调用也会稳定地落在微秒量级包含 strace 自身的插桩开销所以看相对值更有意义。这就是为什么日志库要做批量缓冲、为什么网络框架要做写合并——不是为了代码优雅是每次调用都有实打实的代价。这里有个特别适合拿来讲设计动机的延伸既然系统调用这么贵能不能让某些高频操作不进内核能。像读取当前时间这种操作硬件提供了一个稳定的时间戳计数器和一套换算参数内核把参数映射到用户态可见的内存里程序自己读一下就能算出时间不用陷入内核。这就是为什么时间相关的调用经常被列入廉价系统调用甚至在一些统计里根本不出现。理解了这个妥协思路你再看其他特权数据映射到用户态的设计就能一眼看穿意图。上下文切换这条我建议和系统调用放在一起看。切换分两种线程切换要保存寄存器、切换栈但不换页表相对便宜进程切换还要换页表寄存器导致 TLB 大量失效后续一段时间的内存访问都会变慢。这就是为什么高并发服务普遍用线程或协程模型而不是一请求一进程。vmstat 1 pidstat -w -p pid 1vmstat输出里的cs列是每秒上下文切换次数pidstat -w能看到具体进程的自愿切换和非自愿切换。如果一个服务 CPU 不高但延迟很高第一件事就是看切换次数——很可能锁竞争或者阻塞 IO 导致的切换在偷偷吃掉时间。3.5 TCP三次握手两次为什么不行编号 #131 这条讲网络我把为什么必须是三次当成整条的核心问题。因为只要这个问题讲通了序列号、状态机、连接建立成本全都能顺着推出来。先明确双方要达成的共识有三个第一双方都确认对方能发也能收第二双方各自选定一个初始序列号并且确认对方收到了自己的序列号第三双方都愿意建立这条连接。两次握手能达成什么客户端发请求、服务端回确认服务端能确认客户端能发能收客户端能确认服务端能发能收看起来齐了。问题出在两个地方。一是服务端无法确认自己的序列号被对方收到。如果服务端的确认包在路上丢了客户端会认为连接没建立、不发送数据而服务端已经认为连接建立、开始分配资源等待这就产生了一个半开连接白白占着资源。这在高并发场景下是致命的网络稍微抖一下就能积累一批僵死的连接。二是历史重复报文的问题。网络中可能存在上一次连接遗留的延迟报文如果只有两次握手服务端收到这个旧报文后会直接建立连接而客户端根本不认这条连接资源又白费了。三次握手里客户端最后再发一次确认服务端的序列号得到确认旧报文也就无法造成误判。换成生活类比更直白打电话确认会议时间。你明天有空吗有空你几点方便我三点方便。三句才能确认彼此都听清了具体时间。两句的话第二句里的时间对方到底听没听进去你并不知道。连接建立的成本也值得量化这在我做性能优化时很有用。一次新建连接至少要一个往返时间跨地域调用里往返时间可能是几十毫秒加上服务端的 accept 处理、内存分配、TLS 握手开销单次成本远超很多人的直觉。所以长连接复用、连接池这些做法不是优化技巧而是架构上必须做的事。ss -s ss -ti state established第一条给出当前连接的总体统计包括各种状态的连接数第二条能看到每条连接的详细指标包括往返时间、拥塞窗口、重传次数。压测时我习惯盯这两个输出如果重传数在涨说明丢包或者缓冲区设置有问题这时候去看应用层日志基本是浪费时间。4. 工具链与验证方法4.1 观测工具选型做底层内容最大的坑是凭印象讲。我给自己定了一条硬规矩每条涉及性能或行为的结论必须有工具输出的支撑。工具选型上我遵循能便宜就不贵、能用户态就不进内核的原则因为越重的工具越容易改变被测对象的行为。第一梯队是无侵入的统计类工具时间测量、/proc下的各种文件、系统自带的状态查看命令。这类工具几乎没有开销适合长期观察。比如看进程的内存分布cat /proc/pid/status里的几个关键字段就够了看系统整体压力vmstat 1连跑几分钟趋势一目了然。第二梯队是采样类工具主要是perf。采样意味着它按固定频率记录调用栈开销可控能给出热点函数、缓存失效、分支预测失败这些硬件事件。我第一次用perf record看到某段代码的缓存失效集中在一个循环上时那种原来真是这里的感觉比读十篇文章都管用。第三梯队是插桩类工具比如strace、ltrace、调试器断点。它们能看到确切的调用序列和参数但开销大会显著改变程序节奏。我的用法是先用采样类工具找到嫌疑范围再用插桩类工具在嫌疑点上做精确观察。反过来用会浪费大量时间因为插桩输出的信息量太大你不能从几万行里看出热点。还有一类是内核态动态追踪能力很强能挂到几乎任意函数上代价是需要一定的学习成本而且不同内核版本的接口稳定性有差异。我的建议是先用前面三梯队解决问题真到了必须看内核内部发生了什么的场景再上不要一开始就把它当主力。4.2 最小实验的构造原则161 条里几乎每条都有实验这些实验我总结了四条构造原则这套原则你用来验证自己的猜想同样适用。第一条一次只改一个变量。听起来是废话但我自己就犯过——在对比填充和不填充时顺手改了循环展开的次数结果两组数据差了三倍完全不知道该归因给谁。所以每次对比只允许一个差异点。第二条结果必须可观测且量化。不要用感觉快了这种描述。要么是时间要么是计数器的变化要么是明确的输出。如果实在测不出差别宁可承认这个差异在我的环境下测不出来也不要编一个数字。第三条规模要足够大让噪声淹没不了信号。测量短循环时计时本身的误差可能比被测逻辑还大。稳妥的做法是把循环次数提到百万级、把单次测量做成多次取中位数再看趋势。我常用一个小脚本跑五轮取中间值排除第一次运行的预热影响。第四条环境必须记录。同一份代码在不同 CPU、不同内核、不同编译选项下结果可能完全不一样。我的每份实验记录都带一行环境说明包括 CPU 型号和核心数、内核版本、编译器版本和优化选项、是否绑核。少了这些三个月后你自己都复现不了。顺带说一个关于优化的经验做对比实验时务必确认编译器没有把你要测的代码优化掉。比如一个只写不读的循环很可能被整个删掉。常见做法是让结果参与最终输出或者用内存屏障、volatile 等手段阻止消除。这个坑我踩过至少三次前两次还以为是优化生效了结果发现是循环根本不存在。4.3 数据记录模板为了让 161 条里的实测数据可追溯我统一用一张表格记录字段少但够用。你如果自己做实验可以直接抄这个模板字段说明示例条目编号对应的条目#058结论一句话结论同一缓存行内的跨核写会互相干扰测试环境CPU、核心数、内核、编译器8 核内核 6.xGCC 13-O2变量本组对比改了什么变量间距 8 字节 vs 64 字节对齐观测指标用什么衡量墙钟时间、缓存失效次数数据多轮中位数9.4s / 0.6s干扰因素已知影响结果的因素是否绑核、是否开超线程复现命令一条能跑的命令taskset -c 0,2 ./bench这张表最大的好处是逼你把干扰因素写出来。很多争议其实不是结论错而是两个人的测试条件不同把条件写清楚争议自然消失。5. 常见问题与排查速查5.1 概念混淆速查表读者问得最多的问题本质上都是两组概念被混在一起。我整理了一张表遇到卡壳可以直接对照。容易混淆的组合区别的关键点典型误判后果线程切换 vs 进程切换是否更换地址空间及页表以为线程切换也很贵选错并发模型缓存 vs 缓冲区缓存为复用缓冲为批量和削峰调参方向完全错栈内存 vs 堆内存分配方式、生命周期、访问局部性盲目扩大栈空间导致崩溃阻塞 IO vs 同步 IO阻塞说的是等待方式同步说的是完成通知方式把两种模型混着描述选型逻辑混乱缺页 vs 换出缺页是访问未映射页换出是页被挪到外部存储排查方向跑偏去优化磁盘却没看映射伪共享 vs 缓存失效伪共享是失效的一种特定成因修了填充却没解决真正的共享热点系统调用 vs 函数调用是否跨越权限级别在热路径里滥用性能莫名下降这张表的使用方式不是背而是当你在排查中感到说不清楚时回来对一下。判断自己有没有真的分清有个简单办法用一句话说出两者的差别然后举一个只有 A 没有 B的例子。举不出来说明还是混的。5.2 实验跑不出预期时的排查顺序做底层实验结果不符合预期是常态我按踩坑频率排了个排查顺序照着走能省很多时间。第一确认代码真的执行了。把结果打印出来看或者在关键位置加输出。我遇到过好几次测了半天没差别最后发现增量被编译器优化没了循环压根没跑。第二确认变量真的改了。拿伪共享的例子说如果你只改了对齐参数但没重新测间距或者填充量算错了变量可能还落在同一行里。稳妥做法是把地址打印出来直接看两个变量地址之差是不是大于等于 64一目了然。printf(a%p b%p diff%ld\n, (void*)q.a, (void*)q.b, (long)((char*)q.b - (char*)q.a));第三确认线程真的并行。有可能两个线程被调度到同一个核上尤其是容器里 CPU 配额受限的时候。用taskset绑到不同物理核或者去查/proc/pid/status里的核占用情况。超线程是把两个逻辑核放在一个物理核上共享执行单元做缓存类实验时影响很大能关就关。第四确认测量方式没引入偏差。计时区间包不包含线程创建、是否包含预热、有没有用中位数。我习惯把线程创建放在计时之外只测循环本身这样对比更干净。第五确认环境干扰。机器上有没有别的负载在跑、有没有开启节能降频、有没有内存压力导致换出。这些都是随机噪声的常见来源一个简单办法是同一组实验跑五遍如果波动超过百分之二十说明噪声太大先解决环境问题再谈结论。5.3 学习路径上的踩坑经验最后说几个学习路径上的坑这些不是技术问题但影响比技术问题还大。第一个坑是从最底层开始死磕。有人一上来就要看数字电路、自己画触发器花两个月之后放弃了因为看不到和实际工作的联系。我的建议是倒过来先挑一个你正在遇到的具体问题比如为什么这个接口在高并发时延迟飙升顺着问题往下挖挖到哪层学到哪层。有了具体问题你的注意力会自然聚焦记忆效果完全不同。第二个坑是收藏代替学习。看到好文章就存存了几百篇一篇没读。我的做法是三篇原则同主题的文章最多存三篇读完再存新的。底层知识的特点是同一个概念反复被不同人讲读三篇足够形成一个自己的理解再多是浪费时间。第三个坑是只看不写。底层内容如果不动手跑很难形成真正的判断力。我给自己的要求是每条至少配一次实验哪怕只是打印几个地址、跑一个strace。动手过一次以后再遇到类似问题脑子里会浮现出当时的输出而不是模糊的概念。第四个坑是追求完整。有人想把每个细节都搞清楚再往下走结果永远卡在第二个概念上。实际做法是允许自己留白先记住结论和适用边界细节在做项目遇到时再补。161 条的设计本身就是为了支持这种不求一次懂透的节奏条目之间互相引用你随时可以回来。6. 后续扩展与维护体会6.1 可以继续加条目的方向这套内容我还在持续更新主要的扩展方向有三个。第一个方向是把条目往现代硬件上靠比如多核 NUMA 架构下的内存访问差异、ARM 和 x86 在内存序模型上的区别、向量指令在实际业务里的收益边界。这些内容在传统教材里覆盖得少但在一线越来越常见。第二个方向是观测能力也就是把工具用得更深。工具本身不难学难的是在正确的时机用正确的工具并且能读懂输出里哪些是噪声。我打算把每个工具配上两到三个真实故障场景讲清楚看到什么现象去查什么可能是什么原因。这类内容比单纯介绍工具参数有用得多。第三个方向是跨层联动比如从一次 HTTP 请求出发把网络、系统调用、页缓存、磁盘、编译优化这几层串起来走一遍形成一个完整的纵向案例。这种案例的价值在于它把 161 条里的很多孤立知识点连成了一条链读者看完能建立起全局地图。6.2 我个人维护这套清单的体会做了这么久我最大的体会是计算机底层原理这东西真正难的不是概念本身而是判断力和边界感。你知道缓存行是 64 字节不难难的是知道什么情况下这个知识有用、什么情况下是过度优化。我自己就有过拿着填充对齐到处改代码的阶段结果大部分地方根本没有竞争改了纯属增加复杂度。另一个体会是底层的很多设计都是几十年前在资源极度受限的情况下定下来的理解它们最好的方式是问当时的约束是什么。缓存行为什么是 64 字节、为什么页是 4KiB、为什么 TCP 报文有最大长度限制答案都在当时的硬件条件里。你把这个约束搞明白再看现代的新设计会发现很多新方案其实是在同样的约束下做不同的取舍。最后一个实用小技巧如果你想把底层知识讲给别人听先试试能不能用一个日常场景把它说完说不完说明你自己还没理清。我在写这 161 条时凡是卡住的地方几乎都是我对这个概念的理解还有洞的地方。写作和讲解本身就是最好的检验方式这一点比读多少书都管用。