挥发性围栏演示?
我试图看看栅栏是如何应用的。
我有这个代码(无限期地阻止):
static void Main() { bool complete = false; var t = new Thread(() => { bool toggle = false; while(!complete) toggle = !toggle; }); t.Start(); Thread.Sleep(1000); complete = true; t.Join(); // Blocks indefinitely }
写volatile bool _complete;
解决问题。
获得围栏:
获取栅栏可防止其他读/写在栅栏之前移动;
但是,如果我用箭头↓
来说明它(想想箭头就可以把所有东西推开。)
所以现在 – 代码看起来像:
var t = new Thread(() => { bool toggle = false; while( !complete ) ↓↓↓↓↓↓↓ // instructions can't go up before this fence. { toggle = !toggle; } });
我不明白插图如何代表解决这个问题的解决方案。
我知道while(!complete)
现在读取真实值。 但它如何与complete = true;
到围栏的位置?
complete
挥发性有两个作用:
-
它可以防止C#编译器或抖动进行优化,从而缓存
complete
的值。 -
它引入了一个栅栏,告诉处理器需要对涉及预读取或延迟写入的其他读写的缓存优化进行去优化以确保一致性。
让我们考虑第一个。 抖动完全在其权利范围内,可以看到循环体:
while(!complete) toggle = !toggle;
不会修改complete
,因此在循环开始时complete
任何值都是它将永远具有的值。 因此,允许抖动生成代码,就像您编写的那样
if (!complete) while(true) toggle = !toggle;
或者,更有可能:
bool local = complete; while(local) toggle = !toggle;
使complete
不稳定可以防止这两种优化。
但你要找的是挥发性的第二个影响。 假设您的两个线程在不同的处理器上运行。 每个都有自己的处理器缓存,它是主内存的副本。 让我们假设两个处理器都制作了主存的副本,其中complete
是假的。 当一个处理器的缓存设置为true时,如果complete
不是volatile,那么“切换”处理器不需要注意到这个事实; 它有自己的缓存,其中complete
仍然是假的,每次返回主内存都会很昂贵。
将complete
标记为volatile可消除此优化。 如何消除它是处理器的实现细节。 也许在每次易失性写入时,写入都会写入主存储器,而其他所有处理器都会丢弃其缓存。 或许还有其他策略。 处理器如何选择实现它取决于制造商。
关键在于,无论何时使字段变为易失性然后读取或写入,都会大大破坏编译器,抖动和处理器优化代码的能力。 尽量不要使用volatile字段; 使用更高级别的构造,并且不在线程之间共享内存。
我试图想象这句话:“获取围栏阻止其他读/写被移动到围栏之前……”在围栏之前不应该有什么指令?
考虑指令可能适得其反。 而不是考虑一堆指令只关注读写序列。 其他一切都无关紧要。
假设你有一块内存,其中一部分被复制到两个缓存中。 出于性能原因,您主要读取和写入缓存。 您不时地将缓存与主内存重新同步。 这对读写序列有什么影响?
假设我们希望这发生在一个整数变量上:
- 处理器Alpha将0写入主存储器。
- 处理器Bravo从主存储器读取0。
- 处理器Bravo将1写入主存储器。
- 处理器Alpha从主存储器读取1。
假设真正发生的是这个:
- Processor Alpha将0写入缓存,并与主内存同步。
- Processor Bravo从主内存同步缓存并读取0。
- 处理器Bravo将1写入缓存并将缓存与主内存同步。
- 处理器Alpha从其缓存中读取0 – 过时值。
真正发生的事情与此有什么不同?
- 处理器Alpha将0写入主存储器。
- 处理器Bravo从主存储器读取0。
- 处理器Alpha从主存储器读取0。
- 处理器Bravo将1写入主存储器。
它没有什么不同。 高速缓存将“写入读写入读取”转换为“写入读取写入”。 它会及时向后移动其中一个读取,并且在这种情况下等效地向前移动其中一个写入。
此示例仅涉及对一个位置的两次读取和两次写入,但您可以想象一个场景,其中有许多读取和许多位置的写入。 处理器具有宽广的格度,可以及时向后移动读取并及时向前移动写入。 什么动作的准确规则是合法的,哪些与处理器不同。
栅栏是一种屏障,可防止读取向后移动或写入向前移动。 所以如果我们有:
- 处理器Alpha将0写入主存储器。
- 处理器Bravo从主存储器读取0。
- 处理器Bravo将1写入主存储器。 FENCE HERE。
- 处理器Alpha从主存储器读取1。
无论处理器使用何种缓存策略,现在都不允许将读取4移动到围栏之前的任何点。 同样地,不允许将锦标记3提前移动到栅栏后的任何点。 处理器如何实现围栏取决于它。
像我关于内存障碍的大多数答案一样,我将使用箭头符号,其中↓表示获取栅栏(易失性读取),↑表示释放栅栏(易失性写入)。 请记住,没有其他读或写可以移过箭头(尽管它们可以移过尾部)。
我们先来分析写作线程。 我将假设complete
被声明为volatile
1 。 Thread.Start
, Thread.Sleep
和Thread.Join
将生成完整的栅栏,这就是为什么我在每个调用的两侧都有向上和向下箭头的原因。
↑ // full fence from Thread.Start t.Start(); ↓ // full fence from Thread.Start ↑ // full fence from Thread.Sleep Thread.Sleep(1000); ↓ // full fence from Thread.Sleep ↑ // release fence from volatile write to complete complete = true; ↑ // full fence from Thread.Join t.Join(); ↓ // full fence from Thread.Join
这里要注意的一件重要事情是, Thread.Join
调用阻止了写入complete
进一步向下浮动。 这里的效果是写入立即提交到主存储器。 complete
本身的波动性不会导致它被刷新到主存。 它是Thread.Join
调用以及它生成的导致该行为的内存屏障。
现在我们将分析阅读线程。 由于while循环,这可视化有点棘手,但让我们从这开始。
bool toggle = false; register1 = complete; ↓ // half fence from volatile read while (!register1) { bool register2 = toggle; register2 = !register2; toggle = register2; register1 = complete; ↓ // half fence from volatile read }
如果我们解开循环,也许我们可以更好地想象它。 为简洁起见,我只展示前4次迭代。
if (!register1) return; register2 = toggle; register2 = !register2; toggle = register2; register1 = complete; ↓ if (!register1) return; register2 = toggle; register2 = !register2; toggle = register2; register1 = complete; ↓ if (!register1) return; register2 = toggle; register2 = !register2; toggle = register2; register1 = complete; ↓ if (!register1) return; register2 = toggle; register2 = !register2; toggle = register2; register1 = complete; ↓
现在我们已经解开了循环,我想你可以看到complete
读取的任何潜在运动将如何受到严重限制。 2是的,它可以被编译器或硬件稍微改变一下,但它几乎被锁定在每次迭代时被读取。 请记住, complete
的读取仍然可以自由移动,但它创建的栅栏不会随之移动。 围栏被锁定到位。 这就是导致这种行为通常被称为“新鲜阅读”的原因。 如果在complete
省略了volatile
,则编译器可以自由地使用称为“提升”的优化技术。 这就是可以在循环外提取或提取内存地址的读取。 在没有volatile
的情况下,优化将是合法的,因为完成的所有读取都将被允许浮动(或提升),直到它们最终都在循环之外。 此时,编译器会在启动循环之前将它们全部合并为一次性读取。 3
我现在总结一些重要的观点。
- 调用
Thread.Join
会导致写入complete
以提交到主内存,以便工作线程最终将其提取。complete
的波动性与写作线程无关(这对大多数人来说可能是令人惊讶的)。 - 它是由
complete
的易失性读取生成的获取栅栏,它阻止读取被提升到循环之外,从而产生“新鲜读取”行为。 读取线程中complete
的波动性产生巨大差异(这对大多数人来说可能是显而易见的)。 - “提交写入”和“新读取”不会直接导致易失性读写。 但是,它们是偶然发生的间接后果,特别是在循环的情况下。
1 在写入线程上将complete
标记为volatile
是不必要的,因为x86写入已经具有volatile语义,但更重要的是因为它创建的fence不会导致“commit committed”行为。
2 请记住,读取和写入可以通过箭头的尾部移动,但箭头已锁定到位。 这就是为什么你不能冒出循环之外的所有读取。
3 提升优化还必须确保线程的实际行为与程序员最初的预期一致。 在这种情况下,该要求很容易满足,因为编译器可以很容易地看到在该线程上永远不会写入complete
。