title, date, author, tags, categories, thumbnail, blogexcerpt
| title | date | author | tags | categories | thumbnail | blogexcerpt | |||
|---|---|---|---|---|---|---|---|---|---|
| 工具 | 2021-06-26 09:40 | gatieme |
|
|
虚拟化 & KVM 子系统 |
本作品采用 <a rel="license"href="http://creativecommons.org/licenses/by-nc-sa/4.0/"> 知识共享署名 - 非商业性使用 - 相同方式共享 4.0 国际许可协议 进行许可, 转载请注明出处, 谢谢合作
<a rel="license"href="http://creativecommons.org/licenses/by-nc-sa/4.0/"><img alt=" 知识共享许可协议 "style="border-width:0"src="https://i.creativecommons.org/l/by-nc-sa/4.0/88x31.png"/>
因本人技术水平和知识面有限, 内容如有纰漏或者需要修正的地方, 欢迎大家指正, 鄙人在此谢谢啦
** 转载请务必注明出处, 谢谢, 不胜感激 **
| 日期 | 作者 | GitHub | CSDN | BLOG |
|---|---|---|---|---|
| 2021-02-15 | 成坚 - gatieme | AderXCoding/system/tools/fzf |
使用模糊搜索神器 FZF 来提升办公体验 | Using FZF to Improve Productivit |
1 Cache 一致性
2 缓存一致性协议 --MESI 协议
由于现在一般是多核处理器, 每个处理器都有自己的高速缓存, 那么会导致一些问题:
当某一个数据在多个处于 "运行" 状态的线程中进行读写共享时 (例如 ThreadA、ThreadB 和 ThreadC)
-
第一个问题是多个线程可能在多个独立的 CPU 内核中 "同时" 修改数据 A, 导致系统不知应该以哪个数据为准;
-
第二个问题是由于 ThreadA 进行数据 A 的修改后没有即时写会内存, ThreadB 和 ThreadC 也没有即时拿到新的数据 A, 导致 ThreadB 和 ThreadC 对于修改后的数据不可见.
这就是缓存一致性问题.
为了解决这个问题, 处理器之间需要一种通信机制 ---- 缓存一致性协议.
2.1 MESI 状态
MESI(Modified-Exclusive-Shared-Invalid) 协议是一种广为使用的缓存一致性协议. MESI 协议对内存数据访问的控制类似于读写锁, 它使得针对同一地址的读内存操作是并发的, 而针对同一地址的写内存操作是独占的.
之所以叫 MESI, 是因为这套方案把一个缓存行 (cache line) 区分出四种不同的状态标记, 他们分别是 Modified、Exclusive、Shared 和 Invalid. 这四种状态分别具备一定的意义:
| 状态 | 描述 | 监听任务 | 状态转换 |
|---|---|---|---|
| M 修改 (Modified) | 该 Cache line 有效, 但是数据被修改了 (和内存中的数据不一致), 并且该数据只存在于本 Cache 中. 在未来的某个时刻该数据会被写入到内存中 (一般在其他 CPU 要读取该缓存行的内容时. 或者其他 CPU 要修改该缓存对应的内存中的内容时 |
缓存行必须时刻监听所有试图读该缓存行相对就主存的操作, 这种操作必须在缓存将该缓存行写回主存并将状态变成 S(共享) 状态之前被延迟执行. | 当被写回主存之后, 该缓存行的状态会变成独享 (exclusive) 状态. |
| E 独享、互斥 (Exclusive) | 该 Cache line 有效, 数据和内存中的数据一致, 并且数据只存在于本 Cache 中. | 缓存行也必须监听其它缓存读主存中该缓存行的操作, 一旦有这种操作, 该缓存行需要变成 S(共享) 状态. | 该缓存可以在任何其他 CPU 读取该缓存对应内存中的内容时变成 S 状态. 或者本地处理器写该缓存就会变成 M 状态. |
| S 共享 (Shared) | 该 Cache line 有效, 数据和内存中的数据一致, 数据存在于很多 Cache 中. | 当前缓存行必须监听其它缓存使该缓存行无效或者独享该缓存行的请求, 并将该缓存行变成无效 (Invalid). | 当其他 CPU 修改该缓存行对应的内存时会使该缓存行变成 I 状态. |
| I 无效 (Invalid) | 该 Cache line 无效. | 无 | 无 |
监听:
-
一个处于 M 状态的缓存行必须时刻监听所有试图读该缓存行相对就主存的操作, 这种操作必须在缓存将该缓存行写回主存并将状态变成 S 状态之前被延迟执行.
-
一个处于 S 状态的缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求, 并将该缓存行变成无效 (Invalid).
-
一个处于 E 状态的缓存行也必须监听其它缓存读主存中该缓存行的操作, 一旦有这种操作, 该缓存行需要变成 S 状态.
2.2 请求
首先不同 CPU 之间也是需要沟通的, 这里的沟通是通过在消息总线上传递 message 实现的. 这些在总线上传递的消息有如下几种:
| 消息 | 类型 | 描述 |
|---|---|---|
| Read | 请求 | 用来获取指定物理地址上的 cache line 数据. 通知其他处理器和内存, 当前 CPU 准备读取某个数据. 该消息内包含待读取数据的内存地址. |
| Read Response | 响应 | Read 请求的响应信息, 该消息内包含了被请求读取的数据. 该消息可能是主内存返回的, 也可能是其他高速缓存嗅探到 Read 消息返回的. |
| Invalidate | 请求 | 通知其他处理器删除 (无效) 指定内存地址的数据副本 (缓存行中的数据). 该消息包含数据的内存物理地址. |
| Invalidate Acknowledge | 响应 | 这是 CPU 对 Invalidate 消息的响应, 接收到 Invalidate 消息的处理器必须回复此消息, 表示已经无效掉了其高速缓存内对应的数据副本. |
| Read Invalidate | 请求 | 这个消息其实是 Read 和 Invalidate 消息组成的复合消息, 主要是用于通知其他 CPU 当前 CPU 准备更新一个数据了, 所以获取对该数据的独占权, 并请求其他处理器删除其高速缓存内对应的数据副本. 接收到该消息的处理器必须回复 Read Response 和 Invalidate Acknowledge 消息. |
| Writeback | 响应 | 消息包含了需要写入内存的数据和其对应的内存地址, 一般用在 modified 状态的 cache line 被置换时发出, 用来将最新的数据写回 memory 或其他下一级 cache 中. |
2.3 状态转换
那么这些请求如何动态的转换呢 ?
MESI 状态转换的规则如下:
-
一个缓存除在 Invalid 状态外都可以满足 CPU 的读请求, 一个 Invalid 的缓存行必须从主存中读取 (变成 S 或者 E 状态) 来满足该 CPU 的读请求.
-
一个写请求只有在该缓存行是 M 或者 E 状态时才能被执行, 如果缓存行处于 S 状态, 必须先将其它缓存中该缓存行变成 Invalid 状态 (也既是不允许不同 CPU 同时修改同一缓存行, 即使修改该缓存行中不同位置的数据也不允许). 该操作经常作用广播的方式来完成, 例如: RequestFor Ownership (RFO).
-
缓存可以随时将一个非 M 状态的缓存行作废, 或者变成 Invalid 状态, 而一个 M 状态的缓存行必须先被写回主存.
-
从上面的意义看来 E 状态是一种投机性的优化: 如果一个 CPU 想修改一个处于 S 状态的缓存行, 总线事务需要将所有该缓存行的 copy 变成 Invalid 状态, 而修改 E 状态的缓存不需要使用总线事务.
-
对于 M 和 E 状态而言总是精确的, 他们和该缓存行的实际状态是一致的. 而 S 状态可能是非一致的, 如果一个缓存将处于 S 状态的缓存行作废了, 而另一个缓存实际上可能已经独享了该缓存行, 但是该缓存却不会将该缓存行升迁为 E 状态, 这是因为其它缓存不会广播他们作废掉该缓存行的通知, 同样由于缓存并没有保存该缓存行的 copy 的数量, 因此 (即使有这种通知) 也没有办法确定自己是否已经独享了该缓存行. 因此上述状态转换的表格中, 中间状态可能并不会发生.
local read 和 local write 分别代表本地 CPU 读写. remote read 和 remote write 分别代表其他 CPU 读写.
| 当前状态 | 事件 | 行为 | 当前所在核的 cache 状态 | 触发读写事件 CPU 的 cache 状态 | 其他 cache 状态 |
|---|---|---|---|---|---|
| M(modified) | local read | 状态不变 | M | M | I |
| M(modified) | local write | 状态不变 | M | M | I |
| M(modified) | remote read | 在本 CPU 独自享受独占数据的时候, 其他的 CPU 发起 read 请求, 希望获取数据, 这时候, 本 CPU 必须以其 local cacheline 的数据回应, 并以 read response 回应之前总线上的 read 请求. 这时候, 本 CPU 失去了独占权, 该 cacheline 状态从 Modified 状态变成 Shared 状态 (有可能也会进行写回的动作, 写会后, 可能存在中间状态 E). | M->E->S | I->S | I->S |
| M(modified) | remote write | ~~ 先把 cache 中的数据写到内存中, 其他 CPU 的 cache 再读取并修改后, 本地 cache 状态变为 I. 修改的那个 cache 状态变为 M~~ CPU 收到一个 read invalidate 消息, 此时 CPU 必须将对应 cache line 设置成 invalid 状态, 并且响应一个 read response 消息和 invalidate acknowledge 消息. |
I | M->E->S->I | I->S->E->M |
| M(modified) | write back | cache 通过 writeback 将数据回写到 memory 或者下一级 cache 中. 这时候状态由 modified 变成了 exclusive | E | E | I |
- |
- |
- |
--- |
||
| E(exclusive) | local read | 状态不变 | E | E | I |
| E(exclusive) | local write | CPU 直接将数据写入 cache line, 导致状态变为了 M | E->M | E->M | I |
| E(exclusive) | remote read | 这个迁移和 (M-=>S) 的转换类似, 只不过开始 cacheline 的状态是 exclusive, cacheline 和 memory 的数据都是最新的, 不存在写回的问题. 总线上的操作也是在收到 read 请求之后, 以 read response 回应. 本 CPU 失去了独占权, 该 cacheline 状态从 Modified 状态变成 shared 状态 (不会进行写回的动作) | E->S | E->S | I->S |
| E(exclusive) | remote write | 其他的 CPU 进行一个原子的 read-modify-write 操作, 但是, 数据在本 CPU 的 cacheline 中, 因此, 其他的那个 CPU 会发送 read invalidate, 请求该数据 (可能存在存在中间状态 S) 以及获取独占权 (可能存在存在中间状态 E). 本 CPU 回送 read response 和 invalidate acknowledge, 一方面把数据转移到其他 CPU 的 cache 中 (存在中间状态 S), 另外一方面, 清空自己的 cacheline. | E->S->I | I->S->E->M | I->S->I |
- |
- |
- |
--- |
||
| S(shared) | local read | 不影响状态 | S | S | S |
| S(shared) | local write | CPU 需要执行一个原子的 readmodify-write 操作, 并且其 local cache 中有 read only 的缓存数据 (cache line 处于 Shared 状态), 这时候, CPU 就会在总线上发送一个 invalidate 请求其他 CPU 清空自己的 local copy, 以便完成其独自霸占对该数据的所有权的梦想 (存在中间状态 E). 同样的, 该 CPU 必须收集所有其他 CPU 发来的 invalidate acknowledge 之后才能更改状态为 modified. | S->E->M | S->E->M | S->I |
| S(shared) | remote read | 不影响状态 | S | S | S |
| S(shared) | remote write | 当 cache line 处于 shared 状态的时候, 说明在多个 CPU 的 local cache 中存在副本, 因此, 这些 cache line 中的数据都是 read only 的, 一旦其中一个 CPU 想要执行数据写入的动作, 必须先通过 invalidate 获取该数据的独占权 (可能存在中间状态 E), 而其他的 CPU 会以 invalidate acknowledge 回应, 清空数据并将其 cacheline 从 shared 状态修改成 invalid 状态. 在写完成后, 本次 cache line 变成 modified 状态 | S->I | S->E->M | S->I |
| S(shared) | NA | 如果 CPU 认为自己很快就会启动对处于 shared 状态的 cacheline 进行 write 操作, 因此想提前先霸占上该数据. 因此, 该 CPU 会发送 invalidate 敦促其他 CPU 清空自己的 local copy, 当收到全部其他 CPU 的 invalidate acknowledge 之后, transaction 完成, 本 CPU 上对应的 cacheline 从 shared 状态切换 exclusive 状态. 还有另外一种方法也可以完成这个状态切换: 当所有其他的 CPU 对其 local copy 的 cacheline 进行写回操作, 同时将 cacheline 中的数据设为无效 (主要是为了为新的数据腾些地方), 这时候, 本 CPU 坐享其成, 直接获得了对该数据的独占权. |
E | E/I | I |
- |
- |
- |
--- |
||
| I(invalid) | local read | 本 CPU 执行读操作, 发现 local cache 没有数据, 因此通过 read 发起一次 bus transaction, 来自其他的 CPU local cache 或者 memory 会通过 read response 回应, 1. 如果数据来自其他 CPU 的 cache, 将该 cache line 从 Invalid 状态迁移到 Shared 状态. 2. 如果来自于内存, 则本地从 Invalid 状态迁移到 Exclusive 状态. |
I->S/I->E | I->S/I->E | E/M/I->S/I |
| I(invalid) | local write | CPU 想要进行 write 的操作但是数据不在 local cache 中, 因此, 该 CPU 首先发送了 read invalidate 启动了一次总线 transaction. 在收到 read response 回应拿到数据, 并且收集所有其他 CPU 发来的 invalidate acknowledge 之后 (确保其他 CPU 没有 local copy), 完成整个 bus transaction. 当 write 操作完成之后, 该 cacheline 的状态会从 I 状态迁移到 E 状态. | E | E | E |
| I(invalid) | local write | CPU 需要执行一个原子的 readmodify-write 操作, 并且其 cache 中没有缓存数据. 这时候 CPU 就会在总线上发送一个 read invalidate 消息来请求数据 (可能存在中间状态 S), 并试图独占该数据 (可能存在中间状态 E). CPU 可以通过收到的 read response 消息获取到数据, 并等待所有的 invalidate acknowledge 消息, 然后将状态设置为 modifie. | I->S->E->M | I->S->E->M | M,E,S->S->I |
| I(invalid) | remote read | remote read 不影响本地 cache 的状态 | I | NA | NA |
| I(invalid) | remote write | remote read 不影响本地 cache 的状态 | I | NA | NA |
下图示意了, 当本地 cache line 的调整的状态的时候, 其他 CPU 的 cache line 需要调整到的状态.
| M | E | S | I |
|---|---|---|---|
| M | × | × | × |
| E | × | × | × |
| S | × | × | √ |
| I | √ | √ | √ |
举个栗子来说:
假设 CPU 1 的 cache line 中有一个变量 x = 0 的 cache line 处于 S 状态 (共享). 那么其他拥有 x 变量的 CPU 2, CPU 3 等的 cache line 可能调整到的状态就是 S 状态 (共享) 或者调整为 I 状态 (无效).
3 硬件的处理
3.1 L3 Cache 在 MESI 中的角色
L3 缓存是所有 CPU 共享的一个缓存. 纵观刚才描述的 MESI, 好像涉及的都是 CPU 内的缓存更新, 不涉及 L3 缓存, 那么 L3 缓存在 MESI 中扮演什么角色呢 ?
其实在常见的 MESI 的状态流程描述中, 所有提到 "内存" 的地方都是值得商榷的. 比如我上一节举的例子中, CPU0 中某缓存行是 I, CPU1 中是 M. 当 CPU0 想到执行 local read 操作时, 就会触发 CPU1 中的缓存写入到内存中, 然后 CPU0 从内存中取最新的缓存行. 其实准确来讲这里是不准确的, 因为由于 L3 缓存的存在, 这里其实是直接从 L3 缓存读取缓存行, 而不直接访问内存.
个人猜测是如果描述 MESI 状态流转的时候引入 L3 缓存, 会造成描述会极其复杂. 所以一般的描述都好似有意地忽略了 L3 缓存.
3.2 Store Buffer
3.2.1 Store Buffer 的作用
当然前面的描述隐藏了一些细节, 比如实际 CPU1 在执行写操作, 更新缓存行的时候, 其实并不会等待其他 CPU 的状态都置为 I 状态, 才去做些操作, 这是一个同步行为, 效率很低. 当前的 CPU 都引入了 Store Buffer(写缓存器) 技术, 也就是在 CPU 和 cache 之间又加了一层 buffer, 在 CPU 执行写操作时直接写 StoreBuffer, 然后就忙其他事去了, 等接收到其他 CPU 返回的 Invalidate Acknowledge 响应 后, CPU1 才把 buffer 中的数据写入到缓存行中.
3.2.2 Store Buffer 引入的问题
Store Buffer 会引入的数据可见性问题, 因为数据已经修改了, 但是没有更新到 Cache, 不被 Cache 一致性感知. 大家读取这个变量和数据的时候, 并不能知道某个核的 Store Buffer 中可能存有一个这个值的最新值, 因此读取到的依旧是旧值. 这就引入了数据可见性的问题.
| 观察者 | 问题 | 解决方案 |
|---|---|---|
| 本核 | 本核对一个变量 X 更新后, 再读取它的值, 由于可能变量 X 的最新值在 Store Buffer 中, 本核可能读出来一个旧值, 这是完全不允许的. 因此它直接破坏了程序预期的逻辑. | 引入了 Store Buffer 之后, 后续当前 CPU 读取数据的时候, 必须先查 Store Buffer, 再查 cache line. 这种 Store Buffer 设计上的策略叫做 Store Forwarding. 这种策略是必须实现的, 否则单核上自己从 Cache 上看到的数据都是旧的 (错的). 就是说核 A 在读取数据的时候会先看 Store Buffer 中的数据, 如果 Store Buffer 中有数据, 直接使用 Store Buffer 中的, 从而避免使用错误数据. |
| 其他核 | 在多核情况下, 核 B 在进行判断的时候发现在自己的缓存存在 x=0, 但此时核 A 刚刚将 x = 2 的操作放到 Store Buffer 中, 所以由于 Store Buffer 的存在导致多核下不能获取到最新值, 所以产生了错误的结果. | 为了解决这个问题出现了写屏障, 写屏障的出现保证屏障两边写的执行是分开的, 也就是说需要先将之前 Store Buffer 中的所有写指令都刷新到缓存之后, 才执行后面的写指令. 具体实现方法是, 先将屏障之前的 Store Buffer 中所有操作都刷新到缓存中, 将屏障后的所有指令操作也同样放到 Store Buffer 中, 不管后续的操作是什么都往里面放, 这样可以提高 CPU 的执行效率, 都通过 Store Buffer 刷新到了缓存中. |
3.3 Invalidate Queue
3.3.1 Invalidate Queue 的作用
看前面的描述, 执行写操作的 CPU1 很聪明啦, 引入了 store buffer 不等待其他 CPU 中的对应缓存行失效就忙别的去了. 而其他 CPU 也不傻, 实际上他们也不会真的把缓存行置为 I 后, 才给 CPU0 发响应. 他们会写入一个 Invalidate Queue(无效化队列), 还没把缓存置为 I 状态就发送响应了.
后续 CPU 会异步扫描 Invalidate Queue, 将缓存置为 I 状态.
3.3.2 Invalidate Queue 引入的问题
和 Store Buffer 不同的是, 在 CPU1 后续读取数据的时候, 会先查 Store Buffer, 再查缓存. 而 CPU0 要读取数据时, 则不会扫描 Invalidate Queue, 所以存在脏读可能.
核 B 在进行判断的时候发现在自己的缓存存在 x, 但此时核 B 刚刚将接收到的其他核对 x 更新的操作放到 Invalidate Queue 中, 所以由于 Invalidate Queue 的存在导致多核下不能获取到最新值, 所以产生了错误的结果.
所以为了解决上面的问题出现了读屏障, 读屏障的出现保证屏障两边读的执行是分开的, 也就是说需要先将之前 Invalidate Queue 中的所有指令都失效之后, 才执行后面的指令, 保证下一次读取共享变量的时候读到的是最新的变量.
通过以上两个操作的结合使用可以保证在多核的情况下对共享变量的修改和读取都是一致的.
3.4 内存屏障 (Memory Barrier)
通过上面对错误情况的分析可以知道, 内存屏障的出现就是为了解决因为 Store Buffer 和 Invalidate Queue 所带来的数据可见性问题, 也就是读和写不能实时更新到其他核的问题. 内存屏障同时还具备强制将 Store Buffer 的内容刷到缓存中, 强制将 Invalidate Queue 中的内容设置完毕的作用.
具体又分为写屏障和读屏障
| 屏障类型 | 描述 |
|---|---|
| 写屏障 (Store Memory Barrier) | 强制将 Store Buffer 中的内容写入到缓存中或者将该指令之后的写操作写入 Store Buffer 直到之前的内容被刷入到缓存中, 也被称之为 smp_wmb |
| 读屏障 (Load Memory Barrier) | 强制将 Invalidate Queue 中的内容处理完毕, 也被称之为 smp_rmb |
| 读写屏障 | 兼备以上两个屏障的功能, 也被称之为 smp_mb |
3.5 有序性
同时保证了在写屏障之前所有的写操作都已经完成, 在读屏障之前所有的无效都已经设置完成, 也就是说保证了程序执行的有序性, 为什么这么说呢, 因为本来在 CPU 执行指令的时候为了提高效率会将写的操作放入到 Store Buffer 中去, 然后去执行其他操作, 这时给我们的感觉就是 CPU 在执行其他操作, 当 Store Buffer 中的操作异步收到其他核返回的信息后, 才执行 Store Buffer 中的操作, 这时执行顺序和本应该执行的顺序是相反的, 这种现象就是指令乱序执行. 而加上内存屏障之后保证异步中的操作执行完毕后才进行其他指令的执行, 在现象上保证了指令执行的有序性.
4 参考资料
-
本作品 / 博文 (AderStep - 紫夜阑珊 - 青伶巷草 Copyright ©2013-2017 ), 由 成坚 (gatieme) 创作.
-
采用 <a rel="license"href="http://creativecommons.org/licenses/by-nc-sa/4.0/"><img alt=" 知识共享许可协议 "style="border-width:0"src="https://i.creativecommons.org/l/by-nc-sa/4.0/88x31.png"/><a rel="license"href="http://creativecommons.org/licenses/by-nc-sa/4.0/"> 知识共享署名 - 非商业性使用 - 相同方式共享 4.0 国际许可协议 进行许可. 欢迎转载、使用、重新发布, 但务必保留文章署名 成坚 gatieme (包含链接: http://blog.csdn.net/gatieme), 不得用于商业目的.
-
基于本文修改后的作品务必以相同的许可发布. 如有任何疑问, 请与我联系.
-
** 转载请务必注明出处, 谢谢, 不胜感激 **
