ManualResetEvent in .NET: Simple Gates for Complex Threads

Signaling Multiple Threads at Once

If you've ever needed to let multiple threads proceed after an initialization completes, or release all waiting threads when data becomes available, you've needed a way to signal many waiters simultaneously. ManualResetEvent acts like a gate that opens for everyone waiting on it.

ManualResetEvent has two states: signaled and non-signaled. Threads calling WaitOne block when non-signaled and proceed immediately when signaled. Unlike AutoResetEvent which resets after releasing one thread, ManualResetEvent stays signaled until you explicitly call Reset. This lets you wake multiple threads at once.

You'll build a parallel batch processor that waits for all workers to reach a checkpoint before proceeding. By the end, you'll know when ManualResetEvent coordinates threads and when higher-level primitives like SemaphoreSlim or Barrier make more sense.

Basic Signal and Wait

Create a ManualResetEvent in the non-signaled state. Worker threads call WaitOne to block until the event is signaled. The controlling thread calls Set to release all waiters simultaneously.

BasicSignal.cs
using System;
using System.Threading;

using var readyEvent = new ManualResetEvent(false);

var worker = new Thread(() =>
{
    Console.WriteLine("Worker waiting for signal");
    readyEvent.WaitOne();
    Console.WriteLine("Worker proceeding after signal");
});

worker.Start();
Thread.Sleep(1000);

Console.WriteLine("Sending signal");
readyEvent.Set();

worker.Join();
Console.WriteLine("Worker finished");

The worker blocks at WaitOne until the main thread calls Set. Once signaled, the worker proceeds immediately. The event stays signaled, so additional calls to WaitOne would return instantly without blocking.

Releasing Multiple Waiters

ManualResetEvent's key feature is broadcasting to all waiting threads. When you signal, every blocked thread wakes up. This works for coordinating parallel initialization or batch processing.

MultipleWaiters.cs
using System;
using System.Collections.Generic;
using System.Threading;

using var startSignal = new ManualResetEvent(false);
var threads = new List<Thread>();

for (int i = 0; i < 5; i++)
{
    int workerId = i;
    var thread = new Thread(() =>
    {
        Console.WriteLine($"Worker {workerId} waiting");
        startSignal.WaitOne();
        Console.WriteLine($"Worker {workerId} started");
        Thread.Sleep(Random.Shared.Next(100, 500));
        Console.WriteLine($"Worker {workerId} finished");
    });

    threads.Add(thread);
    thread.Start();
}

Thread.Sleep(1000);
Console.WriteLine("Signaling all workers to start");
startSignal.Set();

foreach (var thread in threads)
{
    thread.Join();
}

Console.WriteLine("All workers completed");

All five workers block until Set is called. One Set releases all of them simultaneously. This is perfect for coordinating a batch of tasks that need to start together after initialization completes.

Resetting for Reuse

Call Reset to return the event to the non-signaled state. This lets you reuse the same event for multiple coordination cycles. Workers block again after Reset until the next Set.

ResetPattern.cs
using System;
using System.Threading;

using var gate = new ManualResetEvent(false);

var worker = new Thread(() =>
{
    for (int i = 0; i < 3; i++)
    {
        Console.WriteLine($"Iteration {i}: Waiting for signal");
        gate.WaitOne();
        Console.WriteLine($"Iteration {i}: Processing");
        Thread.Sleep(200);
    }
});

worker.Start();

for (int i = 0; i < 3; i++)
{
    Thread.Sleep(500);
    Console.WriteLine($"Iteration {i}: Signaling");
    gate.Set();
    Thread.Sleep(300);
    gate.Reset();
}

worker.Join();

The worker waits three times, and the main thread signals and resets three times. Each Set/Reset cycle creates a new gate that opens and closes. This pattern coordinates repeated batch operations.

Try It Yourself

Build a simple parallel data loader that waits for all chunks to be ready before processing. This demonstrates coordinating multiple producers with a single consumer.

Steps

  1. Create: dotnet new console -n EventDemo
  2. Navigate: cd EventDemo
  3. Replace Program.cs
  4. Update .csproj
  5. Run: dotnet run
Program.cs
using System;
using System.Collections.Generic;
using System.Threading;

using var dataReady = new ManualResetEvent(false);
var chunks = new List<int>();
var lockObj = new object();

for (int i = 0; i < 3; i++)
{
    int chunkId = i;
    new Thread(() =>
    {
        Thread.Sleep(Random.Shared.Next(500, 1500));
        lock (lockObj)
        {
            chunks.Add(chunkId);
            Console.WriteLine($"Chunk {chunkId} loaded");

            if (chunks.Count == 3)
            {
                Console.WriteLine("All chunks ready, signaling");
                dataReady.Set();
            }
        }
    }).Start();
}

Console.WriteLine("Waiting for all chunks");
dataReady.WaitOne();

Console.WriteLine($"Processing {chunks.Count} chunks");
Thread.Sleep(100);
Console.WriteLine("Done");
EventDemo.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

Output

Waiting for all chunks
Chunk 1 loaded
Chunk 0 loaded
Chunk 2 loaded
All chunks ready, signaling
Processing 3 chunks
Done

Better Alternatives

Prefer Task and async/await for modern coordination. Use Task.WhenAll for waiting on multiple async operations instead of managing threads and events manually. Tasks integrate better with the thread pool and provide cleaner exception handling and cancellation support.

For scenarios where you need countdown semantics, use CountdownEvent. It signals when a counter reaches zero, avoiding manual counting logic. For synchronizing threads at barriers, use the Barrier class which provides built-in phase coordination.

Only use ManualResetEvent when you're working with threads directly and need explicit broadcast signaling. Legacy code, interop with unmanaged code, or specific performance requirements might justify this. For most application code, higher-level abstractions are clearer and less error-prone.

Quick FAQ

When should I use ManualResetEvent vs AutoResetEvent?

Use ManualResetEvent to signal multiple waiters at once. Use AutoResetEvent to release one waiter at a time. ManualResetEvent stays signaled until you call Reset. AutoResetEvent resets automatically after releasing a single thread. Choose based on whether you need broadcast or single-wakeup semantics.

What's the gotcha with forgetting to call Reset?

If you signal but never reset, all future WaitOne calls return immediately. Threads don't block as expected. Always pair Set with Reset when you want gate-like behavior. Consider ManualResetEventSlim which has built-in spin-wait for short waits before blocking.

How do I avoid resource leaks with wait handles?

Always call Dispose on ManualResetEvent when you're done. Use using statements or try-finally blocks. Wait handles hold unmanaged resources that won't be reclaimed until finalization otherwise. ManualResetEventSlim is lighter weight and finalizer-free for short-lived scenarios.

Does ManualResetEvent work across processes?

Yes, create a named ManualResetEvent to share across processes. Pass a name to the constructor and multiple processes can signal and wait on the same event. This enables inter-process synchronization. Ensure consistent naming and handle security properly for named system objects.

Back to Articles