【问题标题】:why can't a local variable be volatile in C#?为什么局部变量在 C# 中不能是 volatile 的?
【发布时间】:2019-03-10 15:55:55
【问题描述】:
public void MyTest()
{
  bool eventFinished = false;

  myEventRaiser.OnEvent += delegate { doStuff(); eventFinished = true; };
  myEventRaiser.RaiseEventInSeperateThread()

  while(!eventFinished) Thread.Sleep(1);

  Assert.That(stuff);
}

为什么 eventFinished 不能是 volatile 的,这有关系吗?

在我看来,在这种情况下,编译器或运行时可能会变得聪明,并在 while 循环中“知道”eventFinished 只能为假。特别是当您考虑将提升变量生成为类的成员并将委托生成为同一类的方法的方式时,从而剥夺了 eventFinished 曾经是局部变量这一事实的优化。

【问题讨论】:

  • 在这种情况下,您的变量不是本地变量!相反,它是编译器生成的类中的一个实例变量。
  • 这是语义上的区别——就代码而言,它是一个恰好被捕获的局部变量......
  • 我“看到”的是一个在不同线程中更新的局部变量。即使我知道编译器会为我的局部变量创建一个实例变量,但预编译器显然不会或“太固执”而无法承认它。
  • 我看不出这不是 C# 编译器错误?或者仅仅是 var 捕获的一个陷阱?无论哪种方式,这个陷阱与其他几个在 var 捕获匿名方法中不那么微妙的陷阱相结合,确实将 C# 变成了一团糟。开始时很好,但现在它与 C++ 一样糟糕/更糟(我认为考虑到这些副作用很奇怪、不直观且无用,情况会更糟)。同样,如果 MSFT 在实现这些编译器功能之前对正确设计它进行了更多思考,那么它没有理由变得如此糟糕。
  • 这只是另一个例子,说明 C# 如何变得过于复杂而无法为自己所用。

标签: c# multithreading volatile


【解决方案1】:

有一个线程原语ManualResetEvent 可以精确地完成这项任务 - 您不想使用布尔标志。

这样的事情应该可以完成:

public void MyTest()
{
    var doneEvent = new ManualResetEvent(false);

    myEventRaiser.OnEvent += delegate { doStuff(); doneEvent.Set(); };
    myEventRaiser.RaiseEventInSeparateThread();
    doneEvent.WaitOne();

    Assert.That(stuff);
}

关于局部变量上缺乏对volatile 关键字的支持,我认为没有任何理由说明这在理论上可能在 C# 中可能。最有可能的是,它不被支持仅仅是因为在 C# 2.0 之前没有使用此类功能。现在,随着匿名方法和 lambda 函数的存在,这种支持可能会变得有用。如果我在这里遗漏了什么,请有人澄清一下。

【讨论】:

  • 当你需要埃里克的时候他在哪里? ;-p
  • 如果您的线程和锁定非常复杂、性能敏感和处理器排序敏感,以至于您需要将内容标记为易失性,那么您不应该依赖编译器来做各种疯狂的事情为您将局部变量重写为闭包类的字段。您应该明确地编写代码,使其尽可能清晰和精确,以便将代码的确切语义传达给需要理解它的维护程序员。语法糖的重点是隐藏机制;你想暴露机制!
  • 如果 doneEvent.Set();在 doneEvent.WaitOne() 之前发生; ?
  • 正如您所指出的,两个不同的线程无法访问本地本身,但是闭包创建的字段可以。将局部变量标记为 volatile 可能表明闭包生成的字段将被标记为相同。
  • 这样看:语言不保证提升的本地变量被实现为字段。只要语言能够在运行时生成维护闭包和变量语义的代码,它们就可以实现为数组的元素、属性或其他任何东西。我们是否想在语言中添加一个对编译器编写者如何生成代码施加很大限制的特性?不,特别是因为该功能一开始是个坏主意。提升本地人作为字段已经是一个泄漏的抽象;我们不想让它变得更糟。
【解决方案2】:

大多数场景中,局部变量是特定于线程的,因此与volatile 相关的问题完全没有必要。

当它像在您的示例中那样是“捕获”变量时,这会发生变化 - 当它作为编译器生成的类上的字段静默实现时。所以理论上它可能是不稳定的,但在大多数情况下它不值得额外的复杂性。

特别是像Monitor(又名lock)和Pulse 等的东西也可以做到这一点,任何数量的其他线程结构也可以。

线程很棘手,主动循环很少是管理它的最佳方式...


重新编辑...secondThread.Join() 将是显而易见的事情 - 但如果您真的想使用单独的令牌,请参见下文。这样做的优点(相对于 ManualResetEvent 之类的东西)是它不需要操作系统提供任何东西 - 它纯粹在 CLI 内部处理。

using System;
using System.Threading;
static class Program {
    static void WriteLine(string message) {
        Console.WriteLine(Thread.CurrentThread.Name + ": " + message);
    }
    static void Main() {
        Thread.CurrentThread.Name = "Main";
        object syncLock = new object();
        Thread thread = new Thread(DoStuff);
        thread.Name = "DoStuff";
        lock (syncLock) {
            WriteLine("starting second thread");
            thread.Start(syncLock);
            Monitor.Wait(syncLock);
        }
        WriteLine("exiting");
    }
    static void DoStuff(object lockHandle) {
        WriteLine("entered");

        for (int i = 0; i < 10; i++) {
            Thread.Sleep(500);
            WriteLine("working...");
        }
        lock (lockHandle) {
            Monitor.Pulse(lockHandle);
        }
        WriteLine("exiting");
    }
}

【讨论】:

  • 如果 RaiseEventInSeperateThread() 被有效地实现为: new Thread(() => { Thread.sleep(100); OnEvent(); }; 你将如何使用 Monitor 或 lock 来进行 MyTest () 等待事件委托完成执行?
  • 我的意思是:new Thread(() => { Thread.sleep(100); OnEvent(); }).Start();
  • 您将拥有一个线程 WaitOne 和另一个 Pulse。我稍后会尝试添加一个示例...
【解决方案3】:

如果你想让本地变量表现得像 Volatile,你也可以使用 Voltile.Write。如:

public void MyTest()
{
  bool eventFinished = false;

  myEventRaiser.OnEvent += delegate { doStuff(); Volatile.Write(ref eventFinished, true); };
  myEventRaiser.RaiseEventInSeperateThread()

  while(!Volatile.Read(eventFinished)) Thread.Sleep(1);

  Assert.That(stuff);
}

【讨论】:

  • 不错的答案,但您的 while 循环应该使用 Volatile.Read
【解决方案4】:

如果引发的事件直到进程退出该局部变量的范围后才完成,会发生什么?该变量将被释放,您的线程将失败。

明智的做法是附加一个委托函数,向父线程指示子线程已完成。

【讨论】:

  • 代码没问题;这是一个“捕获的”变量,并作为编译器生成的类上的字段实现。线程不会失败。
  • 难道不能和类级变量一样吗?如果实例在其他线程中的事件完成之前被垃圾收集,则会发生同样的问题。
  • 当它仍在任一线程的可见范围内时不能被垃圾回收。
  • @Marc:是的,我只是在评论之后才考虑到这一点。尽管如此,在 C# 2.0 中,volatile 局部变量似乎对局部变量很有用。
  • 我看不出垃圾收集器如何在这里“抢戏”。 MyTest() 方法在等待结果时执行了一个半 while(true){} 循环。可能发生的唯一不好的情况是,如果事件委托忘记将 eventFinished 设置为 true,从而导致 MyTest() 挂起。幸运的是,我足够聪明,不会忘记。 ;-)