1. CPU 如何读写数据的?

(1) CPU框架:
先认识CPU架构,才能更好地理解 CPU是如何读写数据的,现代CPU的架构图 如下图所示:

可以看到,⼀个CPU中通常会有 多个CPU 核心,比如上图中的 1号和2号CPU核心,并且每个CPU核心 都有自己的L1 Cache和L2 Cache。L1Cache通常分为 dCache(数据缓存) 和 iCache(指令缓存),L3 Cache是多个核心所共享的,这就是CPU典型的缓存层次
上面提到的都是 CPU内部的Cache,CPU之外 还有内存和硬盘,这些存储设备共同构成了 金字塔存储层次。如下图所示:




(2) CPU读写单位:
CPU从内存中读取数据 到Cache中时,并不是⼀个字节⼀个字节地读取,而是一块一块地读取数据。这⼀块⼀块的数据被称为 CPU Line (缓存行),所以CPU Line是 CPU从内存中读取数据到Cache中的单位。
Linux系统中,可以用如下命令 查看CPU Line的大小:(该服务器的 L1 Cache Line 的大小为 64 字节,意味着 L1 Cache 一次载入数据的大小是 64 字节)

对于数组的加载, CPU会加载数组中 连续的多个数据 到Cache中,当按照数组的物理内存地址的分布顺序 去访问元素时,Cache的命中率就更高。从而减小 从内存读取数据的频率, 进而提高程序的性能。
出现的问题:当不使用数组,而是使用单独的变量时,则会出现Cache伪共享的问题,Cache伪共享的问题 是⼀个性能杀⼿,所以应该尽可能规避它。

问题:Cache伪共享是什么?如何规避?
举例:假设有⼀个双核心的CPU,这两个CPU核心 并行运行着两个不同的线程,它们同时从内存中 读取两个不同的数据,分别是类型为long的 变量A和变量B。这个两个数据的地址 在物理内存上是连续的,如果Cahce Line的大小是 64字节,并且变量A 在Cahce Line的开头位置,也就说明这两个数据是位于 同一个Cache Line中。又因为CPU Line 是CPU从内存中读取数据到Cache中 的单位,所以这两个数据会被同时读入到 两个CPU核心各自的Cache中

问题:如果这两个不同核心的线程 分别修改不同的数据 (比如:1号CPU核心的线程 只修改变量A,2号CPU核心的线程 只修改变量B),会发生什么呢?


(3) CPU伪共享问题:
结合 保证多核缓存⼀致的MESI协议 来说明整个过程:
1) 最开始的时候,变量A和变量B 都还没加载到Cache中。假设1号核心 绑定了线程 A,2号核心 绑定了线程 B,线程A 只会读写变量A,线程B 只会读写变量 B。

2) 1号核心从内存中 读取变量A,由于Cache Line 是CPU从内存中读取数据到Cache中 的单位,又因为 变量A和变量B的数据 归属于同一个Cache Line,所以 变量A和变量B的数据 都会被加载到Cache中,并将此Cache Line标记为 独占状态

3) 接着,2号核心从内存中 读取变量B,同样也是读取Cache Line大小的数据 到Cache中,此Cache Line中的数据 也包含了变量A和变量B。此时,1号核心和2号核心 的Cache Line状态变为 共享状

4) 1号核心 修改变量A时,发现此Cache Line的状态是 共享状态。所以需要先通过总线 发送消息给2号核心,通知2号核心 将Cache中对应的Cache Line标记为 已失效状态;之后再修改变量A,并将1号核心中对应的Cache Line的状态标记为 已修改状态

5) 之后,2号核心 修改变量B时,由于2号核心的Cache中对应的Cache Line 是已失效状态,另外1号核心的Cache中 也有此相同的数据,且状态为 已修改状态。所以要先将 1号核心的Cache中对应的Cache Line 写回到内存;然后2号核心再重新从内存中 读取Cache Line大小的数据 到Cache中;再先通过总线 发送消息给1号核心,通知1号核心 将Cache中对应的Cache Line标记为 已失效状态之后再修改变量B,并将该Cache Line标记为 已修改状态

因此,这种 因为多个线程同时读写 同⼀个Cache Line中的不同变量,而导致CPU Cache失效的现象 称为伪共享


(4) 避免伪共享问题:
对于多个线程共享的 热点数据 (即经常会修改的数据),应该避免这些数据 同时在同⼀个Cache Line中,否则就会出现为伪共享的问题。
Linux系统中,使用 _ _cacheline_aligned_in_smp宏定义 来解决伪共享的问题:

从上⾯的宏定义,我们可以看到:
  • 在多核(MP)系统中该宏定义是 _ _cacheline_aligned,也就是Cache Line的大小;
  • 在单核系统中:该宏定义是空的;
因此,针对在同⼀个Cache Line中的 共享的数据,如果在多个核心之间 竞争比较严重,为了防止伪共享现象的发生,可以采用上面的宏定义 使得数据在Cache Line中是对齐的。

举例:比如有下⾯这个结构体

结构体中的两个成员变量a和b 在物理内存地址上是连续的,因此它们可能会位于 同⼀个Cache Line中。如下图:

所以,为了防止出现 Cache伪共享问题,可以使用上面介绍的宏定义,从而将b的地址设置为 Cache Line对齐地址。如下图:

因此,变量a和b就不会出现在 同⼀个Cache Line中了。如下图:

避免Cache伪共享问题,实际上是用 空间换时间的思想 来解决的。选择浪费⼀部分Cache空间,来换得性能的提升。

应用层面的规避方案:有⼀个Java并发框架Disruptor 使用 "字节填充 + 继承" 的方式,来避免伪共享的问题。
Disruptor中有⼀个RingBuffer类 经常被多个线程使⽤。代码如下:

CPU Cache从内存中读取数据的单位 是CPU Line,且⼀般64位CPU的CPU Line的大小 是64个字节,而⼀个long类型的数据 占8个字节,所以CPU一次会加载 8个long类型的数据。
根据JVM对象继承关系中的 父类成员和子类成员,以及内存地址是 连续排列布局的,因此RingBufferPad中的 7个long类型数据 作为Cache Line的前置填充,而RingBuffer中的 7个long类型数据 则作为Cache Line的置填充。(这14个long变量 没有任何实际用途,更不会对它们 进行读写操作)

RingBufferFelds中定义的变量 都是final修饰的,意味着第⼀次加载之后 不会再修改,又因为"前后"各填充了 7个不会被读写的long类型变量。所以无论怎么加载Cache Line,整个Cache Line中都没有 需要更新操作的数据。只要数据被频繁地 读取访问,就自然没有数据被换出 Cache的可能,也因此不会产生 伪共享的问题。

2. CPU 如何选择线程的?

在Linux内核中,进程和线程都是用 tark_struct结构体表示的,区别在于 线程的tark_struct结构体中的部分资源 是共享了进程已创建的资源 (比如内存地址空间、代码段、⽂件描述符等),所以Linux中的线程 也称为轻量级进程。(因为线程的tark_struct 相比进程的tark_struct 承载的资源更少)
⼀般来说,没有创建线程的进程,只有单个执行流,也被称为是主线程。如果想让进程处理更多的事情,可以创建多个线程分别处理,但不管怎么样,它们对应到内核中 都是tark_struct 。

Linux内核中的调度器,调度的对象就是tark_struct。接下来将这个数据结构 统称为任务
在Linux系统中,根据任务的优先级 以及响应要求,主要分为两种任务:(优先级的数值越小,优先级越高)
  • 实时任务:对系统的响应时间 要求高。(优先级在 0~99 范围内的 就算实时任务)
  • 普通任务:对系统的响应时间 要求不高。(优先级在 100~139 范围内的 都是普通任务)

(1) 调度类:
由于任务有优先级之分,Linux系统为了保障 高优先级的任务 能够尽可能早的被执行,于是分为了这几种调度类。如下图:

Deadline调度类Realtime调度类 都应用于实时任务。这两个调度类的调度策略 共有以下三种:
  • SCHED_DEADLINE按照 deadline 进行调度,距离当前时间点最近的deadline的任务 将会被优先调度。
  • SCHED_FIFO:对于相同优先级的任务,按先来先服务的原则运行。但是优先级更高的任务 可以抢占低优先级的任务,也就是 优先级高的任务 可以" 插队"。
  • SCHED_RR:对于相同优先级的任务,轮流运行。每个任务都有⼀个的时间片,用完时间片的任务 会被放到队列尾部,以保证 相同优先级任务的公平性,但高优先级的任务 依然可以抢占低优先级的任务。
Fair调度类 应用于普通任务,由CFS调度器管理。分为两种调度策略:
  • SCHED_NORMAL:普通任务使用的调度策略
  • SCHED_BATCH:后台任务使用的调度策略,不和终端进⾏交互。因此在不影响 其他需要交互的任务时,可以适当降低它的优先级。

(2) 完全公平调度:
大部分的任务 都是普通任务,对于普通任务而言,公平性最重要。在Linux系统中,实现了⼀个基于CFS的调度算法,称为完全公平调度算法
算法的理念:为了让分配给每个任务的 CPU时间是⼀样,于是为每个任务 安排⼀个虚拟运行时间vruntime。如果⼀个任务在运⾏,且其运行的越久,则该任务的vruntime就越大;而没有被运行的任务,vruntime不变。在CFS算法调度时,会优先选择vruntime小的任务,以保证每个任务的公平性
虽然是普通任务,但是普通任务之间 还是存在优先级区分的,所以在计算虚拟运行时间vruntime时 还需要考虑普通任务的权重值。(权重值并不是优先级的值,内核中有⼀个 nice值与权重值的转换表,nice值越低 权重值就越大)

NICE_0_LOAD是⼀个常量,在"同样的实际运行时间"内,高权重任务的vruntime 比低权重任务的vruntime小,并且也是 优先选择vruntime小的任务进行调度,所以高权重任务 会被优先调度。

(3) CPU 运行队列:
一个系统通常都会 运行着很多任务,任务的数量基本都是远超 CPU的核心数量,因此这时候就需要排队。
事实上,每个CPU都有自己的 运行队列(run queue),用于描述在此CPU上 运行的所有进程。该队列包含三个运行队列:Deadline运行队列 dl_rq实时任务运行队列 rt_rqCFS运行队列 csf_rq。其中 csf_rq 是用红⿊树来描述的,按vruntime大小来排序的,最左侧的叶子节点,就是下次会被调度的任务。

这几种调度类 是有优先级的,优先级如下:Deadline > Realtime > Fair。意味着Linux内核 选择下⼀个任务执行时,会按照此优先级顺序 进行选择。也就是说,先从dl_rq中选择任务,再从rt_rq中选择任务,最后从 csf_rq中选择任务。因此,实时任务相对普通任务 总是被优先执行

(4) 调整优先级:
如果启动任务时 没有特意指定优先级,则默认情况下 都为普通任务。普通任务的调度类是Fair,由CFS调度器管理。CFS调度器的目的是 实现任务运行的公平性,也就是保障每个任务的运行时间 是差不多的。
如果某个普通任务 需要更多的执行时间,可以通过 调整任务的nice值,从而让优先级更高的任务 获得更多的执行时间。在Linux内核中,nice值的可设置范围是 -20~19, nice值越小,表示优先级越高。(-20是最高优先级,19是最低优先级,默认优先级是0)
事实上,nice值并不代表优先级,而是优先级的修正数值nice值与优先级的关系:priority(new) = priority(old) + nice。在Linux内核中,priority的范围是 0~139,priority值越小,表示优先级越高。(0~99是提供给 实时任务使用的,而nice值是映射到100~139,这个范围是提供给 普通任务使用的。因此,nice值调整的是 普通任务的优先级

前面提到过,nice值越低,权重值就越大,计算出的vruntime值 就会越小。由于CFS算法调度时 会优先选择vruntime值小的任务执行,所以nice值越小,任务的优先级就越高。

在启动任务时,可以指定nice的值。(比如:将mysqld的优先级 设为-3)

修改运行中的任务 的优先级,可使用 renice 来调整nice的值

nice调整的是 普通任务的优先级,如果任务要求实时性高,则可以考虑 改变任务的优先级以及调度策略,使其变为实时任务