CPU三级缓存技术解析
cpu存取数据
cpu存取数据大致可以认为是下图的流程(此处图比较简单)

cpu拿到需要的内存地址,之后这个地址会被mmu转换成真正的物理地址,接下来会去查接下来查L1 cache,L1 cache不命中查L2 cache,L2 cache不命中查L3 cache,L3 cache不能命中查内存。
其实现在查到内存还算完,现在有了虚拟内存,内存其实也是一层cache,是磁盘的cache,也就是说查内存也有可能不会命中,因为内存中的数据可能被虚拟内存系统放到磁盘中了,如果内存也不能命中就要查磁盘。
为什么需要cache
程序局部性原理
如果访问内存中的一个数据A,那么很有可能接下来再次访问到,同时还很有可能访问与数据A相邻的数据B,这分别叫做时间局部性和空间局部性。
cpu cache 有多快
根据摩尔定律,CPU 的访问速度每 18 个月就会翻 倍,相当于每年增⻓ 60% 左右,内存的速度当然也会不断增⻓,但是增⻓的速度远小于 CPU,平均每年 只增⻓ 7% 左右。于是,CPU 与内存的访问性能的差距不断拉大。
为了弥补 CPU 与内存两者之间的性能差异,就在 CPU 内部引入了 CPU Cache,也称高速缓存。
CPU Cache 通常分为大小不等的三级缓存,分别是 L1 Cache、L2 Cache 和 L3 Cache。其中L3是多个核心共享的。
程序执行时,会先将内存中的数据加载到共享的 L3 Cache 中,再加载到每个核心独有的 L2 Cache,最后 进入到最快的 L1 Cache,之后才会被 CPU 读取。之间的层级关系,如下图。

越靠近 CPU 核心的缓存其访问速度越快

cpu cache 读取过程
CPU Cache 的数据是从内存中读取过来的,以一小块一小块读取数据的,而不是按照单个数组元素来
读取数据的,在 CPU Cache 中的,这样一小块一小块的数据,称为 Cache Line(缓存块)。
可以在linux机器下执行一下命令查询L1cache的大小,单位是字节
#查看cache line 大小
cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size
#查看各级缓存大小 inde0-3分别是 L1数据缓存 L1指令缓存 L2数据缓存 L3数据缓存
cat /sys/devices/system/cpu/cpu0/cache/index0/size
比如,有一个 int array[100] 的数组,当载入 array[0] 时,由于这个数组元素的大小在内存只占 4 字 节,不足 64 字节,CPU 就会顺序加载数组元素到 array[15] ,意味着 array[0]~array[15] 数组元素都会 被缓存在 CPU Cache 中了,因此当下次访问这些数组元素时,会直接从 CPU Cache 读取,而不用再从内 存中读取,大大提高了 CPU 读取数据的性能。
如何写出让cpu跑的更快的代码
其实,这个问题定义为如何提高cpu缓存利用率更好
大家可以看下如下代码哪个执行效率更高
func main() {
n := 100
x := 0
arr := createArray(n)
//var arr [][]int
t := time.Now().UnixNano()
for i := 0; i < n; i++ {
for j := 0; j < n; j++ {
x = arr[i][j]
}
}

t1 := time.Now().UnixNano()
for i := 0; i < n; i++ {
for j := 0; j < n; j++ {
x = arr[j][i]
}
}
fmt.Println(x)

}

//创建二维数组
func createArray(n int) [][]int {
var arr [][]int

for i := 0; i < n; i++ {
var tmp []int
for j := 0; j < n; j++ {
tmp = append(tmp, i+j)
}
arr = append(arr, tmp)
}

return arr
}

/**
经过测试,形式一 array[i][j] 执行时间比形式二 array[j][i] 快好几倍。
之所以有这么大的差距,是因为二维数组 array 所占用的内存是连续的,比如⻓度 N 的指是 2 的 话,那么内存中的数组元素的布局顺序是这样的:
array[0][0] array[0][1] array[1][0] array[1][1]
形式一用 array[i][j] 访问数组元素的顺序,正是和内存中数组元素存放的顺序一致。当 CPU 访问 array[0][0] 时,由于该数据不在 Cache 中,
于是会「顺序」把跟随其后的 3 个元素从内存中加载到 CPU Cache,这样当 CPU 访问后面的 3 个数组元素时,就能在 CPU Cache 中成功地找到数据,
这意味着缓存命中率很高,缓存命中的数据不需要访问内存,这便大大提高了代码的性能。
而如果用形式二的 array[j][i] 来访问,则访问的顺序就是:
array[0][0] array[1][0] array[0][1] array[1][1]
可以看到,访问的方式跳跃式的,而不是顺序的,那么如果 N 的数值很大,那么操作 array[j][i] 时,是 没办法把 array[j+1][i] 也读入到
CPU Cache 中的,既然 array[j+1][i] 没有读取到 CPU Cache,那么就 需要从内存读取该数据元素了。很明显,这种不连续性、跳跃式访问数据元素
的方式,可能不能充分利用 到了 CPU Cache 的特性,从而代码的性能不高。那访问 array[0][0] 元素时,CPU 具体会一次从内存中加载多少元素到
CPU Cache 呢?这个问题,在前 面也提到过,这跟 CPU Cache Line 有关,表示 CPU Cache 一次性能加载数据的大小,可以在 Linux 里通过
coherency_line_size 配置查看大小,通常是 64 个字节。
*/
cpu cache的结构
CPU Cache 是由很多个 Cache Line 组成的,CPU Line 是 CPU 从内存读取数据的基本单位,而 CPU Line 是由各种标志(Tag)+ 数据块(Data Block)组成

cpu cache数据的写入
事实上,数据不止有读取还有写入,如果数据写入cache之后,内存和cache的数据就不同了,需要把cache同步到内存中。
问题的关键就在于在什么时机去把数据写到内存?一般来讲有以下两种策略
写直达
保持内存与 Cache 一致性最简单的方式是,把数据同时写入内存和 Cache 中,这种方法称为写直达 (Write Through)。

在这个方法里,写入前会先判断数据是否已经在 CPU Cache 里面了:
如果数据已经在 Cache 里面,先将数据更新到 Cache 里面,再写入到内存里面; 如果数据没有在 Cache 里面,就直接把数据更新到内存里面。
写直达法很直观,也很简单,但是问题明显,无论数据在不在 Cache 里面,每次写操作都会写回到内存, 这样写操作将会花费大量的时间,无疑性能会受到很大的影响。
写回
由于写直达的机制会有性能问题,所以产生了写回(Write Back)的方法
在写回机制中,当发生写操作时,新的数据仅仅被写入 Cache Block 里,只有当修改过的 Cache Block 「被替换」时才需要写到内存中,减少了数据写回内存的频率,这样便可以提高系统的性能。

  1. 如果当发生写操作时,数据已经在 CPU Cache 里的话,则把数据更新到 CPU Cache 里,同时标记 CPU Cache 里的这个 Cache Block 为脏(Dirty)的,这个脏的标记代表这个时候, CPU Cache 里面的这个 Cache Block 的数据和内存是不一致的,这种情况是不用把数据写到内存里的;
  2. 如果当发生写操作时,数据所对应的 Cache Block 里存放的是「别的内存地址的数据」的话,就要检 查这个 Cache Block 里的数据有没有被标记为脏的,如果是脏的话,就要把这个 Cache Block 里的数据写回到内存,然后再把当前要写入的数据,写入到这个 Cache Block 里,同时标记为脏的;如果 Cache Block 里面的数据没有被标记为脏,则就直接将数据写入到这个 Cache Block 里,然后再把这个 Cache Block 标记为脏的就好了。
    可以发现写回这个方法,在把数据写入到 Cache 的时候,只有在缓存不命中,同时数据对应的 Cache 中 的 Cache Block 为脏标记的情况下,才会将数据写到内存中,而在缓存命中的情况下,则在写入后 Cache 后,只需把该数据对应的 Cache Block 标记为脏即可,而不用写到内存里。
    这样的好处是,如果大量的操作都能够命中缓存,那么大部分时间里 CPU 都不需要读写内存,自然性 能相比写直达会高很多。
    缓存一致性问题
    现在的CPU都是多核的,由于L1/L2cache是各个核心独有的,那么会带来多核心的缓存一致性问题,如果不能保证缓存一致性问题就会造成错误的结果
    那缓存一致性的问题具体是怎么发生的呢?以一个含有两个核心的 CPU 作为例子看一看。
    假设 A 号核心和 B 号核心同时运行两个线程,都操作共同的变量 i(初始值为 0 )。

这时如果 A 号核心执行了 i++ 语句的时候,为了考虑性能,使用了前面所说的写回策略,先把值为 1 的执行结果写入到 L1/L2 Cache 中,然后把 L1/L2 Cache 中对应的 Block 标记为脏的,这个时候数据其实没有被同步到内存中的,因为写回策略,只有在 A 号核心中的这个 Cache Block 要被替换的时候,数据才会写入到内存里。
如果这时旁边的 B 号核心尝试从内存读取 i 变量的值,则读到的将会是错误的值,因为刚才 A 号核心更新 i 值还没写入到内存中,内存中的值还依然是 0。这个就是所谓的缓存一致性问题,A 号核心和 B 号核心的缓存,在这个时候是不一致,从而会导致执行结果的错误。

那么,要解决这一问题,就需要一种机制,来同步两个不同核心里面的缓存数据。要实现的这个机制的话,要保证做到下面这 2 点:
• 第一点,某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的 Cache,这个称为写传播(Wreite Propagation);
• 第二点,某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的,这个称为事务的串形化(Transaction Serialization)。
第一点写传播很容易就理解,当某个核心在 Cache 更新了数据,就需要同步到其他核心的 Cache 里。
而对于第二点事务的串形化,举个例子来理解。
假设有一个含有 4 个核心的 CPU,这 4 个核心都操作共同的变量 i(初始值为 0 )。A 号核心先把 i 值变为 100,而此时同一时间,B 号核心先把 i 值变为 200,这里两个修改,都会「传播」到 C 和 D 号核心。

那么问题就来了,C 号核心先收到了 A 号核心更新数据的事件,再收到 B 号核心更新数据的事件,因此 C 号核心看到的变量 i 是先变成 100,后变成 200。
而如果 D 号核心收到的事件是反过来的,则 D 号核心看到的是变量 i 先变成 200,再变成 100,虽然是做到了写传播,但是各个 Cache 里面的数据还是不一致的。
所以,要保证 C 号核心和 D 号核心都能看到相同顺序的数据变化,比如变量 i 都是先变成 100,再变成 200,这样的过程就是事务的串形化。
要实现事务串形化,要做到 2 点:
• CPU 核心对于 Cache 中数据的操作,需要同步给其他 CPU 核心;
• 要引入「锁」的概念,如果两个 CPU 核心里有相同数据的 Cache,那么对于这个 Cache 数据的更新,只有拿到了「锁」,才能进行对应的数据更新。
那接下来看看,写传播和事务串形化具体是用什么技术实现的。
总线
写传播的原则就是当某个 CPU 核心更新了 Cache 中的数据,要把该事件广播通知到其他核心。最常⻅实 现的方式是总线嗅探(Bus Snooping)。
还是以前面的 i 变量例子来说明总线嗅探的工作机制,当 A 号 CPU 核心修改了 L1 Cache 中 i 变量的 值,通过总线把这个事件广播通知给其他所有的核心,然后每个 CPU 核心都会监听总线上的广播事件,并 检查是否有相同的数据在自己的 L1 Cache 里面,如果 B 号 CPU 核心的 L1 Cache 中有该数据,那么也需 要把该数据更新到自己的 L1 Cache。
可以发现,总线嗅探方法很简单, CPU 需要每时每刻监听总线上的一切活动,但是不管别的核心的 Cache 是否缓存相同的数据,都需要发出一个广播事件,这无疑会加重总线的负载。
另外,总线嗅探只是保证了某个 CPU 核心的 Cache 更新数据这个事件能被其他 CPU 核心知道,但是并 不能保证事务串形化。
于是,有一个协议基于总线嗅探机制实现了事务串形化,也用状态机机制降低了总线带宽压力,这个协议 就是 MESI 协议,这个协议就做到了 CPU 缓存一致性。
MESI协议
MESI 协议其实是 4 个状态单词的开头字母缩写,分别是:
• Modified,已修改
• Exclusive,独占
• Shared,共享
• Invalidated,已失效
这四个状态来标记 Cache Line 四个不同的状态。
「已修改」状态就是前面提到的脏标记,代表该 Cache Block 上的数据已经被更新过,但是还没有写到内存里。而「已失效」状态,表示的是这个 Cache Block 里的数据已经失效了,不可以读取该状态的数据。
「独占」和「共享」状态都代表 Cache Block 里的数据是干净的,也就是说,这个时候 Cache Block 里的数据和内存里面的数据是一致性的。
「独占」和「共享」的差别在于,独占状态的时候,数据只存储在一个 CPU 核心的 Cache 里,而其他 CPU 核心的 Cache 没有该数据。这个时候,如果要向独占的 Cache 写数据,就可以直接自由地写入,而不需要通知其他 CPU 核心,因为只有这有这个数据,就不存在缓存一致性的问题了,于是就可以随便操作该数据。
另外,在「独占」状态下的数据,如果有其他核心从内存读取了相同的数据到各自的 Cache ,那么这个时候,独占状态下的数据就会变成共享状态。
那么,「共享」状态代表着相同的数据在多个 CPU 核心的 Cache 里都有,所以当要更新 Cache 里面的数据的时候,不能直接修改,而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后再更新当前 Cache 里面的数据。
举个例子
当 A 号 CPU 核心从内存读取变量 i 的值,数据被缓存在 A 号 CPU 核心自己的 Cache 里面,此时其他 CPU 核心的 Cache 没有缓存该数据,于是标记 Cache Line 状态为「独占」,此时其 Cache 中的数据与内存是一致的;

然后 B 号 CPU 核心也从内存读取了变量 i 的值,此时会发送消息给其他 CPU 核心,由于 A 号 CPU 核心已经缓存了该数据,所以会把数据返回给 B 号 CPU 核心。在这个时候, A 和 B 核心缓存了相同的数据,Cache Line 的状态就会变成「共享」,并且其 Cache 中的数据与内存也是一致的;

当 A 号 CPU 核心要修改 Cache 中 i 变量的值,发现数据对应的 Cache Line 的状态是共享状态,则要向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后 A 号 CPU 核心才更新 Cache 里面的数据,同时标记 Cache Line 为「已修改」状态,此时 Cache 中的数据就与内存不一致了。

如果 A 号 CPU 核心「继续」修改 Cache 中 i 变量的值,由于此时的 Cache Line 是「已修改」状态,因此不需要给其他 CPU 核心发送消息,直接更新数据即可。

如果 A 号 CPU 核心的 Cache 里的 i 变量对应的 Cache Line 要被「替换」,发现 Cache Line 状态是「已修改」状态,就会在替换前先把数据同步到内存。
所以,可以发现当 Cache Line 状态是「已修改」或者「独占」状态时,修改更新其数据不需要发送广播给其他 CPU 核心,这在一定程度上减少了总线带宽压力。
事实上,整个 MESI 的状态可以用一个有限状态机来表示状态流转。还有一点,对于不同状态触发的事件操作,可能是来自本地 CPU 核心发出的广播事件,也可以是来自其他 CPU 核心通过总线发出的广播事件。下图即是 MESI 协议的状态图:

MESI 协议的四种状态之间的流转过程,汇总成了下面的表格,可以更详细的看到每个状态转换的原因:

mesi可视化

MESI 缓存一致性协议
这个 VivioJS 动画旨在帮助了解 MESI 缓存一致性协议。

描述了一个多处理器系统,包括 3 个具有本地高速缓存和主存储器的 CPU。为简单起见,主存储器包括 4 个位置 a0、a1、a2 和 a3。缓存是直接映射的并且包含两个集合。偶数地址(a0 和 a2)映射到集合 0,而奇数地址(a1 和 a3)映射到集合 1。

注意:为了简化这个动画,缓存行的大小和 CPU 读/写操作的大小是相同的。然而,在写未命中时,CPU 会读取内存,即使完全覆盖高速缓存行。这模拟了实际缓存的行为,其中缓存行的大小通常大于 CPU 读/写操作的大小。

每个 CPU 都包含在指定内存位置启动读取或写入事务的按钮。“CPU 写入”将递增值(最初为 1)写入“内存”。
这个想法是按下按钮,看看是否可以跟随发生的动作和状态转换。按下右上角的“无错误”按钮可以将错误引入动画。看看是否可以确定错误是什么!
地址总线和数据总线上的流量方向分别用蓝色和红色箭头表示。事务中涉及的高速缓存行和内存位置为绿色。陈旧的内存位置是灰色的。
高速缓存行可以处于 4 种状态之一。无效:缓存中不存在缓存行。独占:缓存行仅存在于此缓存中,与内存中的副本相同。已修改:仅此缓存中存在缓存行且内存副本已过期(陈旧)。 SHARED:此缓存和可能的其他缓存中的缓存行,所有副本与内存副本相同。对SHARED高速缓存行的写入是直写,而对EXCLUSIVE高速缓存行的写入是回写。如果高速缓存观察到包含的地址的总线事务,将断言共享总线线路。MESI 是一种无效的缓存一致性协议。
这是缓存行的状态转换图:

https://www.scss.tcd.ie/Jeremy.Jones/VivioJS/caches/MESIHelp.htm
MMU
百度百科:MMU是Memory Management Unit的缩写,中文名是内存管理单元,有时称作分页内存管理单元(英语:paged memory management unit,缩写为PMMU)。一种负责处理中央处理器(CPU)的内存访问请求的计算机硬件。
为什么需要mmu?
在单片机时代,是没有操作系统的,每次写完代码都需要借助工具把程序烧进去,这样程序才能跑起来,单片机的CPU是直接操作内存的物理地址
在这种情况下要想在内存中同时运行两个程序是不可能的。比如第一个程序在2000这个写入一个新的值,将会擦掉第二个程序存放在相同位置的内容。
所以操作系统引入虚拟地址,进程都有自己的,互不干涉。
操作系统会提供一种机制,将不同进程的虚拟地址和不同内存的物理地址映射起来。如果程序要访问虚拟地址的时候,由操作系统转换成不同的物理地址,这样不同的进程运行的时候,写入
的是不同的物理地址,这样就不会冲突了。
在现在的硬件情况下,虽然内存在一步步的变大,但是对应的程序使用的内存也在一步步变大,这个时候虚拟内存就可以提供远远超实际内存限制的空间,使计算机能够同时执行更多的程序。
这个edge浏览器占用的内存

mmu的好处

  1. 为编程提供方便统一的内存空间抽象,在应用开发而言,好似都完全拥有各自独立的用户内存空间的访问权限,这样隐藏了底层实现细节,提供了统一可移植用户抽象。
  2. 以最小的开销换取性能最大化,利用MMU管理内存肯定不如直接对内存进行访问效率高,为什么需要用这样的机制进行内存管理,是因为并发进程每个进程都拥有完整且相互独立的内存空间。那么实际上内存是昂贵的,即使内存成本远比从前便宜,但是应用进程对内存的寻求仍然无法在实际硬件中,设计足够大的内存实现直接访问,即使能满足,CPU利用地址总线直接寻址空间也是有限的。
  3. 其实更重要的不是扩展了内存而是给每个程序提供了一个连续的内存空间,降低了操作内存的复杂性。
    实际上虚拟内存可以实现的是 将内存看作一个存储在硬盘上的虚拟地址空间的高速缓存,并且只在内存中缓存活动区域(比如一个浏览器打开需要200mb内存 每个页面需要200内存 浏览器即使开十几个页面,内存占用也只是400mb,当然这是一个简单的例子)
    CPU寻址
    内存通常被组织为一个由M个连续的字节大小的单元组成的数组,每个字节都有一个唯一的物理地址(Physical Address PA),作为到数组的索引。CPU访问内存最简单直接的方法就是使用物理地址,这种寻址方式被称为物理寻址。
    现代处理器使用的是一种称为虚拟寻址(Virtual Addressing)的寻址方式。使用虚拟寻址,CPU需要将虚拟地址翻译成物理地址,这样才能访问到真实的物理内存。

CPU:Central Processing Unit
MMU:Memory Management Unit
TLB:Translation Lookaside Buffer
虚拟地址
虚拟寻址需要硬件与操作系统之间互相合作。CPU中含有一个被称为内存管理单元(Memory Management Unit, MMU)的硬件,功能是将虚拟地址转换为物理地址。MMU需要借助存放在内存中的页表来动态翻译虚拟地址,该页表由操作系统管理。
分页
分⻚是把整个虚拟和物理内存空间切成一段段固定尺寸的大小。这样一个连续并且尺寸固定的内存空间, 叫⻚(Page)。在 Linux 下,每一⻚的大小为 4KB 。
CPU在获得虚拟地址之后,需要通过MMU将虚拟地址翻译为物理地址。而在翻译的过程中还需要借助页表,所谓页表就是一个存放在物理内存中的数据结构,记录了虚拟页与物理页的映射关系。
页表是一个元素为页表条目(Page Table Entry, PTE)的集合,每个虚拟页在页表中一个固定偏移量的位置上都有一个PTE。下面是PTE仅含有一个有效位标记的页表结构,该有效位代表这个虚拟页是否被缓存在物理内存中。

image-20211228160304209
虚拟页VP 0、VP 4、VP 6、VP 7被缓存在物理内存中,虚拟页VP 2和VP 5被分配在页表中,但并没有缓存在物理内存,虚拟页VP 1和VP 3还没有被分配。
在进行动态内存分配时,例如malloc()函数或者其他高级语言中的new关键字,操作系统会在硬盘中创建或申请一段虚拟内存空间,并更新到页表(分配一个PTE,使该PTE指向硬盘上这个新创建的虚拟页)。
由于CPU每次进行地址翻译的时候都需要经过PTE,所以如果想控制内存系统的访问,可以在PTE上添加一些额外的许可位(例如读写权限、内核权限等),这样只要有指令违反了这些许可条件,CPU就会触发一个一般保护故障,将控制权传递给内核中的异常处理程序。一般这种异常被称为“段错误(Segmentation Fault)”。
页命中

image-20211228160408348
如上图所示,MMU根据虚拟地址在页表中寻址到了PTE 4,该PTE的有效位为1,代表该虚拟页已经被缓存在物理内存中了,最终MMU得到了PTE中的物理内存地址(指向PP 1)。
缺页

image-20211228160442595
如上图所示,MMU根据虚拟地址在页表中寻址到了PTE 2,该PTE的有效位为0,代表该虚拟页并没有被缓存在物理内存中。虚拟页没有被缓存在物理内存中(缓存未命中)被称为缺页。
当CPU遇见缺页时会触发一个缺页异常,缺页异常将控制权转向操作系统内核,然后调用内核中的缺页异常处理程序,该程序会选择一个牺牲页,如果牺牲页已被修改过,内核会先将复制回硬盘(采用写回机制而不是直写也是为了尽量减少对硬盘的访问次数),然后再把该虚拟页覆盖到牺牲页的位置,并且更新PTE。
当缺页异常处理程序返回时,会重新启动导致缺页的指令,该指令会把导致缺页的虚拟地址重新发送给MMU。由于现在已经成功处理了缺页异常,所以最终结果是页命中,并得到物理地址。
这种在硬盘和内存之间传送页的行为称为页面调度(paging):页从硬盘换入内存和从内存换出到硬盘。当缺页异常发生时,才将页面换入到内存的策略称为按需页面调度(demand paging),所有现代操作系统基本都使用的是按需页面调度的策略。
虚拟内存跟CPU高速缓存(或其他使用缓存的技术)一样依赖于局部性原则。虽然处理缺页消耗的性能很多(毕竟还是要从硬盘中读取),而且程序在运行过程中引用的不同虚拟页的总数可能会超出物理内存的大小,但是局部性原则保证了在任意时刻,程序将趋向于在一个较小的活动页面(active page)集合上工作,这个集合被称为工作集(working set)。根据空间局部性原则(一个被访问过的内存地址以及其周边的内存地址都会有很大几率被再次访问)与时间局部性原则(一个被访问过的内存地址在之后会有很大几率被再次访问),只要将工作集缓存在物理内存中,接下来的地址翻译请求很大几率都在其中,从而减少了额外的硬盘流量。
如果一个程序没有良好的局部性,将会使工作集的大小不断膨胀,直至超过物理内存的大小,这时程序会产生一种叫做抖动(thrashing)的状态,页面会不断地换入换出,如此多次的读写硬盘开销,性能自然会十分“恐怖”。所以,想要编写出性能高效的程序,首先要保证程序的时间局部性与空间局部性。
多级页表
在 32 位的环境下,虚拟地址空间共有 4GB,假设一个⻚的大小是 4KB(2^12),那么就需要大约 100 万 (2^20) 个⻚,每个「⻚表项」需要 4 个字节大小来存储,那么整个 4GB 空间的映射就需要有 4MB 的内存来存储⻚表。
这 4MB 大小的⻚表,看起来也不是很大。但是要知道每个进程都是有自己的虚拟地址空间的,也就说都有 自己的⻚表。
那么, 100 个进程的话,就需要 400MB 的内存来存储⻚表,这是非常大的内存了,更别说 64 位的环 境了。
要解决上面的问题,就需要采用一种叫作多级⻚表(Multi-Level Page Table)的解决方案。
把这个 100 多万个「⻚表项」的单级⻚表再分⻚,将⻚表(一级⻚表)分为 1024 个⻚表(二级⻚ 表),每个表(二级⻚表)中包含 1024 个「⻚表项」,形成二级分⻚。
虽然分了二级表,映射 4GB 地址空间就需要 4KB(一级⻚表)+ 4MB(二级⻚表)的内存,这样 占用空间不是更大了吗?
当然如果 4GB 的虚拟地址全部都映射到了物理内存上的话,二级分⻚占用空间确实是更大了,但是, 往往不会为一个进程分配那么多内存。
如果使用了二级分⻚,一级⻚表就可以覆盖整个 4GB 虚拟地址空间,但如果某个一级⻚表的⻚表项没有被 用到,也就不需要创建这个⻚表项对应的二级⻚表了,即可以在需要时才创建二级⻚表。做个简单的计 算,假设只有 20% 的一级⻚表项被用到了,那么⻚表占用的内存空间就只有 4KB(一级⻚表) + 20% * 4MB(二级⻚表)= 0.804MB ,这对比单级⻚表的 4MB 是巨大的节约。

这个结构看起来很像是一个B-Tree,这种层次结构有效的减缓了内存要求:
• 如果一个一级页表的一个PTE是空的,那么相应的二级页表也不会存在。这代表一种巨大的潜在节约(对于一个普通的程序来说,虚拟地址空间的大部分都会是未分配的)。
• 只有一级页表才总是需要缓存在内存中的,这样虚拟内存系统就可以在需要时创建、页面调入或调出二级页表(只有经常使用的二级页表才会被缓存在内存中),这就减少了内存的压力。
对于 64 位的系统,两级分⻚肯定不够了,就变成了四级目录,分别是:
全局⻚目录项 PGD(Page Global Directory); 上层⻚目录项 PUD(Page Upper Directory); 中间⻚目录项 PMD(Page Middle Directory); ⻚表项 PTE(Page Table Entry);
地址翻译的过程
从形式上来说,地址翻译是一个N元素的虚拟地址空间中的元素和一个M元素的物理地址空间中元素之间的映射。
下图为MMU利用页表进行寻址的过程:

页表基址寄存器(PTBR)指向当前页表。一个n位的虚拟地址包含两个部分,一个p位的虚拟页面偏移量(Virtual Page Offset, VPO)和一个(n - p)位的虚拟页号(Virtual Page Number, VPN)。
MMU根据VPN来选择对应的PTE,例如VPN 0代表PTE 0、VPN 1代表PTE 1…因为物理页与虚拟页的大小是一致的,所以物理页面偏移量(Physical Page Offset, PPO)与VPO是相同的。那么之后只要将PTE中的物理页号(Physical Page Number, PPN)与虚拟地址中的VPO串联起来,就能得到相应的物理地址。
多级页表的地址翻译也是如此,只不过因为有多个层次,所以VPN需要分成多段。假设有一个k级页表,虚拟地址会被分割成k个VPN和1个VPO,每个VPN i都是一个到第i级页表的索引。为了构造物理地址,MMU需要访问k个PTE才能拿到对应的PPN。

TLB
多级⻚表虽然解决了空间上的问题,但是虚拟地址到物理地址的转换就多了几道转换的工序,这显然就降
低了这俩地址转换的速度,也就是带来了时间上的开销。
又是无处不在的局部性原理
就可以利用这一原理,把最常访问的几个⻚表项存储到访问速度更快的硬件,于是计算机科学家们, 就在 CPU 芯片中,加入了一个专⻔存放程序最常访问的⻚表项的 Cache,这个 Cache 就是 TLB (Translation Lookaside Buffer) ,通常称为⻚表缓存、转址旁路缓存、快表等。

image-20211228161346517
在 CPU 芯片里面,封装了内存管理单元(Memory Management Unit)芯片,用来完成地址转换和 TLB 的访问与交互。
有了 TLB 后,那么 CPU 在寻址时,会先查 TLB,如果没找到,才会继续查常规的⻚表。TLB 的命中率其实是很高的,因为程序最常访问的⻚就那么几个。
页表是被缓存在内存中的,尽管内存的速度相对于硬盘来说已经非常快了,但与CPU还是有所差距。为了防止每次地址翻译操作都需要去访问内存,CPU使用了高速缓存与TLB来缓存PTE。
在最糟糕的情况下(不包括缺页),MMU需要访问内存取得相应的PTE,这个代价大约为几十到几百个周期,如果PTE凑巧缓存在L1高速缓存中(如果L1没有还会从L2中查找,不过忽略多级缓冲区的细节),那么性能开销就会下降到1个或2个周期。然而,许多系统甚至需要消除即使这样微小的开销,TLB由此而生。

TLB(Translation Lookaside Buffer, TLB)被称为翻译后备缓冲器或翻译旁路缓冲器,MMU中的一个缓冲区,其中每一行都保存着一个由单个PTE组成的块。用于组选择和行匹配的索引与标记字段是从VPN中提取出来的,如果TLB中有T = 2^t个组,那么TLB索引(TLBI)是由VPN的t个最低位组成的,而TLB标记(TLBT)是由VPN中剩余的位组成的。
下图为地址翻译的流程(TLB命中的情况下):

• 第一步,CPU将一个虚拟地址交给MMU进行地址翻译。
• 第二步和第三步,MMU通过TLB取得相应的PTE。
• 第四步,MMU通过PTE翻译出物理地址并发送给高速缓存/内存。
• 第五步,高速缓存返回数据到CPU(如果缓存命中的话,否则还需要访问内存)。
当TLB未命中时,MMU必须从高速缓存/内存中取出相应的PTE,并将新取得的PTE存放到TLB(如果TLB已满会覆盖一个已经存在的PTE)。

Linux中的虚拟内存系统
Linux为每个进程维护了一个单独的虚拟地址空间。虚拟地址空间分为内核空间与用户空间,用户空间包括代码、数据、堆、共享库以及栈,内核空间包括内核中的代码和数据结构,内核空间的某些区域被映射到所有进程共享的物理页面。Linux也将一组连续的虚拟页面(大小等于内存总量)映射到相应的一组连续的物理页面,这种做法为内核提供了一种便利的方法来访问物理内存中任何特定的位置。

Linux将虚拟内存组织成一些区域(也称为段)的集合,区域的概念允许虚拟地址空间有间隙。一个区域就是已经存在着的已分配的虚拟内存的连续片(chunk)。例如,代码段、数据段、堆、共享库段,以及用户栈都属于不同的区域,每个存在的虚拟页都保存在某个区域中,而不属于任何区域的虚拟页是不存在的,也不能被进程所引用。
内核为系统中的每个进程维护一个单独的任务结构(task_struct)。任务结构中的元素包含或者指向内核运行该进程所需的所有信息(PID、指向用户栈的指针、可执行目标文件的名字、程序计数器等)。

• mm_struct:描述了虚拟内存的当前状态。pgd指向一级页表的基址(当内核运行这个进程时,pgd会被存放在CR3控制寄存器,也就是页表基址寄存器中),mmap指向一个vm_area_structs的链表,其中每个vm_area_structs都描述了当前虚拟地址空间的一个区域。
• vm_starts:指向这个区域的起始处。
• vm_end:指向这个区域的结束处。
• vm_prot:描述这个区域内包含的所有页的读写许可权限。
• vm_flags:描述这个区域内的页面是与其他进程共享的,还是这个进程私有的以及一些其他信息。
• vm_next:指向链表的下一个区域结构。
内存映射
Linux通过将一个虚拟内存区域与一个硬盘上的文件关联起来,以初始化这个虚拟内存区域的内容,这个过程称为内存映射(memory mapping)。这种将虚拟内存系统集成到文件系统的方法可以简单而高效地把程序和数据加载到内存中。
一个区域可以映射到一个普通硬盘文件的连续部分,例如一个可执行目标文件。文件区(section)被分成页大小的片,每一片包含一个虚拟页的初始内容。由于按需页面调度的策略,这些虚拟页面没有实际交换进入物理内存,直到CPU引用的虚拟地址在该区域的范围内。如果区域比文件区要大,那么就用零来填充这个区域的余下部分。
一个区域也可以映射到一个匿名文件,匿名文件是由内核创建的,包含的全是二进制零。当CPU第一次引用这样一个区域内的虚拟页面时,内核就在物理内存中找到一个合适的牺牲页面,如果该页面被修改过,就先写回到硬盘,之后用二进制零覆盖牺牲页并更新页表,将这个页面标记为已缓存在内存中的。
简单的来说:普通文件映射就是将一个文件与一块内存建立起映射关系,对该文件进行IO操作可以绕过内核直接在用户态完成(用户态在该虚拟地址区域读写就相当于读写这个文件)。匿名文件映射一般在用户空间需要分配一段内存来存放数据时,由内核创建匿名文件并与内存进行映射,之后用户态就可以通过操作这段虚拟地址来操作内存了。匿名文件映射最熟悉的应用场景就是动态内存分配(malloc()函数)。
Linux很多地方都采用了“懒加载”机制,自然也包括内存映射。不管是普通文件映射还是匿名映射,Linux只会先划分虚拟内存地址。只有当CPU第一次访问该区域内的虚拟地址时,才会真正的与物理内存建立映射关系。
只要虚拟页被初始化了,就在一个由内核维护的交换文件(swap file)之间换来换去。交换文件又称为交换空间(swap space)或交换区域(swap area)。swap区域不止用于页交换,在物理内存不够的情况下,还会将部分内存数据交换到swap区域(使用硬盘来扩展内存)。
共享对象
虚拟内存系统为每个进程提供了私有的虚拟地址空间,这样可以保证进程之间不会发生错误的读写。但多个进程之间也含有相同的部分,例如每个C程序都使用到了C标准库,如果每个进程都在物理内存中保持这些代码的副本,那会造成很大的内存资源浪费。
内存映射提供了共享对象的机制,来避免内存资源的浪费。一个对象被映射到虚拟内存的一个区域,要么是作为共享对象,要么是作为私有对象的。
如果一个进程将一个共享对象映射到虚拟地址空间的一个区域内,那么这个进程对这个区域的任何写操作,对于那些也把这个共享对象映射到虚拟内存的其他进程而言,也是可见的。相对的,对一个映射到私有对象的区域的任何写操作,对于其他进程来说是不可见的。一个映射到共享对象的虚拟内存区域叫做共享区域,类似地,也有私有区域。
为了节约内存,私有对象开始的生命周期与共享对象基本上是一致的(在物理内存中只保存私有对象的一份副本),并使用写时复制的技术来应对多个进程的写冲突。

只要没有进程试图写私有区域,那么多个进程就可以继续共享物理内存中私有对象的一个单独副本。然而,只要有一个进程试图对私有区域的某一页面进行写操作,就会触发一个保护异常。在上图中,进程B试图对私有区域的一个页面进行写操作,该操作触发了保护异常。异常处理程序会在物理内存中创建这个页面的一个新副本,并更新PTE指向这个新的副本,然后恢复这个页的可写权限。
还有一个典型的例子就是fork()函数,该函数用于创建子进程。当fork()函数被当前进程调用时,内核会为新进程创建各种必要的数据结构,并分配一个唯一的PID。为了给新进程创建虚拟内存,复制了当前进程的mm_struct、vm_area_struct和页表的原样副本。并将两个进程的每个页面都标为只读,两个进程中的每个区域都标记为私有区域(写时复制)。
这样,父进程和子进程的虚拟内存空间完全一致,只有当这两个进程中的任一个进行写操作时,再使用写时复制来保证每个进程的虚拟地址空间私有的抽象概念。
动态内存分配
虽然可以使用内存映射(mmap()函数)来创建和删除虚拟内存区域来满足运行时动态内存分配的问题。然而,为了更好的移植性与便利性,还需要一个更高层面的抽象,也就是动态内存分配器(dynamic memory allocator)。
动态内存分配器维护着一个进程的虚拟内存区域,也就是所熟悉的“堆(heap)”,内核中还维护着一个指向堆顶的指针brk(break)。动态内存分配器将堆视为一个连续的虚拟内存块(chunk)的集合,每个块有两种状态,已分配和空闲。已分配的块显式地保留为供应用程序使用,空闲块则可以用来进行分配,空闲状态直到显式地被应用程序分配为止。已分配的块要么被应用程序显式释放,要么被垃圾回收器所释放。

本文只讲解动态内存分配的一些概念,关于动态内存分配器的实现已经超出了本文的讨论范围。如果有感兴趣的同学,可以去参考dlmalloc的源码,由Doug Lea(就是写Java并发包的那位)实现的一个设计巧妙的内存分配器,而且源码中的注释十分多。
内存碎片
造成堆的空间利用率很低的主要原因是一种被称为碎片(fragmentation)的现象,当虽然有未使用的内存但这块内存并不能满足分配请求时,就会产生碎片。有以下两种形式的碎片:
• 内部碎片:在一个已分配块比有效载荷大时发生。例如,程序请求一个5字(这里不纠结字的大小,假设一个字为4字节,堆的大小为16字并且要保证边界双字对齐)的块,内存分配器为了保证空闲块是双字边界对齐的(具体实现中对齐的规定可能略有不同,但对齐是肯定会有的),只好分配一个6字的块。在本例中,已分配块为6字,有效载荷为5字,内部碎片为已分配块减去有效载荷,为1字。
• 外部碎片:当空闲内存合计起来足够满足一个分配请求,但是没有一个单独的空闲块足够大到可以来处理这个请求时发生。外部碎片难以量化且不可预测,所以分配器通常采用启发式策略来试图维持少量的大空闲块,而不是维持大量的小空闲块。分配器也会根据策略与分配请求的匹配来分割空闲块与合并空闲块(必须相邻)。
空闲链表
分配器将堆组织为一个连续的已分配块和空闲块的序列,该序列被称为空闲链表。空闲链表分为隐式空闲链表与显式空闲链表。
• 隐式空闲链表,是一个单向链表,并且每个空闲块仅仅是通过头部中的大小字段隐含地连接着的。
• 显式空闲链表,即是将空闲块组织为某种形式的显式数据结构(为了更加高效地合并与分割空闲块)。例如,将堆组织为一个双向空闲链表,在每个空闲块中,都包含一个前驱节点的指针与后继节点的指针。
查找一个空闲块一般有如下几种策略:
• 首次适配:从头开始搜索空闲链表,选择第一个遇见的合适的空闲块。优点在于趋向于将大的空闲块保留在链表的后面,缺点是趋向于在靠近链表前部处留下碎片。
• 下一次适配:每次从上一次查询结束的地方开始进行搜索,直到遇见合适的空闲块。这种策略通常比首次适配效率高,但是内存利用率则要低得多了。
• 最佳适配:检查每个空闲块,选择适合所需请求大小的最小空闲块。最佳适配的内存利用率是三种策略中最高的,但需要对堆进行彻底的搜索。
对一个链表进行查找操作的效率是线性的,为了减少分配请求对空闲块匹配的时间,分配器通常采用分离存储(segregated storage)的策略,即是维护多个空闲链表,其中每个链表的块有大致相等的大小。
一种简单的分离存储策略:分配器维护一个空闲链表数组,然后将所有可能的块分成一些等价类(也叫做大小类(size class)),每个大小类代表一个空闲链表,并且每个大小类的空闲链表包含大小相等的块,每个块的大小就是这个大小类中最大元素的大小(例如,某个大小类的范围定义为(17~32),那么这个空闲链表全由大小为32的块组成)。
当有一个分配请求时,检查相应的空闲链表。如果链表非空,那么就分配其中第一块的全部。如果链表为空,分配器就向操作系统请求一个固定大小的额外内存片,将这个片分成大小相等的块,然后将这些块链接起来形成新的空闲链表。
要释放一个块,分配器只需要简单地将这个块插入到相应的空闲链表的头部。
垃圾回收
在编写C程序时,一般只能显式地分配与释放堆中的内存(malloc()与free()),程序员不仅需要分配内存,还需要负责内存的释放。
许多现代编程语言都内置了自动内存管理机制(通过引入自动内存管理库也可以让C/C++实现自动内存管理),所谓自动内存管理,就是自动判断不再需要的堆内存(被称为垃圾内存),然后自动释放这些垃圾内存。
自动内存管理的实现是垃圾收集器(garbage collector),一种动态内存分配器,会自动释放应用程序不再需要的已分配块。
垃圾收集器一般采用以下两种(之一)的策略来判断一块堆内存是否为垃圾内存:
• 引用计数器:在数据的物理空间中添加一个计数器,当有其他数据与其相关时(引用),该计数器加一,反之则减一。通过定期检查计数器的值,只要为0则认为是垃圾内存,可以释放所占用的已分配块。使用引用计数器,实现简单直接,但缺点也很明显,无法回收循环引用的两个对象(假设有对象A与对象B,2个互相引用,但实际上对象A与对象B都已经是没用的对象了)。
• 可达性分析:垃圾收集器将堆内存视为一张有向图,然后选出一组根节点(例如,在Java中一般为类加载器、全局变量、运行时常量池中的引用类型变量等),根节点必须是足够“活跃“的对象。然后计算从根节点集合出发的可达路径,只要从根节点出发不可达的节点,都视为垃圾内存。
垃圾收集器进行回收的算法有如下几种:
• 标记-清除:该算法分为标记(mark)和清除(sweep)两个阶段。首先标记出所有需要回收的对象,然后在标记完成后统一回收所有被标记的对象。标记-清除算法实现简单,但效率不高,而且会产生许多内存碎片。
• 标记-整理:标记-整理与标记-清除算法基本一致,只不过后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。
• 复制:将程序所拥有的内存空间划分为大小相等的两块,每次都只使用其中的一块。当这一块的内存用完了,就把还存活着的对象复制到另一块内存上,然后将已使用过的内存空间进行清理。这种方法不必考虑内存碎片问题,但内存利用率很低。这个比例不是绝对的,像HotSpot虚拟机为了避免浪费,将内存划分为Eden空间与两个Survivor空间,每次都只使用Eden和其中一个Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一个Survivor空间上,然后清理掉Eden和刚才使用过的Survivor空间。HotSpot虚拟机默认的Eden和Survivor的大小比例为8:1,只有10%的内存空间会被闲置浪费。
• 分代:分代算法根据对象的存活周期的不同将内存划分为多块,这样就可以对不同的年代采用不同的回收算法。一般分为新生代与老年代,新生代存放的是存活率较低的对象,可以采用复制算法;老年代存放的是存活率较高的对象,如果使用复制算法,那么内存空间会不够用,所以必须使用标记-清除或标记-整理算法。
总结
虚拟内存是对内存的一个抽象。支持虚拟内存的CPU需要通过虚拟寻址的方式来引用内存中的数据。CPU加载一个虚拟地址,然后发送给MMU进行地址翻译。地址翻译需要硬件与操作系统之间紧密合作,MMU借助页表来获得物理地址。
• 首先,MMU先将虚拟地址发送给TLB以获得PTE(根据VPN寻址)。
• 如果恰好TLB中缓存了该PTE,那么就返回给MMU,否则MMU需要从高速缓存/内存中获得PTE,然后更新缓存到TLB。
• MMU获得了PTE,就可以从PTE中获得对应的PPN,然后结合VPO构造出物理地址。
• 如果在PTE中发现该虚拟页没有缓存在内存,那么会触发一个缺页异常。缺页异常处理程序会把虚拟页缓存进物理内存,并更新PTE。异常处理程序返回后,CPU会重新加载这个虚拟地址,并进行翻译。
虚拟内存系统简化了内存管理、链接、加载、代码和数据的共享以及访问权限的保护:
• 简化链接,独立的地址空间允许每个进程的内存映像使用相同的基本格式,而不管代码和数据实际存放在物理内存的何处。
• 简化加载,虚拟内存使向内存中加载可执行文件和共享对象文件变得更加容易。
• 简化共享,独立的地址空间为操作系统提供了一个管理用户进程和内核之间共享的一致机制。
• 访问权限保护,每个虚拟地址都要经过查询PTE的过程,在PTE中设定访问权限的标记位从而简化内存的权限保护。
操作系统通过将虚拟内存与文件系统结合的方式,来初始化虚拟内存区域,这个过程称为内存映射。应用程序显式分配内存的区域叫做堆,通过动态内存分配器来直接操作堆内存。
参考文献


• CS:APP3e, Bryant and O’Hallaron
• Virtual memory - Wikipedia
• Garbage collection (computer science) - Wikipedia
参考

  1. https://juejin.cn/post/6844903507594575886
  2. https://cloud.tencent.com/developer/article/1921341#:~:text=MMU
  3. https://gitlib.com/page/pc-mmu.html
    参考链接:
    https://juejin.cn/post/6844903507594575886
    https://www.zhihu.com/question/55854401/answer/2292909472

CPU三级缓存技术解析相关推荐

  1. linux查看双路cpu三级缓存,Intel谈八核心Xeon 24MB超大三级缓存技术

    今年五月底,Intel公布了即将发布的八核心Nehalem-EX Xeon服务器处理器的一些初期资料,今天又特地通过技术博客探讨了其中的24MB超大三级缓存技术. 上代架构的六核心Dunnington ...

  2. 浅谈CPU三级缓存和缓存命中率

    CPU: 文章迁移:说说CPU三级缓存和缓存命中率 - 码到城攻说说CPU三级缓存和缓存命中率https://www.codecomeon.com/posts/95/ CPU缓存(Cache Memo ...

  3. CPU三级缓存的读取顺序

    CPU缓存顺序 CPU读取数据的顺序是先缓存后内存.按照数据读取顺序和与CPU结合的紧密程度,CPU缓存可以分为一级缓存,二级缓存,部分高端CPU还具有三级缓存,每一级缓存中所储存的全部数据都是下一级 ...

  4. 应用计算机散热的原理是什么,笔记本电脑散热原理与CPU降频技术解析

    笔记本电脑的散热技术 随着电脑技术整体性能的提高,各部件的运行频率也越来越高,功耗随之加大,散热成了一个很关键的技术问题. 热量的排放关系着整个系统的稳定性及产品的使用寿命.尤其是对笔记本电脑及一些小 ...

  5. 三级缓存(不是CPU的概念,而是一种技术上逻辑容错处理方案)

    三级缓存(不是CPU的概念,而是一种技术上逻辑容错处理方案) 相信硬件出生的同学,对这个一眼就认为是CPU的三级缓存. 百科上解释的三级缓存 三级缓存是为读取二级缓存后未命中的数据设计的-种缓存,在拥 ...

  6. CPU,缓存,内存,外存全解析

    CPU,缓存,内存,外存结构图如下: 一:缓存 定义:凡是位于速度相差较大的两种硬件之间,用于协调两者数据传输速度差异的结构,均可称之为Cache 被扩充概念:如今缓存的概念已被扩充,不仅在CPU和主 ...

  7. 内卷老员工之三级缓存和伪共享

    cpu三级缓存与伪共享 cpu三级缓存 cpu共有L1 cache.L2 cache.L3 cache三级缓存,速度由高到低.其中L1与L2为cpu核心内共享,L3为所有cpu共享. L1.L2.L3 ...

  8. CPU 与 Memory 内存之间的三级缓存的实现原理

    title: CPU Cache date: 2019-11-17 20:20:30 keywords: cache "CPU cache" "三级缓存" 缓存 ...

  9. CPU 与 内存之间的三级缓存的实现原理

    一. CPU 与 Memory 内存之间的三级缓存的实现原理 1.1 cache 存在的原理   引入 Cache 的理论基础是程序局部性原理,包括时间局部性和空间局部性.时间局部性原理即最近被CPU ...

最新文章

  1. 香港中文大学(深圳)韩晓光教授招收三维数字人方向博士生
  2. 基于webuploader.js的单图片上传封装
  3. VTK:Math之1DTupleInterpolation
  4. ES6的新特性(8)——数组的扩展
  5. 红橙Darren视频笔记 仿QQ侧滑效果
  6. 【Flink】Flink Failed to push metrics to PushGateway Connect refuse
  7. 基于序列图像的三维体绘的视线投射算法
  8. 瑞友客户端无法建立跟远程计算机的连接,瑞友天翼终端错误信息的原因以及解决方法大全.doc...
  9. 网站创业项目商业计划书的写法
  10. k20pro刷鸿蒙,红米K20Pro(Redmi K20Pro)一键刷入RECOVERY获取ROOT权限-刷机教程
  11. linux磁盘刻录ISO工具,技术|Ubuntu下的三个好用的CD/DVD刻录工具
  12. 树莓派入门:一篇解决树莓派系统安装启动及远程连接的多种方式
  13. java.lang.IndexOutOfBoundsExceptionInconsistency detected. Invalid view holder adapter position问题处理
  14. 【海量数据学院】DBA学习方法论系列之:明确的学习目标
  15. ECL电平特点及其应用
  16. C语言-快速回忆_float和double的输入输出格式
  17. Java kafka监控 topic的数据量count情况,每个topic的Summed Recent Offsets(总结最近的偏移量)
  18. O - 鸣人和佐助(BFS)
  19. Linux 库打桩机制
  20. 计算机网络:08---带宽与吞吐量

热门文章

  1. Pyinstaller 打包 torch 后执行失败 OSError: could not get source code
  2. python 替换字符串的方法replace()、正则re.sub()
  3. LeetCode简单题之找到小镇的法官
  4. LeetCode简单题之托普利茨矩阵
  5. runtime系统的Cello
  6. 凭借128核芯片,安培寻求提供可靠的进步
  7. CVPR2020论文解析:视频语义检索
  8. Android多进程引发的问题
  9. 2021年大数据Kafka(九):kafka消息存储及查询机制原理
  10. TCP/UDP对比总结