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_rq
、
CFS运行队列 csf_rq
。其中 csf_rq 是用红⿊树来描述的,按vruntime大小来排序的,最左侧的叶子节点,就是下次会被调度的任务。
这几种调度类 是有优先级的,
优先级如下:
D
eadline > 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调整的是 普通任务的优先级,
如果任务要求
实时性高,则可以考虑 改变任务的优先级以及调度策略,使其变为实时任务
。