1、硬件结构

1、硬件

计算机组成:CPU、内存、磁盘、外设
总线:地址总线、数据总线、控制总线
指令周期:取指、译码、执行、访存、回写

2、存储器

存储器的层次结构:寄存器(SRAM)、L1 L2 L3 Cache(SRAM)、内存(DRAM)、硬盘(ROM)

机械硬盘、固态硬盘、内存这三个存储器,到底和 CPU L1 Cache 相比速度差多少倍呢

  • CPU L1 Cache 随机访问延时是 1 纳秒,内存则是 100 纳秒,所以 CPU L1 Cache 比内存快 100 倍左右
  • SSD 随机访问延时是 150 微秒,所以 CPU L1 Cache 比 SSD 快 150000 倍左右
  • 最慢的机械硬盘随机访问延时已经高达 10 毫秒,我们来看看机械硬盘到底有多「龟速」
    • SSD 比机械硬盘快 70 倍左右
    • 内存比机械硬盘快 100000 倍左右
    • CPU L1 Cache 比机械硬盘快 10000000 倍左右

我们把上述的时间比例差异放大后,就能非常直观感受到它们的性能差异了
如果 CPU 访问 L1 Cache 的缓存时间是 1 秒,那访问内存则需要大约 2 分钟,随机访问 SSD 里的数据则需要 1.7 天,访问机械硬盘那更久,长达近 4 个月
可以发现,不同的存储器之间性能差距很大,构造存储器分级很有意义,分级的目的是要构造缓存体系

image
image

3、CPU Cache

# L1 Cache「数据」缓存的容量大小
$ cat /sys/devices/system/cpu/cpu0/cache/index0/size
# L1 Cache「指令」缓存的容量大小
$ cat /sys/devices/system/cpu/cpu0/cache/index1/size

# L2 Cache 的容量大小
$ cat /sys/devices/system/cpu/cpu0/cache/index2/size

# L3 Cache 的容量大小
$ cat /sys/devices/system/cpu/cpu0/cache/index3/size

image

3.1、直接映射

image

3.2、查找缓存过程

如果内存中的数据已经在 CPU Cache 中了,那 CPU 访问一个内存地址的时候

  • 根据内存地址中索引信息,计算在 CPU Cache 中的索引,也就是找出对应的 CPU Cache Line 的地址
  • 找到对应 CPU Cache Line 后,判断 CPU Cache Line 中的有效位,确认 CPU Cache Line 中数据是否是有效的
    如果是无效的,CPU 就会直接访问内存,并重新加载数据,如果数据有效,则往下执行
  • 对比内存地址中组标记和 CPU Cache Line 中的组标记,确认 CPU Cache Line 中的数据是我们要访问的内存数据
    如果不是的话,CPU 就会直接访问内存,并重新加载数据,如果是的话,则往下执行
  • 根据内存地址中偏移量信息(如果缓存行大小为 64 字节,偏移量信息需要 6 位),从 CPU Cache Line 的数据块中,读取对应的字

image

3.3、提高缓存命中率

局部性原理

  • 空间局部性:最近引用过的内存位置以及其周边的内存位置容易再次被使用
  • 时间局部性:程序在运行时,最近刚刚被引用过的一个内存位置容易再次被引用

L1 Cache 通常分为「数据缓存」和「指令缓存」,因此我们要分开来看「数据缓存」和「指令缓存」的缓存命中率

  • 数据缓存:空间局部性,遍历数组的时按照内存布局顺序访问
  • 指令缓存:时间局部性,如果分支预测可以预测到接下来要执行 if 里的指令,还是 else 指令的话,就可以「提前」把这些指令放在指令缓存中
    这样 CPU 可以直接从 Cache 读取到指令,于是执行速度就会很快
    当数组中的元素是随机的,分支预测就无法有效工作,而当数组元素都是是顺序的,分支预测器会动态地根据历史命中数据对未来进行预测,这样命中率就会很高
# 查看 L1 Cache 数据缓存一次载入数据的大小,通常是 64 byte
$ cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size

提高多核 CPU 的缓存命中率

  • 现代 CPU 都是多核心的,线程可能在不同 CPU 核心来回切换执行,这对 CPU Cache 不是有利的
    虽然 L3 Cache 是多核心之间共享的,但是 L1 和 L2 Cache 都是每个核心独有的,如果一个线程在不同核心来回切换,各个核心的缓存命中率就会受到影响
    相反如果线程都在同一个核心上执行,那么其数据的 L1 和 L2 Cache 的缓存命中率可以得到有效提高,缓存命中率高就意味着 CPU 可以减少访问内存的频率
  • 当有多个同时执行「计算密集型」的线程,为了防止因为切换到不同的核心,而导致缓存命中率下降的问题
    我们可以把线程绑定在某一个 CPU 核心上,这样性能可以得到非常可观的提升

4、CPU Cache 的写入

如果数据写入 Cache 之后,内存与 Cache 相对应的数据将会不同,这种情况下 Cache 和内存数据都不一致了
于是我们肯定是要把 Cache 中的数据同步到内存里的,那在什么时机才把 Cache 中的数据写回到内存呢,下面介绍两种针对写入数据的方法

  • 写回(Write Back)
  • 写直达(Write Through)

4.1、写直达

保持内存与 Cache 一致性最简单的方式是:把数据同时写入内存和 Cache 中,这种方法称为写直达(Write Through)
image

4.2、写回

既然写直达由于每次写操作都会把数据写回到内存,而导致影响性能,于是为了要减少数据写回内存的频率,就出现了写回(Write Back)的方法

  • 在写回机制中,当发生写操作时,新的数据仅仅被写入 Cache Block 里
  • 只有当修改过的 Cache Block「被替换」时才需要写到内存中,减少了数据写回内存的频率,这样便可以提高系统的性能

写回流程

  • 如果当发生写操作时,数据已经在 CPU Cache 里的话,则把数据更新到 CPU Cache 里,同时标记 CPU Cache 里的这个 Cache Block 为脏(Dirty)的
    这个脏的标记代表:我们 CPU Cache 里面的这个 Cache Block 的数据和内存是不一致的
  • 如果当发生写操作时,数据所对应的 Cache Block 里存放的是「别的内存地址的数据」的话,就要检查这个 Cache Block 里的数据有没有被标记为脏的
    • 如果不是脏的话,把当前要写入的数据先从内存读入到 Cache Block 里
      接着将数据写入到这个 Cache Block 里,然后再把这个 Cache Block 标记为脏的就好了
    • 如果是脏的话,我们就要把这个 Cache Block 里的数据写回到内存
      然后再把当前要写入的数据,先从内存读入到 Cache Block 里(这一步不是没用的)
      然后再把当前要写入的数据写入到 Cache Block,最后也把它标记为脏的

image
image

5、缓存一致性问题

5.1、问题

现在 CPU 都是多核的,由于 L1 L2 Cache 是多个核心各自独有的,那么会带来多核心的缓存一致性问题

  • 假设 A 号核心和 B 号核心同时运行两个线程,都操作共同的变量 i(初始值为 0)
  • A 号核心执行了 i++ 语句的时候,为了考虑性能,使用了我们前面所说的写回策略
    先把值为 1 的执行结果写入到 L1 L2 Cache 中,然后把 L1 L2 Cache 中对应的 Block 标记为脏的,这个时候数据其实没有被同步到内存中的
    因为写回策略,只有在 A 号核心中的这个 Cache Block 要被替换的时候,数据才会写入到内存里
  • B 号核心尝试从内存读取 i 变量的值,则读到的将会是错误的值,因为刚才 A 号核心更新 i 值还没写入到内存中,内存中的值还依然是 0

5.2、解决

要解决这一问题,就需要一种机制:同步两个不同核心里面的缓存数据,要实现的这个机制的话,必须保证做到下面这 2 点

  • 某个 CPU 核心里的 Cache 数据更新时,必须要传播到其他核心的 Cache,这个称为写传播(Write Propagation)
  • 某个 CPU 核心里对数据的操作顺序,必须在其他核心看起来顺序是一样的,这个称为事务的串行化(Transaction Serialization)

事务的串行化

假设我们有一个含有 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,这样的过程就是事务的串行化

  • CPU 核心对于 Cache 中数据的操作,需要同步给其他 CPU 核心
  • 要引入「锁」的概念,如果两个 CPU 核心里有相同数据的 Cache,那么对于这个 Cache 数据的更新,只有拿到了「锁」,才能进行对应的数据更新

image

6、总线嗅探

当 A 号 CPU 核心修改了 L1 Cache 中 i 变量的值,通过总线把这个事件广播通知给其他所有的核心
然后每个 CPU 核心都会监听总线上的广播事件,并检查是否有相同的数据在自己的 L1 Cache 里面

  • CPU 需要每时每刻监听总线上的一切活动,更新时不管别的核心的 Cache 是否缓存相同的数据,都需要发出一个广播事件,这无疑会加重总线的负载
  • 总线嗅探只是保证了某个 CPU 核心的 Cache 更新数据这个事件能被其他 CPU 核心知道,但是并不能保证事务串行化

有一个协议基于总线嗅探机制实现了事务串行化,也用状态机机制降低了总线带宽压力,这个协议就是 MESI 协议,这个协议就做到了 CPU 缓存一致性

7、MESI 协议

MESI 协议其实是 4 个状态单词的开头字母缩写:Modified 已修改、Exclusive 独占、Shared 共享、Invalidated 已失效

  • 「已修改」状态:脏标记,代表该 Cache Block 上的数据已经被更新过,但是还没有写到内存里
  • 「已失效」状态:表示的是这个 Cache Block 里的数据已经失效了,不可以读取该状态的数据
  • 「独占」和「共享」状态都代表 Cache Block 里的数据是干净的(Cache Block 里的数据和内存里面的数据是一致性的)
  • 「独占」状态:数据只存储在一个 CPU 核心的 Cache 里,而其他 CPU 核心的 Cache 没有该数据
    这个时候,如果要向独占的 Cache 写数据,就可以直接自由地写入,而不需要通知其他 CPU 核心
    因为只有你这有这个数据,就不存在缓存一致性的问题了,于是就可以随便操作该数据
    在「独占」状态下的数据,如果有其他核心从内存读取了相同的数据到各自的 Cache ,那么这个时候,独占状态下的数据就会变成「共享」状态
  • 「共享」状态:相同的数据在多个 CPU 核心的 Cache 里都有,所以当我们要更新 Cache 里面的数据的时候,不能直接修改
    而是要先向所有的其他 CPU 核心广播一个请求,要求先把其他核心的 Cache 中对应的 Cache Line 标记为「无效」状态,然后再更新当前 Cache 里面的数据

image

8、CPU 伪共享

8.1、CPU 架构

可以看到,一个 CPU 里通常会有多个 CPU 核心,比如下图中的 1 号和 2 号 CPU 核心,并且每个 CPU 核心都有自己的 L1 Cache 和 L2 Cache
而 L1 Cache 通常分为 dCache(数据缓存) 和 iCache(指令缓存),L3 Cache 则是多个核心共享的,这就是 CPU 典型的缓存层次

CPU 的 L1 ~ L3 Cache:把 Cache 作为 CPU 与内存之间的缓存层,以减少对内存的访问频率
image

8.2、伪共享问题

CPU 从内存中读取数据到 Cache 的时候,并不是一个字节一个字节读取
而是一块一块的方式来读取数据的,这一块一块的数据被称为 CPU Cache Line(缓存块),所以 CPU Cache Line 是 CPU 从内存读取数据到 Cache 的单位

至于 CPU Cache Line 大小,在 Linux 系统可以用下面的方式查看到

# 查看 L1 Cache 数据缓存一次载入数据的大小,通常是 64 byte
$ cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size

在使用单独的变量的时候,会有 Cache 伪共享的问题,Cache 伪共享问题上是一个性能杀手,我们应该规避它

  • 现在假设有一个双核心的 CPU,这两个 CPU 核心并行运行着两个不同的线程
  • 它们同时从内存中读取两个不同的数据,分别是类型为 long 的变量 A 和 B,这个两个数据的地址在物理内存上是连续的
  • 如果 Cahce Line 的大小是 64 字节,并且变量 A 在 Cahce Line 的开头位置,那么这两个数据是位于同一个 Cache Line 中
  • 又因为 CPU Cache Line 是 CPU 从内存读取数据到 Cache 的单位,所以这两个数据会被同时读入到了两个 CPU 核心中各自 Cache 中

image

步骤一:最开始变量 A 和 B 都还不在 Cache 里面,假设 1 号核心绑定了线程 A,2 号核心绑定了线程 B,线程 A 只会读写变量 A,线程 B 只会读写变量 B

image

步骤二:1 号核心读取变量 A
由于 CPU 从内存读取数据到 Cache 的单位是 Cache Line,也正好变量 A 和 变量 B 的数据归属于同一个 Cache Line
所以 A 和 B 的数据都会被加载到 Cache,并将此 Cache Line 标记为「独占」状态

image

步骤三:2 号核心开始从内存里读取变量 B
同样的也是读取 Cache Line 大小的数据到 Cache 中,此 Cache Line 中的数据也包含了变量 A 和 变量 B,此时 1 号和 2 号核心的 Cache Line 状态变为「共享」状态

image

步骤四:1 号核心需要修改变量 A
发现此 Cache Line 的状态是「共享」状态,所以先需要通过总线发送消息给 2 号核心,通知 2 号核心把 Cache 中对应的 Cache Line 标记为「已失效」状态
然后 1 号核心对应的 Cache Line 状态变成「已修改」状态,并且修改变量 A

image

步骤五:2 号核心需要修改变量 B
此时 2 号核心的 Cache 中对应的 Cache Line 是「已失效」状态,另外由于 1 号核心的 Cache 也有此相同的数据,且状态为「已修改」状态
所以要先把 1 号核心的 Cache 对应的 Cache Line 写回到内存,然后 2 号核心再从内存读取 Cache Line 大小的数据到 Cache 中
最后把变量 B 修改到 2 号核心的 Cache 中,并将状态标记为「已修改」状态

image

如果 1 号和 2 号 CPU 核心这样持续交替的分别修改变量 A 和 B,就会重复 4 和 5 这两个步骤,Cache 并没有起到缓存的效果
虽然变量 A 和 B 之间其实并没有任何的关系,但是因为同时归属于一个 Cache Line ,这个 Cache Line 中的任意数据被修改后,都会相互影响,从而出现 4 和 5 这两个步骤
这种因为多个线程同时读写同一个 Cache Line 的不同变量时,而导致 CPU Cache 失效的现象称为伪共享(False Sharing)

8.3、避免伪共享的方法

对于多个线程共享的热点数据,即经常会修改的数据,应该避免这些数据(共享变量且频繁写)刚好在同一个 Cache Line 中,否则就会出现为伪共享的问题
避免 Cache 伪共享实际上是用空间换时间的思想,浪费一部分 Cache 空间,从而换来性能的提升:Cache Line 大小字节对齐、字节填充
image

Java 并发框架 Disruptor 中有一个 RingBuffer 类会经常被多个线程使用,它使用「字节填充 + 继承」的方式,来避免伪共享的问题
image

你可能会觉得 RingBufferPad 类里 7 个 long 类型的名字很奇怪,但事实上,它们虽然看起来毫无作用,但却对性能的提升起到了至关重要的作用

  • CPU Cache 从内存读取数据的单位是 CPU Cache Line,一般 64 位 CPU 的 CPU Cache Line 的大小是 64 个字节
    一个 long 类型的数据是 8 个字节,所以 CPU 一下会加载 8 个 long 类型的数据
  • 根据 JVM 对象继承关系中父类成员和子类成员,内存地址是连续排列布局的
    因此 RingBufferPad 中的 7 个 long 类型数据作为 Cache Line 前置填充,而 RingBuffer 中的 7 个 long 类型数据则作为 Cache Line 后置填充

image

RingBufferFelds 里面定义的这些变量都是 final 修饰的,意味着第一次加载之后不会再修改
又由于「前后」各填充了 7 个不会被读写的 long 类型变量,所以无论怎么加载 Cache Line,这整个 Cache Line 里都没有会发生更新操作的数据,因此不会产生伪共享的问题

9、CPU 如何选择线程的

在 Linux 内核中,进程和线程都是用 task_struct 结构体表示的,区别在于
线程的 task_struct 结构体里部分资源是共享了进程已创建的资源,比如内存地址空间、代码段、文件描述符等
所以 Linux 中的线程也被称为轻量级进程,因为线程的 task_struct 相比进程的 task_struct 承载的资源比较少,因此以「轻」得名

一般来说,没有创建线程的进程,只有单个执行流,它被称为是主线程
如果想让进程处理更多的事情,可以创建多个线程分别去处理,但不管怎么样,进程和线程对应到内核里都是 task_struct
image

Linux 内核里的调度器,调度的对象就是 task_struct,接下来我们就把这个数据结构统称为任务
在 Linux 系统中,根据任务的优先级以及响应要求,主要分为两种(数值越小,优先级越高)

  • 实时任务:对系统的响应时间要求很高,也就是要尽可能快的执行实时任务,优先级在 0 ~ 99 范围内的就算实时任务
  • 普通任务:响应时间没有很高的要求,优先级在 100 ~ 139 范围内都是普通任务级别

9.1、调度类

由于任务有优先级之分,Linux 系统为了保障高优先级的任务能够尽可能早的被执行,于是分为了这几种调度类
image

Deadline 和 Realtime 这两个调度类,都是应用于实时任务的,这两个调度类的调度策略合起来共有这三种,它们的作用如下

  • SCHED_DEADLINE:是按照 deadline 进行调度的,距离当前时间点最近的 deadline 的任务会被优先调度
  • SCHED_FIFO:对于相同优先级的任务,按先来先服务的原则,但是优先级更高的任务,可以抢占低优先级的任务,也就是优先级高的可以「插队」
  • SCHED_RR:对于相同优先级的任务,轮流着运行,每个任务都有一定的时间片,当用完时间片的任务会被放到队列尾部,以保证相同优先级任务的公平性,但是高优先级的任务依然可以抢占低优先级的任务

而 Fair 调度类是应用于普通任务,都是由 CFS 调度器管理的,分为两种调度策略

  • SCHED_NORMAL:普通任务使用的调度策略
  • SCHED_BATCH:后台任务的调度策略,不和终端进行交互,因此为了不影响其它需要交互的任务,可以适当降低它的优先级

9.2、完全公平调度

我们平日里遇到的基本都是普通任务,对于普通任务来说,公平性最重要,在 Linux 里面,实现了一个基于 CFS 的调度算法,也就是完全公平调度

  • 这个算法的理念是:让分配给每个任务的 CPU 时间是一样
  • 它为每个任务安排一个虚拟运行时间 vruntime,如果一个任务在运行,其运行的越久,该任务的 vruntime 自然就会越大,而没有被运行的任务,vruntime 是不会变化的
  • 在 CFS 算法调度的时候,会优先选择 vruntime 少的任务,以保证每个任务的公平性

上面提到的例子没有考虑到优先级的问题,虽然是普通任务,但是普通任务之间还是有优先级区分的,所以在计算虚拟运行时间 vruntime 还要考虑普通任务的权重值
注意:权重值并不是优先级的值,内核中会有一个 nice 级别与权重值的转换表,nice 级别越低的权重值就越大,至于 nice 值是什么,我们后面会提到
image
你可以不用管 NICE_0_LOAD 是什么,你就认为它是一个常量,那么在「同样的实际运行时间」里,高权重任务的 vruntime 比低权重任务的 vruntime 少
你可能会奇怪为什么是少的:CFS 调度会优先选择 vruntime 少的任务进行调度,所以高权重的任务就会被优先调度了,于是高权重的获得的实际运行时间自然就多了

9.3、CPU 运行队列

一个系统通常都会运行着很多任务,多任务的数量基本都是远超 CPU 核心数量,因此这时候就需要排队
每个 CPU 都有自己的运行队列(Run Queue,rq),用于描述在此 CPU 上所运行的所有进程,其队列包含三个运行队列

  • Deadline 运行队列 dl_rq
  • 实时任务运行队列 rt_rq
  • CFS 运行队列 cfs_rq(用红黑树来描述的,按 vruntime 大小来排序的,最左侧的叶子节点,就是下次会被调度的任务)

这几种调度类是有优先级的,优先级如下:Deadline > Realtime > Fair,即 dl_rq > rt_rq > cfs_rq
这意味着 Linux 选择下一个任务执行的时候,会按照此优先级顺序进行选择,因此实时任务总是会比普通任务优先被执行
image

9.4、调整优先级

  • 如果我们启动任务的时候,没有特意去指定优先级的话,默认情况下都是普通任务
    普通任务的调度类是 Fair,由 CFS 调度器来进行管理(实现任务运行的公平性,也就是保障每个任务的运行的时间是差不多的)
  • 如果你想让某个普通任务有更多的执行时间,可以调整任务的 nice 值,从而让优先级高一些的任务执行更多时间
    nice 的值能设置的范围是 -20 ~ 19,值越低,表明优先级越高,因此 -20 是最高优先级,19 则是最低优先级,默认优先级是 0

是不是觉得 nice 值的范围很诡异,事实上 nice 值并不是表示优先级,而是表示优先级的修正数值(120 + nice)
它与优先级(priority)的关系是这样的:priority(new) = priority(old) + nice,内核中 priority 的范围是 0 ~ 139,值越低优先级越高

  • 其中前面的 0 ~ 99 范围是提供给实时任务使用的
  • 而 nice 值是映射到 100 ~ 139,这个范围是提供给普通任务用的,因此 nice 值调整的是普通任务的优先级

image

在前面我们提到了权重值与 nice 值的关系,nice 值越低,权重值就越大,计算出来的 vruntime 就会越少
由于 CFS 算法调度的时候,就会优先选择 vruntime 少的任务进行执行,所以 nice 值越低,任务的优先级就越高

posted @ 2023-08-06 23:07  lidongdongdong~  阅读(97)  评论(0编辑  收藏  举报