对码农的荒岛求生的《计算机底层的秘密》的学习
程序员,更具体的是编译器,告诉CPU去读写内存
CPU只是按照指令按部就班的执行,机器指令从哪里来的呢?是编译器生成的,程序员通过高级语言编写程序,编译器将其翻译为机器指令,机器指令来告诉CPU去读写内存。在精简指令集架构下会有特定的机器指令,Load/Store指令来读写内存,以x86为代表的复杂指令集架构下没有特定的访存指令。精简指令集下,一条机器指令操作的数据必须来存放在寄存器中,不能直接操作内存数据,因此RISC下,数据必须先从内存搬运到寄存器,这就是为什么RISC下会有特定的Load/Store访存指令;
而x86下无此限制,一条机器指令操作的数据可以来自于寄存器也可以来自内存,因此这样一条机器指令在执行过程中会首先从内存中读取数据。
现在我们知道了,是特定的机器指令告诉CPU要去访问内存。不过,值得注意的是,不管是RISC下特定的Load/Store指令还是x86下包含在一条指令内部的访存操作,这里读写的都是内存中的数据,除此之外还要意识到,CPU除了从内存中读写数据外,还要从内存中读取下一条要执行的机器指令。毕竟,我们的计算设备都遵从冯诺依曼架构:程序和数据一视同仁,都可以存放在内存中。
现在,我们清楚了CPU读写内存其实是由两个因素来驱动的:
- 程序执行过程中需要读写来自内存中的数据
- CPU需要访问内存读取下一条要执行的机器指令
然后CPU根据机器指令中包含的内存地址或者PC寄存器中下一条机器指令的地址访问内存。这不就完了吗?有了内存地址,CPU利用硬件通路直接读内存就好了,你可能也是这样的想的。但是cpu和内存的处理速度相差过大,在这种速度差异下,CPU执行一条涉及内存读写指令时需要等“很长一段时间“数据才能”缓缓的“从内存读取到CPU中,在这种情况你还认为CPU应该直接读写内存吗?于是你可能会想到缓存,比如如今cpu的3级缓存,甚至为了打游戏,amd在2024年末还推出9800X3D这种大缓存cpu
CPU执行指令符合28定律,大部分时间都在执行那一少部分指令,这一现象的发现奠定了精简指令集设计的基础。而程序操作的数据也符合类似的定律,只不过不叫28定律,而是叫principle of locality,程序局部性原理。如果我们访问内存中的一个数据A,那么很有可能接下来再次访问到,同时还很有可能访问与数据A相邻的数据B,这分别叫做时间局部性和空间局部性。你应该在大学课程-计算机组成原理里面有印象

如图所示,该程序占据的内存空间只有一少部分在程序执行过程经常用到。有了这个发现重点就来了,既然只用到很少一部分,那么我们能不能把它们集中起来呢?就像这样:集中起来放到哪里呢?当然是放到一种比内存速度更快的存储介质上,这种介质就是我们熟悉的SRAM,普通内存一般是DRAM,这种读写速度更快的介质充当CPU和内存之间的Cache,这就是所谓的缓存。
我们把经常用到的数据放到cache中存储,CPU访问内存时首先查找cache,如果能找到,也就是命中,那么就赚到了,直接返回即可,找不到再去查找内存并更新cache。我们可以看到,有了cache,CPU不再直接与内存打交道了。实现CPU<----->CACHE<------>memory。
但cache的快速读写能力是有代价的,代价就是Money,造价不菲,因此我们不能把内存完全替换成cache的SRAM,因此cache的容量不会很大,但由于程序局部性原理,因此很小的cache也能有很高的命中率,从而带来性能的极大提升,有个词叫四两拨千斤,用到cache这里说的好
虽然小小的cache能带来性能的极大提升,但,这也是有代价的。这个代价出现在写内存时。当CPU需要写内存时该怎么办呢?现在有了cache,CPU不再直接与内存打交道,因此CPU直接写cache,但此时就会有一个问题,那就是cache中的值更新了,但内存中的值还是旧的,这就是所谓的不一致问题,inconsistent.就像下图这样,cache中变量的值是4,但内存中的值是2。

同步缓存更新
最简单的方法是这样的,当我们更新cache时一并把内存也更新了,这种方法被称为 write-through,很形象吧。可是如果当CPU写cache时,cache中没有相应的内存数据该怎么呢?这就有点麻烦了,首先我们需要把该数据从内存加载到cache中,然后更新cache,再然后更新内存。
这种实现方法虽然简单,但有一个问题,那就是性能问题,在这种方案下写内存就不得不访问内存,上文也提到过CPU和内存可是有很大的速度差异,因此这种方案性能比较差。
异步更新缓存
这种方法性能差不是因为写内存慢,写内存确实是慢,更重要的原因是CPU在同步等待,因此很自然的,这类问题的统一解法就是把同步改为异步。
异步的这种方法是这样的,当CPU写内存时,直接更新cache,然后,注意,更新完cache后CPU就可以认为写内存的操作已经完成了,尽管此时内存中保存的还是旧数据。当包含该数据的cache块被剔除时再更新到内存中,这样CPU更新cache与更新内存就解耦了,也就是说,CPU更新cache后不再等待内存更新,这就是异步,这种方案也被称之为write-back,这种方案相比write-through来说更复杂,但很显然,性能会更好。

现在你应该能看到,添加cache后会带来一系列问题,更不用说cache的替换算法,毕竟cache的容量有限,当cache已满时,增加一项新的数据就要剔除一项旧的数据,那么该剔除谁就是一个非常关键的问题
当缓存容量有限时,替换策略的核心在于平衡效率与实现复杂度。首先,缓存系统会基于程序的局部性原理——即近期被访问的数据更可能再次被访问——来设计替换逻辑。例如,LRU(最近最少使用)算法会追踪每个缓存块的访问时间,优先替换最久未被使用的数据,这种策略通过维护访问顺序来最大化时间局部性。其次,FIFO(先进先出)算法则按照数据进入缓存的顺序进行替换,虽然实现简单,但可能错误淘汰频繁使用的“热点”数据,导致性能下降。此外,随机替换策略通过随机选择牺牲块来降低实现成本,尽管其命中率不稳定,但在某些场景下反而能避免最坏情况的发生。
更复杂的场景中,替换策略还需结合缓存层级与一致性需求。例如,当多核CPU的私有缓存(如L1/L2)需要替换脏数据(被修改但未写回内存的块)时,必须先触发回写操作以保证内存一致性。此时,替换算法不仅要选择目标块,还需处理额外的写回开销,这进一步影响性能权衡。现代处理器通常采用混合策略,例如通过硬件计数器或伪LRU树结构来近似实现高效替换,同时在多级缓存间协调,确保L3等共享缓存的替换策略能兼顾全局访问模式。这种设计在实现复杂度与命中率之间取得平衡,最终支撑起缓存系统的高性能表现。
多级cache
现代CPU为了增加CPU读写内存性能,已经在CPU和内存之间增加了多级cache,典型的有三级,L1、L2和L3,CPU读内存时首先从L1 cache找起,能找到直接返回,否则就要在L2 cache中找,L2 cache中找不到就要到L3 cache中找,还找不到就不得不访问内存了。因此我们可以看到,现代计算机系统CPU和内存之间其实是有一个cache的层级结构的。

越往上,存储介质速度越快,造价越高容量也越小;越往下,存储介质速度越慢,造价越低但容量也越大。现代操作系统巧妙的利用cache,以最小的代价获得了最大的性能。但是,注意这里的但是,要想获得极致性能是有前提的,那就是程序员写的程序必须具有良好的局部性,充分利用缓存。高性能程序在充分利用缓存这一环节可谓绞尽脑汁煞费苦心,关于这一话题值得单独成篇,稍后我会进一步学习
TO DO LIST
鉴于cache的重要性,现在增大cache已经成为提升CPU性能的重要因素,因此你去看当今的CPU布局,其很大一部分面积都用在了cache上。

多核,多问题
当摩尔定律渐渐失效后鸡贼的人类换了另一种提高CPU性能的方法,既然单个CPU性能不好提升了,我们还可以堆数量啊,这样,CPU进入多核时代,程序员开始进入苦逼时代。拥有一堆核心的CPU其实是没什么用的,关键需要有配套的多线程程序才能真正发挥多核的威力,但写过多线程程序的程序员都知道,能写出来不容易,能写出来并且能正确运行更不容易,你稍微学下redis,就知道即使是单线程的redis在高并发的状况,还是出现一堆穿透雪崩的问题
CPU开始拥有多个核心后不但苦逼了软件工程师,硬件工程师也不能幸免。前文提到过,为提高CPU 访存性能,CPU和内存之间会有一个层cache,但当CPU有多个核心后新的问题来了:
现在假设内存中有一变量X,初始值为2。系统中有两个CPU核心C1和C2,现在C1和C2要分别读取内存中X的值,根据cache的工作原理,首次读取X不能命中cache,因此从内存中读取到X后更新相应的cache,现在C1 cache和C2 cache中都有变量X了,其值都是2。接下来C1需要对X执行+2操作,同样根据cache的工作原理,C1从cache中拿到X的值+2后更新cache,在然后更新内存,此时C1 cache和内存中的X值都变为了4。
然后C2也许需要对X执行加法操作,假设需要+4,同样根据cache的工作原理,C2从cache中拿到X的值+4后更新cache,此时cache中的值变为了6(2+4),再更新内存,此时C2 cache和内存中的X值都变为了6。
看出问题在哪里了吗?一个初始值为2的变量,在分别+2和+4后正确的结果应该是2+2+4 = 8,但从上图可以看出内存中X的值却为6,问题出在哪了呢?
多核cache一致性
有的同学可能已经发现了,问题出在了内存中一个X变量在C1和C2的cache中有共计两个副本,当C1更新cache时没有同步修改C2 cache中X的值。
解决方法是什么呢?显然,如果一个cache中待更新的变量同样存在于其它核心的cache,那么你需要一并将其它cache也更新好。现在你应该看到,CPU更新变量时不再简单的只关心自己的cache和内存,你还需要知道这个变量是不是同样存在于其它核心中的cache,如果存在需要一并更新。当然,这还只是简单的读,写就更加复杂了,实际上,现代CPU中有一套协议来专门维护缓存的一致性,比较经典的包括MESI协议等。为什么程序员需要关心这个问题呢?原因很简单,你最好写出对cache一致性协议友好的程序,因为cache频繁维护一致性也是有性能代价的。
这相当复杂,只能简单的说一下:首先,当多个CPU核心共享同一块内存数据时,缓存一致性问题会变得异常复杂。比如,假设核心A和核心B的缓存中都有一份变量X的副本,当核心A修改X时,必须确保核心B的缓存中的旧副本被标记为无效或更新,否则两个核心看到的X值会不一致。这需要一套复杂的通信机制,就像交通信号灯一样协调不同方向的车辆——MESI协议正是为此设计的,它通过“修改(Modified)、独占(Exclusive)、共享(Shared)、无效(Invalid)”四种状态标记缓存行,确保每个核心在读写数据时都能感知到其他核心的操作67。
其次,维护这种一致性需要额外的通信开销。比如,当核心A写入数据时,它必须通过总线广播“我要修改这个缓存行”,其他核心收到消息后会检查自己的缓存,若存在同一数据的副本,就需要将其置为无效或更新。这种频繁的“握手”过程会消耗带宽和时间,尤其在多核竞争激烈时,总线可能成为性能瓶颈。就像一群人同时修改同一份文档,每次修改都需要通知所有人并等待确认,效率自然下降17。
然后,程序员的代码逻辑会直接影响这种通信的频率。例如,如果多个线程频繁修改同一个变量(如全局计数器),会导致缓存行在多个核心间来回失效和重新加载,这种现象称为“缓存颠簸”(Cache Thrashing)。相反,若能将数据设计为每个线程独立操作(如线程本地存储),或通过数据对齐避免“伪共享”(False Sharing,即无关变量因位于同一缓存行而被误触发失效),就能显著减少一致性维护的开销。这就像在团队协作中,合理分工比频繁协调更高效67。
此外,现代CPU还通过“嗅探”(Snooping)或“目录协议”(Directory Protocol)等技术优化一致性维护。前者让所有核心监听总线消息,实时响应数据变更;后者则在分布式系统中记录数据副本的位置,减少广播范围。但无论哪种方案,硬件层面的优化终究有限,程序员仍需在代码层面规避高冲突的内存访问模式,才能最大化性能
虚拟内存
现代计算机中CPU和内存之间有多级cache,CPU读写内存时不但要维护cache和内存的一致性,同样需要维护多核间cache的一致性。现代程序员写程序基本上不需要关心内存是不是足够这个问题,但这个问题在远古时代绝对是困扰程序员的一大难题。如果你去想一想,其实现代计算机内存也没有足够大的让我们随便申请的地步,但是你在写程序时是不是基本上没有考虑过内存不足该怎么办?为什么我们在内存资源依然处于匮乏的现代可以做到申请内存时却进入内存极大丰富的共产主义理想社会了呢?原来这背后的功臣是我们熟悉的操作系统。操作系统对每个进程都维护一个假象,即,每个进程独占系统内存资源;同时给程序员一个承诺,让程序员可以认为在写程序时有一大块连续的内存可以使用。这当然是不可能不现实的,因此操作系统给进程的地址空间必然不是真的,虚拟内存,一个“假的地址空间”更高级的叫法。进程其实一直活在操作系统精心维护的幻觉当中,能让你4gb的内存条变成8gb,真神了
CPU执行机器指令时,指令指示CPU从内存地址A中取出数据,然后CPU执行机器指令时下发命令:“给我从地址A中取出数据”,尽管真的能从地址A中取出数据,但这个地址A不是真的,不是真的,不是真的。因为这个地址A属于虚拟内存,也就是那个“假的地址空间”,现代CPU内部有一个叫做MMU的模块将这假的地址A转换为真的地址B,将地址A转换为真实的地址B之后才是本文之前讲述的关于cache的那一部分。
CPU给出内存地址,此后该地址被转为真正的物理内存地址,接下来查L1 cache,L1 cache不命中查L2 cache,L2 cache不命中查L3 cache,L3 cache不能命中查内存。各单位注意,各单位注意,到查内存时还不算完,现在有了虚拟内存,内存其实也是一层cache,是磁盘的cache,也就是说查内存也有可能不会命中,因为内存中的数据可能被虚拟内存系统放到磁盘中了,如果内存也不能命中就要查磁盘。
(看看就行了。很复杂啊)
首先,CPU在访问内存时会先将虚拟地址通过MMU(内存管理单元)转换为物理地址3,这个过程涉及TLB(快表)的快速地址转换缓存。如果TLB未命中,就需要手动计算物理地址1,这一步本身就会增加延迟。接着,CPU会按层级检查L1、L2、L3缓存,每一级缓存的访问速度逐级递减,L1延迟最低(通常几个周期),L3则可能达到几十个周期58。若三级缓存全部未命中,才会真正访问物理内存——但这里有个关键转折:物理内存本身只是虚拟内存系统的一个“缓存层”6。其次,当内存访问未命中时,问题会变得更复杂。操作系统通过虚拟内存机制将部分内存数据换出到磁盘(如SSD或硬盘),此时内存中的数据可能已被标记为“非活跃”并写入磁盘的交换分区或页面文件中6。这种情况下,CPU必须触发缺页异常(Page Fault),由操作系统介入处理:找到磁盘上的目标数据页,将其加载回内存,同时可能需要替换掉内存中其他不常用的页面(类似缓存替换策略)7。这个过程的延迟极高,可能是内存访问的十万倍以上,因此操作系统会通过预取算法和局部性原理尽量减少此类事件2。
然后,整个流程的性能瓶颈不仅在于硬件层级(如缓存行大小、关联度设计10),还涉及软件层面的协作。例如,程序的内存访问模式是否符合缓存友好性(如空间局部性和时间局部性),或者是否频繁触发跨页访问导致TLB抖动1。更极端的情况是,当多核CPU同时修改同一物理地址时,缓存一致性协议(如MESI)需要介入,通过总线嗅探或目录协议保证数据同步9,而虚拟内存的页面映射若与缓存行冲突(如伪共享问题),甚至会导致多核性能倒退7。
最后,这一切的复杂性被操作系统和硬件抽象隐藏,但程序员仍需理解底层机制。例如,频繁的内存分配/释放可能导致虚拟内存页表碎片化,而大页内存(Huge Pages)技术能减少TLB未命中3;数据库系统通过预读策略优化磁盘I/O与缓存的配合6;高性能计算则可能绕过操作系统直接操作物理内存(如RDMA)。可以说,现代计算机的存储层级就像一个精密协作的“缓存金字塔”,每一层都在用空间换时间,而程序员的优化目标就是让数据尽可能停留在金字塔顶端,减少向下的“坠落”成本。
这个过程给程序员的启示是:1),现代计算机系统是非常复杂的;2),你需要写出对cache友好的程序。
Comments NOTHING