受欢迎的“挥发性民意调查旗帜”模式是否被打破?

时间:2017-05-22 20:45:13

标签: c# .net multithreading volatile memory-barriers

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

private volatile bool _stopping;

public void Start()
{
    var thread = new Thread(() =>
    {
        while (!_stopping)
        {
            // Do computation lasting around 10 seconds.
        }
    });

    thread.Start();
}

public void Stop()
{
    _stopping = true;
}

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

我见过的绝大多数消息来源表明上述内容应该按预期工作;看到: MSDN; Jon Skeet; Brian Gideon; Marc Gravell; Remus Rusanu

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

  

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

因此,无法保证不会(似乎)交换易失性写入和易失性读取,如Joseph Albahari所观察到的那样。因此,后台线程可能会在当前迭代结束后继续读取_stopping的陈旧值(即false)。具体来说,如果我在0s调用Start()而在3s调用Stop(),则后台任务可能不会按预期在10s终止,而是在20s,或30s,或者根本不会终止。

基于acquire and release semantics,这里有两个问题。首先,易失性读取将被约束为从内存刷新字段(抽象地说)不是在当前迭代结束时,而是在后续结束时刷新字段,因为获取栅栏发生< em>在读取之后。其次,更重要的是,没有什么可以强制volatile写入将值提交给内存,因此无法保证循环将永远终止。

考虑以下顺序流程:

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 ------------

最重要的部分是内存栅栏,标有--><--,代表线程同步点。 _stopping的易失性读取只能(似乎)最多移动到其线程的先前获取范围。但是,易失性写入可以(似乎)无限期地向下移动,因为在其线程上没有其他释放栅栏。换句话说,写入_stopping及其任何读取之间没有“synchronizes-with”(“发生前”,“是 - 可见 - ”)关系。

P.S。我知道MSDN对volatile关键字提供了非常强大的保证。但是,专家的共识是MSDN不正确(并没有得到ECMA规范的支持):

  

MSDN文档指出使用volatile关键字“确保始终在字段中存在最新值”。这是不正确的,因为正如我们在前面的例子中看到的那样,可以重新排序写入后跟读取。 (Joseph Albahari

1 个答案:

答案 0 :(得分:1)

  

如果我在另一个线程上调用Start()为0,Stop()为3,那么循环是否保证在当前迭代结束时以10s左右退出?

是的,对于一个线程来说,只需7秒即可完成_stopping变量的更改。

几乎正式的解释

对于提供任何类型可见性障碍(内存顺序)的每个变量,任何语言的规范都应该提供以下保证:

  

finit <期间,其他线程中观察来自一个线程的变量(具有特殊内存顺序)的更改 / strong>和有界一段时间。

如果没有这个保证,即使变量的记忆顺序功能也没用。

C#规范明确提供了关于 volatile 变量的保证,但我找不到相应的文本。

请注意,关于finit时间的这种保证与记忆订单保证(“获取”,“发布”等)无关,并且无法从障碍的定义中推断 和记忆订单。

正式 - 非正式解释

当说

  

我在3点打电话给Stop()

一个暗示,有一些可见效果(例如,打印到终端的信息),这允许他声称大约3s时间戳(因为打印声明已经发布之后 Stop())。

使用C#规范优雅地播放(“10.10执行顺序”):

  

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

假设打印是关键执行点(可能它使用锁定),您可能会确信此时将_stopping volatile变量指定为副作用可见到另一个线程,它检查给定的变量。

非正式解释

虽然允许编译器在代码中向前移动 volatile 变量的分配,但它无法无限期

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

  • 如果在一个循环内执行分配,则应在下一个循环中的另一个分配之前完成分配。

  • 虽然可以想象具有1000个连续简单赋值(对于其他变量)的代码,因此可以针对1000个指令来定义易失性赋值,编译器只是执行这样的deffering。即使这样,在现代CPU上执行1000条简单指令也不会超过几微秒。

CPU 的一侧,情况更简单:没有CPU会比有限数量的指令更多地分配给内存单元。

总计 volatile 变量的分配只能在非常有限的指令数上进行。