🚀 优质资源分享 🚀
学习路线指引(点击解锁) | 知识定位 | 人群定位 |
---|---|---|
🧡 Python实战微信订餐小程序 🧡 | 进阶级 | 本课程是python flask+微信小程序的完美结合,从项目搭建到腾讯云部署上线,打造一个全栈订餐系统。 |
💛Python量化交易实战💛 | 入门级 | 手把手带你打造一个易扩展、更安全、效率更高的量化交易系统 |
故事还得从一个矛盾说起。
摩尔定律告诉我们:大约每18个月会将芯片的性能提高一倍。芯片的这种飞速发展直接导致了芯片的指令执行速度与内存读取速度之间的巨大鸿沟。
举个例子,CPU在1纳秒之内可以执行几十条指令,但是从内存中读取一条数据就需要花费几十纳秒。这种数量级的差异便是计算机中的一个主要矛盾:
CPU日益增长的对数据快速读取的需要和I/O设备读取速度不平衡不充分的发展之间的矛盾
而CPU运行所需要的指令和数据都存储在低速的内存中,人们无法容忍让CPU这样宝贵的高速设备进行漫长的等待。
计算机科学领域的任何问题都可以通过增加一个中间层来解决。所以需要一个比内存更快的存取设备做缓冲,尽量做到和CPU一样快,这样就不需要每次都从低速的内存中获取数据了。
于是引入了高速缓存。
1. 高速缓存
我们已经知道为什么需要高速缓存了。那么什么是高速缓存?它为什么就比内存快?既然这么快,为什么不直接当成内存用?
别急,我一点点解释。
1.1. 什么是高速缓存Cache
我们最熟悉的内存是一种动态随机访问存储器(Dynamic RAM,DRAM),存储器中每个存储单元由配对出现的晶体管和电容器构成,每隔一段时间,固定要对DRAM刷新充电一次,否则内部的数据就会消失。
而高速缓存是一种静态随机访问存储器(Static RAM,SRAM),不需要刷新电路就能保存它内部存储的数据,这就是静态的含义,因此SRAM的存储性能非常高!工作速度在纳秒级别,勉强能跟得上CPU的运算速度。
但是SRAM的缺点就是集成度低,相同容量的内存可以设计成较小的体积,但是SRAM却需要更大的体积;而且,SRAM这玩意儿巨贵!这就是不能直接把它当内存用的原因。
越靠近CPU核心地带的设备越需要强悍的性能,可是容量如果太小又帮不上太大的忙。如果一个中间层(一层高速缓存)不能高效解决问题,那就多来几个中间层。目前CPU的解决思路一般是以量取胜,比如同时设置L1
、L2
、L3
三级缓存。
在缓存容量上,通常是内存 > L3 > L2 > L1
,容量越小速度越快。其中L1
和L2
是由每个CPU核心独享的,L3
缓存是由所有CPU核心共享的。CPU的架构见下图:
需要特别说明的是,L1
缓存又分为了L1d
数据缓存(L1 Data)和L1i
指令缓存(L1 Instruct),上图为了完整性一并画出了,本文中的高速缓存一律指数据缓存。
为了接下来方便讲解,我们把三级缓存模型简化为一级缓存模型,毕竟道理都是相通的嘛。看一下简化之后的图。
1.2. 缓存行
说完了什么是Cache,接下来我们来看看Cache里装的到底是什么?
这不是废话嘛,肯定装的是数据啊。没错,是从内存中获取到的数据,但是数据的单位呢?CPU每次只把需要的数据从内存中读取到Cache就行了吗?肯定不是,我们想一下,只把需要的一个数据从内存中读到Cache,CPU再从Cache中继续读这个数据进行处理,Cache的存在完全就是多此一举,还不如直接从内存读数据呢。
所以要想让Cache充分发挥作用,必须让它做点“多余”的事情。因此从内存中获取数据的时候,我们把包含目标数据的一整块内存数据都放入Cache中。别小看这个动作,它有个科学的解释,叫做空间局部性。
位置相邻的数据常常会在相近的时间内被访问
根据空间局部性原理,如果目标数据相邻的数据被访问,CPU就不需要再从内存中获取了,这种直接从Cache中获取到目标数据的行为叫做“缓存命中”,极大地提高了CPU的工作效率。如果Cache里边没有,就称为Cache Miss,CPU需要再等待几十个指令周期从内存中把这一整块内存数据读入Cache。
给存储“一整块内存数据”的地方起个名字,叫「缓存行」(Cache Line)。
Cache是由缓存行组成的,缓存行是CPU高速缓存和内存交互的最小单元。在X86架构中,缓存行的大小是64个字节,大小和CPU具体型号有关。本文只关注缓存行的抽象概念,不涉及具体的缓存行大小。
接下来,终于要进入本文的正式部分了。
我一直认为,计算机的演进就是一部在挖坑和填坑之间反复横跳的发展史。对这一点的理解会随着本文的后续讲述逐渐加深。比如高速缓存Cache很好地解决了CPU与内存的速度矛盾,但是也为计算机系统带来了更高的复杂度,我来举个例子。
2. 伪共享问题
我们到目前为止说的都是CPU从Cache中read数据,但是总得有write的时候吧。既然有了Cache,肯定就得先把值write到Cache中,再更新到内存里啊。那么,问题来了。
2.1. 什么是伪共享
数据X
、Y
、Z
同处于一个缓存行内,Core0
和Core1
同时加载了该缓存行到Cache中,此时Core0
修改了该缓存行中的X
为X1
,如果此时Core1
也想修改Y
为Y1
该怎么办呢?
由于缓存行是Cache和内存之间交互的最小单元,所以Core0
根本不知道Core1
修改的是缓存中的Y
还是X
,所以为了防止造成并发问题,最好的办法就是让Core1
中的该缓存行失效,重新加载。这就是伪共享问题。
伪共享问题的定义:当多核心修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享。
2.2. 解决伪共享
既然问题是由多个变量共享一个缓存行导致的,那就让Y
变量独享一个缓存行就好了。
最简单的方法就是通过代码手动进行字节填充,拿早期的LinkedTransferQueue
中的部分源码举个例子,注意看注释内容:
static final class PaddedAtomicReference extends AtomicReference {
// 追加15个对象引用,一个对象引用占据4个字节
// 加上继承自父类的value,共64字节,正好占一个缓存行
Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;
PaddedAtomicReference(T r) {
super(r);
}
}
//父类
public class AtomicReference implements java.io.Serializable {
private volatile V value;
public AtomicReference(V initialValue) {
value = initialValue;
}
}
此外,JDK 8开始,提供了一个sun.misc.Contended
注解来解决伪共享问题,加上这个注解的类会自动补齐缓存行。
稍微扯远了一些,我们回到上方的动图。Core0
修改了缓存行中的X
,我们说当前最合适的处理办法就是让Core1
中的缓存行失效,否则就会出现缓存一致性问题。伪共享问题其实就是解决缓存一致性问题的副作用。只不过本文中我单独把这个问题列了出来。
为了解决缓存一致性问题,CPU天然支持了总线锁的功能。
3. 总线锁
顾名思义就是,锁住Bus总线。通过处理器发出lock
指令,总线接受到指令后,其他处理器的请求就会被阻塞,直到此处理器执行完成。这样,处理器就可以独占共享内存的使用。
但是,总线锁有一个非常大的缺点,一旦某个处理器获取总线锁,其他处理器都只能阻塞等待,多处理器的优势就无法发挥。
于是,经过发展、优化,又产生了缓存锁。
4. 缓存锁
缓存锁:不需锁定总线,维护本处理器内部缓存和其他处理器缓存的一致性。相比总线锁,会提高cpu利用率。
但是缓存锁也不是万能,有些场景和情况依然必须通过总线锁才能完成。
缓存锁其实是一种实现的效果,它是通过缓存一致性协议来实现的,可能有的读者也听说过Snoopy嗅探协议
,我举个例子帮助大家理解这三个概念。
假如村里有一个单人公