首页 > > > LINUX内核内存屏障(中)
点击上方“蓝字”可以关注我们哦

|本文来自ChinaUnix博客
|作者:kouu
|原文链接:http://blog.chinaunix.net/uid-9918720-id-1640912.html
内存屏障举例
首先, 写屏障用作部分有序的STORE操作. 考虑如下的操作序列:
CPU 1
=======================
STORE A = 1
STORE B = 2
STORE C = 3
<写屏障>
STORE D = 4
STORE E = 5
这个操作序列会按顺序被提交到内存一致性系统,而系统中的其他组件可能看到{STORE A, STORE B, STORE C }的组合出现在{ STORE D, STORE E }的组合之前, 而组合内部可能乱序:

其次, 数据依赖屏障用作部分有序的数据依赖LOAD操作. 考虑如下的操作序列:

没有干预的话, CPU 1的操作被CPU 2感知到的顺序是随机的, 尽管CPU 1执行了写屏障:

在上面的例子中, CPU 2看到的B的值是7, 尽管对*C(值应该是B)的LOAD发生在对C的LOAD之后.但是, 如果一个数据依赖屏障被放到CPU 2的LOAD C和LOAD *C(假设值是B)之间:

那么下面的情况将会发生:

第三, 读屏障用作部分有序的LOAD操作. 考虑如下事件序列:

没有干预的话, CPU 1的操作被CPU 2感知到的顺序是随机的, 尽管CPU 1执行了写屏障:

但是, 如果一个读屏障被放到CPU 2的LOAD B和LOAD A之间:

那么CPU 1所施加的部分有序将正确的被CPU 2所感知:

为了更全面地说明这一点, 考虑一下如果代码在读屏障的两边都有一个LOAD A的话, 会发生什么:

尽管两次LOAD A都发生在LOAD B之后, 它们也可能得到不同的值:

但是也可能CPU 2在读屏障结束之前就感知到CPU 1对A的更新:

这里只保证, 如果LOAD B得到的值是2的话, 第二个LOAD A能得到的值是1. 对于第一个LOAD A是不存在这样的保证的; 它可能得到A的值是0或是1.
读内存屏障与内存预取
许多CPU会对LOAD操作进行预取: 作为性能优化的一种手段, 当CPU发现它们将要从内存LOAD一个数据时, 它们会寻找一个不需要使用总线来进行其他LOAD操作的时机, 用于LOAD这个数据 - 尽管他们的指令执行流程实际上还没有到达该处LOAD的地方. 实际上, 这可能使得某些LOAD指令能够立即完成, 因为CPU已经预取到了所需要LOAD的值.
这也可能出现CPU实际上用不到这个预取的值的情况 - 可能因为一个分支而避开了这次LOAD - 在这样的情况下, CPU可以丢弃这个值或者干脆就缓存它以备后续使用.
考虑如下场景

如果在第二个LOAD之前放一个读屏障或数据依赖屏障:

这在一定程度上将迫使预取所获得的值, 根据屏障的类型而被重新考虑. 如果没有新的更新操作作用到已经被预取的内存地址, 则预取到的值就会被使用:

但是, 如果存在一个来自于其他CPU的更新或失效, 那么预取将被取消, 并且重新载入值:

内核中显式的内存屏障
linux内核拥有各式各样的屏障, 作用在不同层次上:
(*) 编译优化屏障.
(*) CPU内存屏障.
(*) MMIO写屏障.
编译优化屏障
Linux内核有一个显式的编译器屏障函数, 能够防止编译器优化将访存操作从它的任一侧移到另一侧:
barrier();
这是一个通用屏障 - 弱类型的编译优化屏障并不存在.
编译优化屏障并不直接作用到CPU, CPU依然可以按其意愿乱序执行代码.
(译注: 既然编译优化屏障并不能限制CPU的乱序访存, 那么单纯的编译优化屏障能起到什么作用呢?
以内核中的preempt_disable宏为例:
#define preempt_disable() \
do { \
inc_preempt_count(); \
barrier(); \
} while (0)
preempt_disable()和对应的preempt_enable()之间的代码是禁止内核抢占的, 通过对当前进程的preempt_count进行++, 以标识进入禁止抢占状态(preempt_count==0时可抢占). 这里在对preempt_count自增之后, 使用了编译优化屏障.
如果不使用屏障, 本该在不可抢占状态下执行的指令可能被重排到preempt_count++之前(因为这些指令基本上不会对preempt_count有依赖). 而抢占可能是由中断处理程序来触发的,
在那些应该在不可抢占状态下执行的指令被执行之后, preempt_count++之前, 可能发生中断.中断来了, preempt_count的值还是0, 于是进程可能会被错误的抢占掉.
究其原因, 是因为编译器看到的上下文依赖逻辑是静态的, 它不知道这段代码跟中断处理程序还存在依赖关系, 所以没法限制自己的乱序行为. 所以, 这里的编译优化屏障是必要的.
但是, 仅仅使用编译优化屏障就足够了么? 是的, 因为preempt_count这个变量是属于当前进程的, 仅会被当前CPU访问.
CPU乱序可能导致后面应该在禁止抢占状态下执行的指令先于preempt_disable()执行完, 但是没有关系, 因为前面也提到过, CPU是"顺序流入, 乱序流出"的, 就算后面的指令先执行完, preempt_disable()也必定已经存在于流水线中了, CPU知道preempt_count变量将要被修改. 而触发抢占的代码肯定会检查preempt_count是否为0, 而这里的检查又将依赖于preempt_disable()的修改结果, 必定在preempt_disable()完成之后才会进行.
究其原因, 是因为CPU看到的上下文依赖逻辑是动态的, 它不管指令是来自于普通的处理流程, 还是来自于中断处理程序, 只要指令存在依赖, 它都能发现. 所以, 对于类似这样的只被一个CPU所关注的内存访问, CPU的乱序访存并不会存在问题. )
CPU内存屏障
Linux内核有8种基本的CPU内存屏障:(译注: 这里所说有SMP屏障是只在SMP环境下才生效的屏障, 而强制屏障则是不管在不在SMP环境下都生效的屏障. 这里所谓的SMP环境, 确切的说, 其实是内核的编译选项指定为SMP的情况, 并不是指实际运行内核的机器的环境. 不过既然编译选项指定了SMP环境, 那么编译生成的内核也基本上将会运行在SMP环境. 下面提到的UP环境亦是同理.)
除了数据依赖屏障之外, 所有的内存屏障都隐含了编译优化屏障的功能. 数据依赖屏障不对编译器输出的代码顺序造成任何额外的影响.
注: 在存在数据依赖关系的情况下, 编译器预期会将LOAD指令按正确的顺序输出(例如, 在`a[b]`语句中, 对b的load必须放在对a[b]的load之前), 但在C规范下, 并不保证编译器不去预测B的值(比如预测它等于1), 于是先load a再load b(比如, tmp = a[1]; if (b != 1) tmp = a[b];). 编译器在load a[b]之后又重新load b, 也可能会存在问题, 因为b拥有比a[b]更新的副本. 这些问题的解决尚未达成共识, 然而内核中的ACCESS_ONCE宏是解决问题的一个好的开始.
在UP系统中, SMP内存屏障将退化成编译器优化屏障, 因为它假定CPU能够保证自身的一致性, 并本身就能以正确的顺序处理重叠的内存访问.
[!] 注意, SMP内存屏障必须用于控制在SMP系统中的共享内存的引用顺序, 而使用锁也能够满足需求.
强制屏障不应该用来控制SMP的影响, 因为强制屏障会过多地增加UP系统的开销. 不过, 在使用MMIO来访问松散属性的IO内存窗口时, 强制屏障可以用来控制这些访存的影响. (译注:这里所指的内存窗口, 是假定对于CPU来说, 可以设置属于不同区间的内存地址拥有不同的属性. 这些属性可以指示一个内存段是否可以松散访问, 即乱序访问.)
强制屏障即使在非SMP环境下也可能需要, 因为它们可以通过禁止编译器和CPU的乱序访存, 从而影响设备感知到内存操作的顺序.
还有一些更高级的屏障函数:
(*) set_mb(var, value)
该函数将value赋值到var变量中, 然后取决于具体编译参数下的函数实现, 可能在之后插入一个内存屏障. 在UP系统中, 它不能保证会插入编译优化屏障以外的其他屏障.
(*) smp_mb__before_atomic_dec();
(*) smp_mb__after_atomic_dec();
(*) smp_mb__before_atomic_inc();
(*) smp_mb__after_atomic_inc();
它们跟一些进行原子操作的函数配合使用, 这些函数进行了原子加法, 减法, 自增和自减, 而又不将原子变量的值返回, 特别被用于引用计数. 这些原子操作本身并不隐含内存屏障. (译注: 像这样被操作的原子变量, 多半是孤立而没有数据依赖的. 如果有数据依赖, 那么依赖关系将在一定程度上限制CPU的乱序. 否则, CPU的乱序就完全要靠内存屏障来限制了.)
举个例子, 考虑如下代码段, 它将object标识为已删除, 然后将其引用计数自减:
obj->dead = 1;
smp_mb__before_atomic_dec();
atomic_dec(&obj->ref_count);
这样可以确保设置删除标记在自减引用计数之前生效.
(*) smp_mb__before_clear_bit(void);
(*) smp_mb__after_clear_bit(void);
它们的用途类似于原子加减的屏障. 它们通常是跟一些进行按位解锁操作的函数配合使用, 必须小心, 因为位操作本身也并不隐含内存屏障.
考虑这样一个场景, 程序通过清除锁定位来实施一些解锁性质的操作. clear_bit()函数需要像这样的屏障:
smp_mb__before_clear_bit();
clear_bit( ... );
这样可以防止应该在锁定位被清除之前发生的内存操作漏到位清除之后去(译注: 注意UNLOCK的屏障作用就是要保证它之前的访存操作一定先于它而完成).
MMIO写屏障
对于内存映射IO的写操作, Linux内核还有一个特别的屏障:
mmiowb();
这是一个强制写屏障的变体, 能够将弱有序的IO内存窗口变成部分有序. 它的作用可能超出CPU与硬件的界面, 从而影响到许多层次上的硬件设备.
内核中隐式的内存屏障
Linux内核中有一些其他的方法也隐含了内存屏障, 包括锁和调度方法.
这个范围是一个最低限度的保证; 一些特定的体系结构可能提供更多的保证, 但是在特定体系结构的代码之外, 不能依赖于它们.
锁相关函数
Linux内核有很多锁结构:
(*) spin locks
(*) R/W spin locks
(*) mutexes
(*) semaphores
(*) R/W semaphores
(*) RCU
在所有情况下, 它们都是LOCK操作和UNLOCK操作的变种. 这些操作都隐含一定的屏障:
(1) LOCK操作所隐含的:
在LOCK操作之后出现的内存操作, 一定在LOCK操作完成之后才会完成.而在LOCK操作之前出现的内存操作, 可能在LOCK操作完成之后才完成.
(2) UNLOCK操作所隐含的:
在UNLOCK操作之前出现的内存操作,一定在UNLOCK操作完成之前完成.而在UNLOCK操作之后出现的内存操作, 可能在LOCK操作完成之前就完成了.
(3) LOCK操作+LOCK操作所隐含的:
在某个LOCK操作之前出现的所有LOCK操作都将在这个LOCK之前完成.
(4) LOCK操作+UNLOCK操作所隐含的:
在UNLOCK操作之前出现的所有LOCK操作都将在这个UNLOCK之前完成.在LOCK操作之前出现的所有UNLOCK操作都将在这个LOCK之前完成.
(5) LOCK失败所隐含的:
某些变种的LOCK操作可能会失败, 比如可能因为不能立刻获得锁(译注: 如try_lock操作), 再比如因为在睡眠等待锁变为可用的过程中接收到了未被阻塞的信号(译注: 如semaphores的down_interruptible操作). 失败的锁操作不隐含任何屏障.
因此, 根据(1), (2)和(4), 一个无条件的LOCK跟在一个UNLOCK之后, 锁相当于一个完整的屏障, 而一个UNLOCK跟在一个LOCK之后并非如此.
[!] 注意: LOCK和UNLOCK只是单向的屏障, 其结果是, 临界区之外的指令可能会在临界区中执行.
一个UNLOCK跟在一个LOCK之后并不能认为是一个完整的屏障, 因为出现在LOCK之前的访存可能在LOCK之后才执行, 而出现在UNLOCK之后的访存可能在UNLOCK之前执行, 这两次访存可能会交叉:
*A = a;
LOCK
UNLOCK
*B = b;
可能表现为:
LOCK, STORE *B, STORE *A, UNLOCK
锁和信号量在UP环境下可能不提供顺序保证, 在这种情况下不能被认作是真正的屏障 - 特别是对于IO访问 - 除非结合中断禁用操作.
例如, 考虑如下代码:
*A = a;
*B = b;
LOCK
*C = c;
*D = d;
UNLOCK
*E = e;
*F = f;
如下的事件序列都是可接受的:
LOCK, {*F,*A}, *E, {*C,*D}, *B, UNLOCK
[+] 注意, {*F,*A} 代表一次合并访问.
但是下面的序列都不可接受:
禁止中断函数
禁止中断(类似于LOCK)和启用中断(类似于UNLOCK)的函数只会起到编译优化屏障的作用. 所以, 如果在这种情况下需要使用内存或IO屏障, 必须采取其他手段.
睡眠唤醒函数
在一个全局事件标记上的睡眠和唤醒可以被看作是两条数据之间的交互: 正在等待事件的进程的状态, 和用于表示事件发生的全局数据. 为了确保它们按正确的顺序发生, 进入睡眠的原语和发起唤醒的原语都隐含了某些屏障.
首先, 睡眠进程通常执行类似于如下的代码序列:
for (;;) {
set_current_state(TASK_UNINTERRUPTIBLE);
if (event_indicated)
break;
schedule();
}
set_current_state()在它更改进程状态之后会自动插入一个通用内存屏障:
CPU 1
===============================
set_current_state();
set_mb();
STORE current->state
<通用屏障>
LOAD event_indicated
set_current_state()可能被包装在以下函数中:
prepare_to_wait();
prepare_to_wait_exclusive();
因此这些函数也隐含了一个在设置了进程状态之后的通用内存屏障. 以上的各个函数又被包装在其他一些函数中, 所有这些包装函数都相当于在对应的位置插入了内存屏障:
wait_event();
wait_event_interruptible();
wait_event_interruptible_exclusive();
wait_event_interruptible_timeout();
wait_event_killable();
wait_event_timeout();
wait_on_bit();
wait_on_bit_lock();
其次, 用作唤醒操作的代码通常是下面这样:
event_indicated = 1;
wake_up(&event_wait_queue);
或:
event_indicated = 1;
wake_up_process(event_daemon);
类似wake_up()的函数会隐含一个写内存屏障. 当且仅当它们的确唤醒了某个进程时. 屏障出现在进程的睡眠状态被清除之前, 也就是在设置唤醒事件标记的STORE操作和将进程状态修改为TASK_RUNNING的STORE操作之间:

可用的唤醒函数包括:
complete();
wake_up();
wake_up_all();
wake_up_bit();
wake_up_interruptible();
wake_up_interruptible_all();
wake_up_interruptible_nr();
wake_up_interruptible_poll();
wake_up_interruptible_sync();
wake_up_interruptible_sync_poll();
wake_up_locked();
wake_up_locked_poll();
wake_up_nr();
wake_up_poll();
wake_up_process();
[!] 注意, 对于唤醒函数读写事件之前, 睡眠函数调用set_current_state()之后的那些STORE操作, 睡眠和唤醒所隐含的内存屏障并不保证它们的顺序. 比如说, 如果睡眠函数这样做:
set_current_state(TASK_INTERRUPTIBLE);
if (event_indicated)
break;
__set_current_state(TASK_RUNNING);
do_something(my_data);
而唤醒函数这样做:
my_data = value;
event_indicated = 1;
wake_up(&event_wait_queue);
睡眠函数并不能保证在看到my_data的修改之后才看到event_indicated的修改. 在这种情况下, 两边的代码必须在对my_data访存之前插入自己的内存屏障. 因此上述的睡眠函数应该这样做:
set_current_state(TASK_INTERRUPTIBLE);
if (event_indicated) {
smp_rmb();
do_something(my_data);
}
而唤醒函数应该这样做:
my_data = value;
smp_wmb();
event_indicated = 1;
wake_up(&event_wait_queue);
其他函数
其他隐含了屏障的函数:
(*) schedule()和类似函数隐含了完整的内存屏障.(译注: schedule函数完成了进程的切换, 它的两边可能对应着两个不同的上下文. 如果访存操作跨越schedule函数而进行了乱序, 那么基本上可以肯定是错误的.)

我知道一种学习
○
于坚




下载我们APP
Android和IOS均可使用

这里有最新鲜的干货
ITPUB|ChinaUnix|IT168


原文作者:;如有侵权,请联系管理员删除。