为什么在布尔条件变量的波动缺失时,这个程序不会进入无限循环?

4
我希望了解何时需要将变量声明为volatile。因此,我编写了一个小程序,并期望由于条件变量缺少易失性而进入无限循环。但是,没有使用volatile关键字,它没有进入无限循环,而且正常工作。
两个问题:
1. 在下面的代码列表中,我应该更改什么,以便绝对需要使用volatile?
2. 如果C#编译器看到变量从不同的线程访问,它是否足够聪明,会将变量视为易失性?
以上引发了更多的问题:
a. volatile只是一个提示吗?
b. 在多线程上下文中,何时应将变量声明为volatile?
c. 对于线程安全类,所有成员变量都应该声明为volatile吗?那样是否过度?
代码清单(易失性而非线程安全是重点):
class Program
{
    static void Main(string[] args)
    {
        VolatileDemo demo = new VolatileDemo();
        demo.Start();

        Console.WriteLine("Completed");
        Console.Read();
    }
}

    public class VolatileDemo
    {
        public VolatileDemo()
        {
        }

        public void Start()
        {
            var thread = new Thread(() =>
            {
                Thread.Sleep(5000);
                stop = true;
            });

            thread.Start();

            while (stop == false)
                Console.WriteLine("Waiting For Stop Event");
        }

        private bool stop = false;
    }

谢谢。

1
我从未见过正确使用volatile的情况 - 我甚至不完全确定volatile的正确用法。我认为有一些微妙之处几乎每个人都会错过。但我认为你的问题之一是执行设置的线程已经结束。当线程完成时,我认为它需要刷新在寄存器中存储的任何值,因为它不再执行任何操作。 - Russell Troywest
它是否已经写入控制台“等待停止事件”? - ediblecode
1
https://dev59.com/OXVC5IYBdhLWcg3w9GPM - jeroenh
@RussellTroywest:我理解执行设置的线程最终(可能是同时或者在线程结束时)会更新内存中的值。我原本期望主线程总是从寄存器中读取(至少在没有使用volatile的情况下),而不是每次评估条件时都从内存中读取。 - Anand Patel
@user1016253:是的,它在控制台上多次写入了“等待停止事件”。 - Anand Patel
显示剩余3条评论
5个回答

4
首先,Joe Duffy表示“volatile是有害的”-这对我来说足够好了。
如果您确实想考虑volatile,则必须考虑内存栅栏和编译器、JIT和CPU的优化。
在x86上,写入是释放栅栏,这意味着您的后台线程将将true值刷新到内存中。
因此,您要寻找的是在循环谓词中缓存false值。编译器或JIT可能会优化谓词并仅评估一次,但我猜它不会为类字段的读取执行此操作。由于调用了Console.WriteLine,CPU不会缓存false值,其中包括一个栅栏。
此代码需要volatile,如果没有Volatile.Read,它将永远不会终止:
static void Run()
{
    bool stop = false;

    Task.Factory.StartNew( () => { Thread.Sleep( 1000 ); stop = true; } );

    while ( !stop ) ;
}

@Nicholas Butler:我尝试了你的代码,它能够正确终止而不会导致无限循环。 - Anand Patel
你是用启用了优化(Release 构建)并且没有连接调试器的方式运行它了吗? - Nick Butler
我的错误。你是对的。在没有附加调试器的情况下,它会在发布模式下进入无限循环。你的解决方案创建了一个包含方法和停止字段的匿名类。从概念上讲,它与我的代码没有什么不同。我需要彻底理解你的回复。非常感谢。 - Anand Patel

3
我不是C#并发方面的专家,但据我所知,您的期望是不正确的。从不同的线程修改非易失性变量并不意味着该更改永远不会对其他线程可见。只是意味着不能保证何时(以及是否)会发生。在您的情况下确实发生了更改(顺便问一下,您运行程序的次数有多少次?),可能是由于完成线程根据@Russell的评论刷新其更改。但在现实生活中的设置中——涉及更复杂的程序流、更多的变量、更多的线程——更新可能会在5秒钟后发生,或者—也许在一千个案例中—可能不会发生
因此,仅运行您的程序一次——甚至运行一百万次——而没有观察到任何问题,只提供统计学上的证明,而不是绝对的证明。"缺乏证据并不是证据缺乏"

2
尝试像这样重写它:
    public void Start()
    {
        var thread = new Thread(() =>
        {
            Thread.Sleep(5000);
            stop = true;
        });

        thread.Start();

        bool unused = false;
        while (stop == false)
            unused = !unused; // fake work to prevent optimization
    }

请确保您运行的是 Release 模式而不是 Debug 模式。在 Release 模式下,应用了优化,实际上会导致代码在没有 volatile 的情况下失败。
关于 volatile 的一些说明:
我们都知道,在程序生命周期中,有两个不同的实体可以应用变量缓存和/或指令重排序的优化:编译器和 CPU。
这意味着,你编写代码的方式与它实际执行的方式可能存在很大差异,因为指令可能相对于彼此进行重新排序,或者读取可能被缓存,因为编译器认为这是“提高速度”的一种方式。
大多数情况下这很好,但有时(特别是在多线程上下文中)会引起麻烦,就像在这个例子中看到的那样。为了允许程序员手动防止这种优化,引入了内存屏障,它们是特殊的指令,其作用是防止指令(只读、只写或两者)相对于屏障本身进行重排序,并且还强制使 CPU 缓存中的值无效,以便每次都需要重新读取(这是我们在上面的场景中想要的)。
虽然你可以通过 Thread.MemoryBarrier() 指定影响所有变量的完整屏障,但如果你只需要影响一个变量,那么这几乎总是过度使用。因此,为了使单个变量始终跨线程保持最新状态,你可以使用 volatile 仅为该变量引入读/写屏障。

我尝试了你建议的方法,但仍然不起作用。已经在调试和发布模式下尝试过了。 - Anand Patel
@Anand Patel:非常奇怪,因为我刚刚测试了一下,它在我这里可以工作。您能否请检查您的项目属性中是否启用了“优化代码”?您正在使用哪个版本的.NET? - Tudor
我的错误。我现在在没有调试器附加的情况下以发布模式运行,结果进入了无限循环。非常感谢。 - Anand Patel
+1. 需要再次仔细查看所有回答,以选择最佳答案;)。您可能还想回答其他包含在问题中的子问题。 - Anand Patel
啊,抱歉,我没有看到其他更理论性的关于volatile的问题。我会提供一些自己的见解。 - Tudor
Tudor:将您的回复标记为答案,因为它接近我的期望。同样感谢Peter Torok、Nicholas Butler和其他人给出的回复。 - Anand Patel

1

volatile关键字是告诉编译器不要在该变量上进行单线程优化的消息。 这意味着该变量可能会被多个线程修改。 这使得变量值在读取时最为“新鲜”。

你在这里粘贴的代码片段是使用volatile关键字的一个很好的例子。 没有使用'volatile'关键字时,该代码可以正常工作并不令人惊讶。但是,当有更多线程运行并且您对标志值执行更复杂的操作时,它可能会表现得更加不可预测。

只有那些可能被多个线程修改的变量才需要声明为volatile。 我不确定C#中的情况,但我认为不能在那些由读写操作(例如递增)修改的变量上使用volatile。Volatile在更改值时不使用锁定机制。 因此,在volatile上设置标志(如上所示)是可以的,递增变量则不行-您应该使用同步/锁定机制。


+1。非常清晰的解释。这是否意味着我线程安全类中的所有字段都需要是volatile的? - Anand Patel
不一定。如果您有一个字段从未被多个线程共享(它对于每个线程都是内部的),则可以在不使用volatile关键字的情况下使用它。 - disorder

1

当后台线程将true分配给成员变量时,会有一个释放栅栏,并将该值写入内存,更新或刷新其他处理器的缓存地址。

Console.WriteLine的函数调用是一个完整的内存栅栏,其语义可能做任何事情(除了编译器优化),需要stop不被缓存。

然而,如果您删除对Console.WriteLine的调用,我发现该函数仍然会停止。

我相信,在没有优化的情况下,编译器不会缓存从全局内存计算出的任何内容。然后,volatile关键字是一条指令,不要考虑将涉及变量的任何表达式缓存到编译器/JIT中。

这段代码仍然会停止(至少对我来说,我正在使用Mono):

public void Start()
{
    stop = false;

    var thread = new Thread(() =>
    {
        while(true)
        {
            Thread.Sleep(50);
            stop = !stop;
        }
    });

    thread.Start();

    while ( !(stop ^ stop) );
}

这表明阻止缓存的不是 while 语句,因为它显示了变量甚至在同一表达式语句中也没有被缓存。

这种优化看起来对内存模型敏感,这是平台相关的,意味着这将在 JIT 编译器中完成;它没有时间(或智能)/看到/其他线程中变量的使用情况并因此防止缓存。

也许微软认为程序员不知道何时使用 volatile,并决定剥夺他们的责任,然后 Mono 也效仿了这样做。


+1. 您的程序在没有附加调试器的情况下以发布模式进入无限循环。 - Anand Patel

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接