基于Monitor和WaitHandle的线程同步

33
我看完这篇文章后的印象是,更好地使用Monitor/Lock进行线程同步,因为它不使用本地资源。
以下是文章第5页的引用:
“Monitor.Wait/Pulse并不是在一个线程中等待某些事情发生并告诉该线程已经发生的唯一方法。Win32程序员已经使用各种其他机制很长时间了,这些机制由AutoResetEvent、ManualResetEvent和Mutex类提供,它们都是从WaitHandle派生而来的。所有这些类都在System.Threading命名空间中。(Win32信号量机制没有.NET 1.1中的托管包装器。在.NET 2.0中存在,但如果您需要在此之前使用它,则可以使用P/Invoke自己包装它或编写自己的计数信号量类。)”
“有些人可能会惊讶地发现,使用这些类比使用各种Monitor方法要慢得多。我认为这是因为从托管代码到本机Win32调用再返回来的过程开销大,与Monitor提供的完全托管视图相比,这是昂贵的。一位读者也解释说,监视器是在用户模式下实现的,而使用等待句柄需要切换到内核模式,这相当昂贵。”
但是,自从发现SO并阅读了一些问题/答案后,我开始怀疑自己关于在何时使用每种方法的理解。许多人似乎建议在Monitor.Wait/Pulse能够完成任务的情况下使用Auto/ManualResetEvent。有人能向我解释一下什么时候应该使用基于WaitHandle的同步而不是Monitor吗?
谢谢
3个回答

57
< p > < code > Monitor.Pulse/Wait 存在的问题是,信号可能会丢失。

< p >例如:

var signal = new ManualResetEvent(false);

// Thread 1
signal.WaitOne();

// Thread 2
signal.Set();

无论哪个线程中的两个语句在哪里执行,这都将始终起作用。这也是一个非常清晰的抽象,并且非常清楚地表达了您的意图。

现在看看使用监视器的相同示例:

var signal = new object();

// Thread 1
lock (signal)
{
    Monitor.Wait(signal);
}

// Thread 2
lock (signal)
{
    Monitor.Pulse(signal);
}

如果在执行Wait之前执行Pulse,信号(Pulse)会丢失。

为了解决这个问题,你需要像这样:

var signal = new object();
var signalSet = false;

// Thread 1
lock (signal)
{
    while (!signalSet)
    {
        Monitor.Wait(signal);
    }
}

// Thread 2
lock (signal)
{
    signalSet = true;
    Monitor.Pulse(signal);
}

这种方式能够工作,而且可能更加高效轻量级,但不易读懂。并且这就是并发带来的头痛所在。

  • 这段代码真的能够工作吗?
  • 在每个角落下都能够工作吗?
  • 在超过两个线程的情况下呢?(提示:不能)
  • 如何对它进行单元测试?

一个坚固、可靠、易读的抽象往往比原始性能更好。

此外,WaitHandles 还提供了一些不错的东西,例如等待一组句柄被设置等。用 monitors 实现这一点会让头痛问题变得更加严重...


经验法则:

  • 使用Monitorslock)确保共享资源的独占访问
  • 使用WaitHandles(Manual/AutoResetEvent/Semaphore)在线程之间发送信号

谢谢您的解释,我完全明白了。 - Matt
我喜欢这个地方...你可以轻松地找到像这样的答案,这将为你节省数小时的查阅书籍和文章的时间... - Padu Merloti
1
dtb - 请问您能解释一下第三个要点吗?为什么使用超过两个线程会导致程序崩溃? - Miguel Sevilla
如果一个线程在没有任务时只循环等待,而在任何其他线程可能正在等待的任何事情发生变化时都进行PulseAll,那么监视器脉冲/等待模式并不难。 - supercat
如果您有2个线程在Monitor.Wait上阻塞,第三个线程通过Monitor.Pulse设置/信号事件,只有一个等待的线程会被唤醒,因为此代码使用了Monitor.Pulse而不是Monitor.PulseAll。这是因为Monitor.Pulse最多唤醒“一个线程”,而WaitHandles /底层Win32事件API释放“所有等待的线程”。请注意,这并不意味着“您不能使用Monitors实现ManualResetEvents”,只是“此特定实现不满足ManualReset事件的‘粘性’属性”。 - MrCC
我认为这个答案有点误导性。Monitor.Pulse 的行为与 AutoResetEvent 相当。暗示它不可靠或不稳定,因为你正在寻找 ManualReset 行为似乎不公平。 如果你想要 ManualReset(信号保持设置),那么使用 ManualResetEvent,但如果你想要 AutoReset,则 Monitor.Pulse 通常可以是一个更好的解决方案。你可以简化你的代码,并避免在错误的时间处置显式事件可能会出现的错误。 - Orion Edwards

3

我认为我有一个相当好的第三个问题的例子(虽然这个帖子有点旧了,但可能对某些人有所帮助)。

我有一些代码,其中线程A接收网络消息,将其排队,然后激活线程B。线程B锁定,出队任何消息,解锁队列,然后处理消息。

问题在于,当线程B正在处理且没有等待时,如果A得到新的网络消息,将其排队并激活...好吧,B没有等待,所以脉冲就会消失。如果B完成正在进行的操作并击中Monitor.Wait(),那么最近添加的消息将一直挂起,直到另一条消息到达并接收到脉冲信号。

请注意,这个问题实际上没有浮现出来,因为最初我的整个循环是这样的:

while (keepgoing)
  { 
  lock (messageQueue)
      {
      while (messageQueue.Count > 0)
          ProcessMessages(messageQueue.DeQueue());

      Monitor.Wait(messageQueue);
      }
  }

这个问题并没有出现(好吧,在关闭时有一些罕见的奇怪事情发生,所以我对这段代码有点怀疑)直到我决定不应该让(潜在的长时间运行的)消息处理保持队列锁定,因为它没有理由。所以我将其更改为出队消息,离开锁定,然后进行处理。然后似乎我开始错过消息,或者它们只会在第二个事件发生后到达...


-1
对于@Will Gore的情况,一种好的做法是在调用Monitor.Wait之前,始终继续处理队列直到它为空。例如:
while (keepgoing)
{ 
  List<Message> nextMsgs = new List<Message>();
  lock (messageQueue)
  {
    while (messageQueue.Count == 0)
    {
        try
        {
            Monitor.Wait(messageQueue);
        }
        catch(ThreadInterruptedException)
        {
            //...
        }
    }
    while (messageQueue.Count > 0)
        nextMsgs.Add(messageQueue.DeQueue());
  }
  if(nextMsgs.Count > 0)
    ProcessMessages(nextMsgs);
}

这应该解决你遇到的问题,并减少锁定时间(非常重要!)。


变量 nextMsg 被声明但从未被赋值。message 没有被声明,但已经被赋值。如果这两个变量应该是相同的,那么只有 最后 一条消息将被处理 - 其余的将被丢弃(这是一个相当基本的逻辑错误)。Monitor.Waitlock 范围之外使用,可能会永久锁定队列。这非常糟糕。 - Kirill Shlenskiy
感谢Kirill Shlenskiy指出错误,对此表示歉意。已经修复。我试图展示解决前一篇文章中提到的两个问题的想法,但没有太仔细。当在锁之外使用Monitor.Wait时,它实际上会抛出SynchronizationLockException异常。 - Jim

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