C# 是“大众化”吗;“易失性轮询标志”;图案破了?

C# 是“大众化”吗;“易失性轮询标志”;图案破了?,c#,.net,multithreading,volatile,memory-barriers,C#,.net,Multithreading,Volatile,Memory Barriers,假设我想在线程之间使用一个布尔状态标志进行协作取消。(我意识到最好使用CancellationTokenSource;这不是问题的重点。) 问题:如果我在另一个线程上在0s调用Start(),在3s调用Stop(),循环是否保证在当前迭代结束时在大约10s退出 我所看到的绝大多数资料表明,上述方法应该如预期的那样有效;见: ; ; ; ; . 但是,volatile仅在读取时生成获取围栏,在写入时生成释放围栏: 易失性读取具有“获取语义”;也就是说,在指令序列中,它保证发生在对内存的任何引

假设我想在线程之间使用一个布尔状态标志进行协作取消。(我意识到最好使用
CancellationTokenSource
;这不是问题的重点。)

问题:如果我在另一个线程上在0s调用
Start()
,在3s调用
Stop()
,循环是否保证在当前迭代结束时在大约10s退出

我所看到的绝大多数资料表明,上述方法应该如预期的那样有效;见: ; ; ; ; .

但是,
volatile
仅在读取时生成获取围栏,在写入时生成释放围栏:

易失性读取具有“获取语义”;也就是说,在指令序列中,它保证发生在对内存的任何引用之前,而对内存的任何引用发生在它之后。 易失性写入具有“释放语义”;也就是说,它保证在指令序列中写入指令之前的任何内存引用之后发生。 ()

因此,无法保证易失性写入和易失性读取不会(看起来)被交换,正如所观察到的那样。因此,在当前迭代结束后,后台线程可能会继续读取过时的
\u stopping
(即
false
)值。具体地说,如果我在0s调用
Start()
,在3s调用
Stop()
,后台任务可能不会像预期的那样在10s终止,而是在20s或30s终止,或者根本不会终止

基于此,这里有两个问题。首先,易失性读取将被限制从内存中刷新字段(抽象地说),而不是在当前迭代结束时,而是在后续迭代结束时,因为获取围栏发生在读取本身之后。第二,更关键的是,没有任何东西可以强制易失性写入将值提交到内存,因此无法保证循环将永远终止

考虑以下顺序流:

Time   |     Thread 1                     |     Thread 2
       |                                  |
 0     |     Start() called:              |        read value of _stopping
       |                                  | <----- acquire-fence ------------
 1     |                                  |     
 2     |                                  |             
 3     |     Stop() called:               |             ↑
       | ------ release-fence ----------> |             ↑
       |        set _stopping to true     |             ↑
 4     |             ↓                    |             ↑
 5     |             ↓                    |             ↑
 6     |             ↓                    |             ↑
 7     |             ↓                    |             ↑
 8     |             ↓                    |             ↑
 9     |             ↓                    |             ↑
 10    |             ↓                    |        read value of _stopping
       |             ↓                    | <----- acquire-fence ------------
 11    |             ↓                    |    
 12    |             ↓                    |             
 13    |             ↓                    |             ↑
 14    |             ↓                    |             ↑
 15    |             ↓                    |             ↑
 16    |             ↓                    |             ↑
 17    |             ↓                    |             ↑
 18    |             ↓                    |             ↑
 19    |             ↓                    |             ↑
 20    |                                  |        read value of _stopping
       |                                  | <----- acquire-fence ------------
Time |线程1 |线程2
|                                  |
0 | Start()调用:|读取_stopping的值
|                                  |  |             ↑
|将“停止”设置为“真”↑
4     |             ↓                    |             ↑
5     |             ↓                    |             ↑
6     |             ↓                    |             ↑
7     |             ↓                    |             ↑
8     |             ↓                    |             ↑
9     |             ↓                    |             ↑
10    |             ↓                    |        读取_停止的值
|             ↓                    | 
如果在另一个线程上,我在0s调用
Start()
,在3s调用
Stop()
,循环是否保证在当前迭代结束时以大约10s的速度退出

是的,7秒绝对足够一个线程感知到
\u stopping
变量的变化

近乎正式的解释 对于提供任何类型的可见性屏障(内存顺序)的每个变量,任何语言的规范都应提供以下保证:

finitBOUND期间,将在其他线程中观察到来自一个线程的变量的任何变化(使用特殊的内存顺序)

如果没有这个保证,即使是变量的内存顺序特征也是无用的

C#规范肯定提供了关于volatile变量的这种保证,但我找不到对应的文本

注意,关于有限时间的这种保证与记忆顺序保证(“获取”、“释放”等)无关,不能从障碍和记忆顺序的定义中推断出它

正式非正式解释 什么时候说

我在3s时调用
Stop()

一种暗示是,存在一些可见的效果(例如,打印到终端中的信息),这允许他声明大约3s的时间戳(因为print语句是在
Stop()
之后发出的)

随着C#规范的优雅演奏(“10.10执行命令”):

执行应继续进行,以便在关键执行点保留每个执行线程的副作用。副作用定义为对易失性字段的读取或写入、对非易失性变量的写入、写入 并引发异常。应保留这些副作用顺序的关键执行点是对易失性字段(§17.4.3)、锁定语句(§15.12)和 线程的创建和终止

假设打印是一个关键执行点(很可能它使用锁),您可以确信,此时另一个线程可以看到对
\u stopping
volatile变量的赋值,因为它会产生副作用

非正式解释 虽然编译器允许在代码中向前移动volatile变量的赋值,但它不能无限期地这样做:

  • 赋值不能在函数调用后移动,因为编译器不能假定函数体的任何内容

  • 如果分配在一个周期内执行,则应在下一个周期的另一次分配之前完成

  • 虽然可以想象代码具有1000个连续的简单赋值(到其他变量),因此可以对1000条指令进行不稳定赋值,但编译器只是执行这种不稳定赋值。即使是这样,在现代CPU上执行1000条简单指令所需的时间也不超过几微秒

CPU的角度来看,情况是simp
Time   |     Thread 1                     |     Thread 2
       |                                  |
 0     |     Start() called:              |        read value of _stopping
       |                                  | <----- acquire-fence ------------
 1     |                                  |     
 2     |                                  |             
 3     |     Stop() called:               |             ↑
       | ------ release-fence ----------> |             ↑
       |        set _stopping to true     |             ↑
 4     |             ↓                    |             ↑
 5     |             ↓                    |             ↑
 6     |             ↓                    |             ↑
 7     |             ↓                    |             ↑
 8     |             ↓                    |             ↑
 9     |             ↓                    |             ↑
 10    |             ↓                    |        read value of _stopping
       |             ↓                    | <----- acquire-fence ------------
 11    |             ↓                    |    
 12    |             ↓                    |             
 13    |             ↓                    |             ↑
 14    |             ↓                    |             ↑
 15    |             ↓                    |             ↑
 16    |             ↓                    |             ↑
 17    |             ↓                    |             ↑
 18    |             ↓                    |             ↑
 19    |             ↓                    |             ↑
 20    |                                  |        read value of _stopping
       |                                  | <----- acquire-fence ------------