问题 发信号并立即关闭ManualResetEvent是否安全?


我觉得我应该知道答案,但无论如何我都会问,以防万一我犯了一个潜在的灾难性错误。

以下代码按预期执行,没有错误/异常:

static void Main(string[] args)
{
    ManualResetEvent flag = new ManualResetEvent(false);
    ThreadPool.QueueUserWorkItem(s =>
    {
        flag.WaitOne();
        Console.WriteLine("Work Item 1 Executed");
    });
    ThreadPool.QueueUserWorkItem(s =>
    {
        flag.WaitOne();
        Console.WriteLine("Work Item 2 Executed");
    });
    Thread.Sleep(1000);
    flag.Set();
    flag.Close();
    Console.WriteLine("Finished");
}

当然,正如多线程代码的情况一样,成功的测试并不能证明这实际上是线程安全的。如果我放,测试也会成功 Close 之前 Set即使文档明确指出在尝试之后做任何事情 Close 将导致未定义的行为。

我的问题是,当我调用时 ManualResetEvent.Set 方法,是否保证发出信号 所有 等待线程 之前 将控制权交还给来电者?换句话说,假设我能够保证不会再进一步​​打电话 WaitOne,在这里关闭手柄是否安全,或者在某些情况下这个代码可能会阻止一些服务员发出信号或导致 ObjectDisposedException

文档只说明了这一点 Set 把它置于一个“信号状态” - 它似乎没有对服务员何时会提出任何要求 实际得到 那个信号,所以我想确定一下。


4252
2018-02-25 19:00


起源

一个有趣的实验是将Sleep移到WaitOne()之前。这将允许您测试当'flag'已经关闭()时d,WaitOne()会做什么。 - Phillip Ngan
@Phillip Ngan:正如文档所示,它实际上是未定义的。如果你动了 Close 在之前的代码中 Thread.Sleep你得到一个 ObjectDisposedException (“安全手柄已关闭”)期间 WaitOne。另一方面,如果你搬家 Close 直 后 该 Thread.Sleep,两个线程都会发出信号并执行成功完成。因此,未定义的行为,“坏”代码中存在竞争条件。我只想确保在我认为的“好”代码中没有一些类似的未定义行为。 :) - Aaronaught


答案:


当你发信号时 ManualResetEvent.Set 保证所有等待该事件的线程(即处于阻塞状态) flag.WaitOne)在将控制权返回给调用者之前将发出信号。

当然,在某些情况下你可能会设置标志并且你的线程没有看到它,因为它在检查标志之前做了一些工作(或者如果你创建了多个线程则建议使用nobugs):

ThreadPool.QueueUserWorkItem(s =>
{
    QueryDB();
    flag.WaitOne();
    Console.WriteLine("Work Item 1 Executed");
});

标志上存在争用,现在您可以在关闭它时创建未定义的行为。您的标志是线程之间的共享资源,您应该创建一个倒计时锁存器,每个线程在完成后发出信号。这将消除你的争用 flag

public class CountdownLatch
{
    private int m_remain;
    private EventWaitHandle m_event;

    public CountdownLatch(int count)
    {
        Reset(count);
    }

    public void Reset(int count)
    {
        if (count < 0)
            throw new ArgumentOutOfRangeException();
        m_remain = count;
        m_event = new ManualResetEvent(false);
        if (m_remain == 0)
        {
            m_event.Set();
        }
    }

    public void Signal()
    {
        // The last thread to signal also sets the event.
        if (Interlocked.Decrement(ref m_remain) == 0)
            m_event.Set();
    }

    public void Wait()
    {
        m_event.WaitOne();
    }
}
  1. 每个线程在倒计时锁存器上发出信号。
  2. 你的主线程在倒计时锁存器上等待。
  3. 倒计时锁存信号后主线程清零。

最终,你在最后睡觉的时间并不是一种安全的方式来处理你的问题,而是你应该设计你的程序,使其在多线程环境中100%安全。

更新:单一生产者/多个消费者
这里的假设是你的生产者知道将创造多少消费者, 创建所有使用者后,重置 CountdownLatch 与给定数量的消费者:

// In the Producer
ManualResetEvent flag = new ManualResetEvent(false);
CountdownLatch countdown = new CountdownLatch(0);
int numConsumers = 0;
while(hasMoreWork)
{
    Consumer consumer = new Consumer(coutndown, flag);
    // Create a new thread with each consumer
    numConsumers++;
}
countdown.Reset(numConsumers);
flag.Set();
countdown.Wait();// your producer waits for all of the consumers to finish
flag.Close();// cleanup

6
2018-02-25 19:53



这看起来可能是概念上更好的选择;我无法理解它如何适用于具有多个消费者的单个生产者的情况(这似乎适用于多个生产者,单个消费者)。你能进一步解释在这种情况下如何使用这种锁存器吗? - Aaronaught
我已经更新了我的答案,以反映单个生产者/多个消费者的情况。 - Kiril
好的,我知道了。必须使用该活动 和 闩锁。不幸的是,在这种情况下,“生产者”将不知道将有多少消费者,现实比测试代码复杂得多......但我可能能够以某种方式适应这种情况。如果它在一段时间后证明是最好的答案,我会接受这个。 - Aaronaught
@Aaronaught如果您不知道将创建多少消费者并且您希望系统更复杂,那么您可以查看ThreadPool示例(您可以等待WaitAll中的多个ManualResetEvent项(数组<WaitHandle> []) ): msdn.microsoft.com/en-us/library/3dasc8as%28VS.80%29.aspx - Kiril
“你可以保证等待该事件的所有线程都会在将控制权返回给调用者之前发出信号” - >我找不到任何参考资料......另外,我的测试告诉我相反的情况。使用WaitOne()和Console.WriteLine(“foo”)迭代启动4个线程。然后设置()MRE并立即重置()它。您会注意到并非所有等待线程都被释放(有时甚至不会释放一个)。或者我错过了什么? - Vincent Van Den Berghe


这不好。你在这里很幸运,因为你只开始了两个主题。当你在双核机器上调用Set时,它们会立即开始运行。试试这个,看看炸弹:

    static void Main(string[] args) {
        ManualResetEvent flag = new ManualResetEvent(false);
        for (int ix = 0; ix < 10; ++ix) {
            ThreadPool.QueueUserWorkItem(s => {
                flag.WaitOne();
                Console.WriteLine("Work Item Executed");
            });
        }
        Thread.Sleep(1000);
        flag.Set();
        flag.Close();
        Console.WriteLine("Finished");
        Console.ReadLine();
    }

当旧计算机或当前计算机忙于执行其他任务时,原始代码将类似地失败。


5
2018-02-25 19:38



这是完全正确的,但是因为测试代码而轰炸,而不是因为它的行为 Set。在这种情况下,事件在某些线程具有偶数之前被关闭 开始 运行。将睡眠超时增加到5秒使得在四核上运行良好。我可以通过序列化对事件的访问并在关闭后立即将其置零来阻止上述情况;我主要关注已经在等待句柄的线程。 - Aaronaught
在任意长时间睡眠之后,无法保证线程实际上会开始运行。睡得更久只会降低ObjectDisposedException的风险,但它无法消除它。这些时间依赖性是任何人对线程做出假设的棺材中的终极指甲。 - Hans Passant
我当然不会用 Thread.Sleep 在生产代码中 - 实际的实现使用了各种同步原语。但事实证明这是一种微妙的竞争条件,现在已经得到修复。感谢您的输入; +1是第一个称呼它的人。 - Aaronaught


我认为存在竞争条件。根据条件变量编写事件对象后,您将获得如下代码:

mutex.lock();
while (!signalled)
    condition_variable.wait(mutex);
mutex.unlock();

因此,虽然可以发信号通知事件,但是等待事件的代码可能仍然需要访问事件的某些部分。

根据文件 ,这只会释放非托管资源。因此,如果活动仅使用托管资源,您可能会很幸运。但是这可能会在未来发生变化,所以我会在预防措施方面犯错误而不是关闭事件,直到你知道它不再被使用为止。


0
2018-02-25 19:40





对我来说看起来像风险模式,即使由于[当前]实施,也没关系。您正在尝试处置可能仍在使用的资源。

这就像新建和构建一个对象,甚至在该对象的消费者完成之前就盲目地删除它。

即便如此,也有一个 问题 这里。即使在其他线程有机会运行之前,程序也可能会退出。线程池线程是后台线程。

鉴于你无论如何都要等待其他线程,你也可以事后清理一下。


0
2018-02-26 08:36