ManualResetEventSlim: Calling .Set() followed immediately by .Reset() doesn't release *any* waiting threads
Asked Answered
P

1

16

ManualResetEventSlim: Calling .Set() followed immediately by .Reset() doesn't release any waiting threads

(Note: This also happens with ManualResetEvent, not just with ManualResetEventSlim.)

I tried the code below in both release and debug mode. I'm running it as a 32-bit build using .Net 4 on Windows 7 64-bit running on a quad core processor. I compiled it from Visual Studio 2012 (so .Net 4.5 is installed).

The output when I run it on my system is:

Waiting for 20 threads to start
Thread 1 started.
Thread 2 started.
Thread 3 started.
Thread 4 started.
Thread 0 started.
Thread 7 started.
Thread 6 started.
Thread 5 started.
Thread 8 started.
Thread 9 started.
Thread 10 started.
Thread 11 started.
Thread 12 started.
Thread 13 started.
Thread 14 started.
Thread 15 started.
Thread 16 started.
Thread 17 started.
Thread 18 started.
Thread 19 started.
Threads all started. Setting signal now.

0/20 threads received the signal.

So setting and then immediately resetting the event did not release a single thread. If you uncomment the Thread.Sleep(), then they are all released.

This seems somewhat unexpected.

Does anyone have an explanation?

using System;
using System.Threading;
using System.Threading.Tasks;

namespace Demo
{
    public static class Program
    {
        private static void Main(string[] args)
        {
            _startCounter = new CountdownEvent(NUM_THREADS); // Used to count #started threads.

            for (int i = 0; i < NUM_THREADS; ++i)
            {
                int id = i;
                Task.Factory.StartNew(() => test(id));
            }

            Console.WriteLine("Waiting for " + NUM_THREADS + " threads to start");
            _startCounter.Wait(); // Wait for all the threads to have called _startCounter.Signal() 
            Thread.Sleep(100); // Just a little extra delay. Not really needed.
            Console.WriteLine("Threads all started. Setting signal now.");
            _signal.Set();
            // Thread.Sleep(50); // With no sleep at all, NO threads receive the signal.
            _signal.Reset();
            Thread.Sleep(1000);
            Console.WriteLine("\n{0}/{1} threads received the signal.\n\n", _signalledCount, NUM_THREADS);
            Console.WriteLine("Press any key to exit.");
            Console.ReadKey();
        }

        private static void test(int id)
        {
            Console.WriteLine("Thread " + id + " started.");
            _startCounter.Signal();
            _signal.Wait();
            Interlocked.Increment(ref _signalledCount);
            Console.WriteLine("Task " + id + " received the signal.");
        }

        private const int NUM_THREADS = 20;

        private static readonly ManualResetEventSlim _signal = new ManualResetEventSlim();
        private static CountdownEvent _startCounter;
        private static int _signalledCount;
    }
}

Note: This question poses a similar problem, but it doesn't seem to have an answer (other than confirming that yes, this can happen).

Issue with ManualResetEvent not releasing all waiting threads consistently


[EDIT]

As Ian Griffiths points out below, the answer is that the underlying Windows API that is used is not designed to support this.

It's unfortunate that the Microsoft documentation for ManualResetEventSlim.Set() states wrongly that it

Sets the state of the event to signaled, which allows one or more threads waiting on the event to proceed.

Clearly "one or more" should be "zero or more".

Pollux answered 25/2, 2013 at 12:49 Comment(8)
OK, I had a go. With no sleep, one thread received the signal. With Sleep(0), 8 threads, with Sleep(10), all 20. So, symptoms confirmed.Nydianye
You are just using the wrong synchronization object, detecting a signaled wait handle requires the operating system thread scheduler to run. It is worse with the Slim version, it is slim because it doesn't make an OS call. You need Monitor.Pulse() here, the Monitor class keeps tracking of waiting threads. A feature that's entirely missing from MRE since it cannot possibly provide fairness guarantees. But you already knew that from the linked duplicate.Lalita
Surely, the scheduler does run - Set() is a system call.Nydianye
Yes, I am using Montor.PulseAll() (not Monitor.Pulse()) in my actual code. I just wondered why it didn't work.Pollux
The same issue appears with the 'fat' ManualResetEvent.Nydianye
The same issue appears with 20 'real' threads, ie. not using Task.Factory. Also much easier to test since you don't have to wait ages for all 20 threads to get created ~:)Nydianye
Yes, I was tempted to put in a glorious ThreadPool.SetMinThreads() hack. ;)Pollux
Anyway, +1 for helping to expose API/MSDN issue.Nydianye
I
13

Resetting a ManualResetEvent is not like calling Monitor.Pulse - it makes no guarantee that it will release any particular number of threads. On the contrary, the documentation (for the underlying Win32 synchronization primitive) is pretty clear that you can't know what will happen:

Any number of waiting threads, or threads that subsequently begin wait operations for the specified event object, can be released while the object's state is signaled

The key phrase here is "any number" which includes zero.

Win32 does provide a PulseEvent but as it says "This function is unreliable and should not be used." The remarks in its documentation at http://msdn.microsoft.com/en-us/library/windows/desktop/ms684914(v=vs.85).aspx provide some insight into why pulse-style semantics cannot reliably be achieved with an event object. (Basically, the kernel sometimes takes threads that are waiting for an event off its wait list temporarily, so it's always possible that a thread will miss a 'pulse' on an event. That's true whether you use PulseEvent or you try to do it yourself by setting and resetting the event.)

The intended semantics of ManualResetEvent is that it acts as a gate. The gate is open when you set it, and is closed when you reset it. If you open a gate and then quickly close it before anyone had a chance to get through the gate, you shouldn't be surprised if everyone is still on the wrong side of the gate. Only those who were alert enough to get through the gate while you held it open will get through. That's how it's meant to work, so that's why you're seeing what you see.

In particular the semantics of Set are very much not "open gate, and ensure all waiting threads are through the gate". (And if it were to mean that, it's not obvious what the kernel should do with multi-object waits.) So this is not a "problem" in the sense that the event isn't meant to be used the way you're trying to use it, so it's functioning correctly. But it is a problem in the sense that you won't be able to use this to get the effect you're looking for. (It's a useful primitive, it's just not useful for what you're trying to do. I tend to use ManualResetEvent exclusively for gates that are initially closed, and which get opened exactly once, and never get closed again.)

So you probably need to consider some of the other synchronization primitives.

Iaverne answered 25/2, 2013 at 13:59 Comment(7)
I'm using Monitor.PulseAll() in my actual code btw. The documentation for ManualResetEventSlim.Set() says: "Sets the state of the event to signaled, which allows one or more threads waiting on the event to proceed" which, on the face of it it, is just wrong. It should say zero or more.Pollux
The use of 'any' in the MSDN documentation can be interpreted as making a distinction between MRE and ARE. If 40 threads are queued on an MRE, signaling it should make all 40 ready. If it does not, it's broken.Nydianye
I guess the answer is "It doesn't work because it wasn't designed to work that way, and the Microsoft documentation for ManualResetEvent.Set() is incorrect and incomplete".Pollux
Yes, it appears that way. Thank **** I don't use MRE that often!Nydianye
@MartinJames, it's not broken, it's just that 'ready' means less than you think. Calling Set makes all 40 threads ready, but does not guarantee they will actually run. Calling Reset takes all threads that have not yet transitioned from 'ready' to 'running' back out of a state of non-readiness. A thread may transition from blocked to runnable to blocked again without ever actually running.Iaverne
@MatthewWatson The key word in your quotation is allows - it does not say it will actually cause any threads to proceed. (There's certainly a case that the docs are "incomplete" (or, at best, unhelpfully vague) because they really should make it more clear, but I've not spotted anything that's positively wrong. The wording you quote is, strictly, correct because, taken in conjunction with the docs for Reset the behaviour you see doesn't actually contradict the docs.) That "allows" gets them off the hook.Iaverne
@IanGriffiths 'Calling Reset takes all threads that have not yet transitioned from 'ready' to 'running' back out of a state of non-readiness' - you're probably right, and maybe one day, M$ will tell us how that is actually useful, at all, ever :)Nydianye

© 2022 - 2024 — McMap. All rights reserved.