Using the regular ManualResetEvent provided by the .Net framework during async code can cause performance issues by blocking up available ThreadPool threads. Searching online Thread.Sleep() vs Task.Delay() will provide some more details. Below you fill find an implementation of ManualResetEvent which is async ready and soft on the ThreadPool.

If you don't care how things work, and all you need is some code, then scroll on down to the bottom!

The implementation hinges mostly around a TaskCompletionSource, which as itself can already be used as a ManualResetEvent. The basic idea is that the class contains a single TaskCompletionSource. When a thread invokes ‘WaitAsync’, they get a reference to the uncompleted task on the TaskCompletionSource. When another thread calls ‘Set’, we can complete the TaskCompletionSource, and all of the threads waiting on ‘WaitAsync’ can continue. The implementation below is not really any sort “ResetEvent” because it can’t be Reset.

raw

public sealed class SuperBasicEventSlim
{
    private TaskCompletionSource<bool> completionSource = new TaskCompletionSource<bool>();

    public void Set()
    {
        this.completionSource.TrySetResult(true);
    }

    public Task WaitAsync()
    {
        return this.completionSource.Task;
    }
}

A TaskCompletionSource can’t transfer out of the completed state so in order to reset we will need to create a new one and start returning the new task. The below implementation is fully functional but doesn’t allow threads to Timeout or Cancel waiting. An important thing to note is that continuations with the "ExecuteSynchronously" attribute will execute immediately on the thread which called "Set". You will see below in the implementation that we provide that behavior, along with executing them on a ThreadPool thread.

raw

public class BasicManualResetEvent
{
    private volatile TaskCompletionSource<bool> completionSource = new TaskCompletionSource<bool>();

    public void Set()
    {
        this.completionSource.TrySetResult(true);
    }

    public Task WaitAsync()
    {
        return this.completionSource.Task;
    }

    public void Reset()
    {
        var currentCompletionSource = this.completionSource;

        if (!currentCompletionSource.Task.IsCompleted)
        {
            return;
        }

        Interlocked.CompareExchange(ref this.completionSource, new TaskCompletionSource<bool>(), currentCompletionSource);
    }
}

The reason for the CompareExchange, is to prevent us from overwriting a new TaskCompletionSource if two threads try to Reset at the same time. We don’t want to leave anyone waiting on a task which we’ve lost all references to.

In order to be more ‘Async Friendly’, now we’re going to implement WaitAsync with both a CancellationToken and a Timeout. There are three scenarios to consider:

  • Set is called
  • Caller cancels the CancellationToken
  • Timeout expires

These scenarios are handled in the method ‘AwaitCompletion’. We always await two tasks. One of which is a TaskCompletionSource and another is a Delay. The TaskCompletionSource handles gets completed when someone calls ‘Set’. The Delay task is used to support a Timeout and a CancellationToken. If the user doesn’t specify a delay, we await an indefinite task (Task.Delay(-1)). One thing to note here is that the user has the option to exclude both a CancellationToken and a delay, in which case there is no reason to do any extra work, we can just return the original task.

raw

private async Task<bool> AwaitCompletion(int timeoutMS, CancellationToken token)
{
    // Validate arguments.
    if (timeoutMS < -1 || timeoutMS > int.MaxValue)
    {
        throw new ArgumentException("The timeout must be either -1ms (indefinitely) or a positive ms value <= int.MaxValue");
    }

    CancellationTokenSource timeoutToken = null;

    // If the token cannot be cancelled, then we don't need to create any sort of linked token source.
    if (false == token.CanBeCanceled)
    {
        // If the wait is indefinite, then we don't need to create a second task at all to wait on, just wait for set. 
        if (timeoutMS == -1)
        {
            return await this.completionSource.Task;
        }

        timeoutToken = new CancellationTokenSource();
    }
    else
    {
        // A token source which will get canceled either when we cancel it, or when the linked token source is canceled.
        timeoutToken = CancellationTokenSource.CreateLinkedTokenSource(token);
    }

    using (timeoutToken)
    {
        // Create a task to account for our timeout. The continuation just eats the task cancelled exception, but makes sure to observe it.
        Task delayTask = Task.Delay(timeoutMS, timeoutToken.Token).ContinueWith((result) => { var e = result.Exception; }, TaskContinuationOptions.ExecuteSynchronously);

        var resultingTask = await Task.WhenAny(this.completionSource.Task, delayTask).ConfigureAwait(false);

        // The actual task finished, not the timeout, so we can cancel our cancellation token and return true.
        if (resultingTask != delayTask)
        {
            // Cancel the timeout token to cancel the delay if it is still going.
            timeoutToken.Cancel();
            return true;
        }

        // Otherwise, the delay task finished. So throw if it finished because it was canceled.
        token.ThrowIfCancellationRequested();
        return false;
    }
}

All four of the WaitAsync methods invoke this one with different arguments. The full code, which is ready to be copy/pasted is here below!

raw

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

/// <summary>
/// An async manual reset event.
/// </summary>
public sealed class ManualResetEventAsync
{
    // Inspiration from https://devblogs.microsoft.com/pfxteam/building-async-coordination-primitives-part-1-asyncmanualresetevent/
    // and the .net implementation of SemaphoreSlim

    /// <summary>
    ///  The timeout in milliseconds to wait indefinitly.
    /// </summary>
    private const int WaitIndefinitly = -1;

    /// <summary>
    /// True to run synchronous continuations on the thread which invoked Set. False to run them in the threadpool.
    /// </summary>
    private readonly bool runSynchronousContinuationsOnSetThread = true;

    /// <summary>
    /// The current task completion source.
    /// </summary>
    private volatile TaskCompletionSource<bool> completionSource = new TaskCompletionSource<bool>();

    /// <summary>
    /// Initializes a new instance of the <see cref="ManualResetEventAsync"/> class.
    /// </summary>
    /// <param name="isSet">True to set the task completion source on creation.</param>
    public ManualResetEventAsync(bool isSet)
        : this(isSet: isSet, runSynchronousContinuationsOnSetThread: true)
    {
    }

    /// <summary>
    /// Initializes a new instance of the <see cref="ManualResetEventAsync"/> class.
    /// </summary>
    /// <param name="isSet">True to set the task completion source on creation.</param>
    /// <param name="runSynchronousContinuationsOnSetThread">If you have synchronous continuations, they will run on the thread which invokes Set, unless you set this to false.</param>
    public ManualResetEventAsync(bool isSet, bool runSynchronousContinuationsOnSetThread)
    {
        this.runSynchronousContinuationsOnSetThread = runSynchronousContinuationsOnSetThread;

        if (isSet)
        {
            this.completionSource.TrySetResult(true);
        }
    }

    /// <summary>
    /// Wait for the manual reset event.
    /// </summary>
    /// <returns>A task which completes when the event is set.</returns>
    public Task WaitAsync()
    {
        return this.AwaitCompletion(ManualResetEventAsync.WaitIndefinitly, default(CancellationToken));
    }

    /// <summary>
    /// Wait for the manual reset event.
    /// </summary>
    /// <param name="token">A cancellation token.</param>
    /// <returns>A task which waits for the manual reset event.</returns>
    public Task WaitAsync(CancellationToken token)
    {
        return this.AwaitCompletion(ManualResetEventAsync.WaitIndefinitly, token);
    }

    /// <summary>
    /// Wait for the manual reset event.
    /// </summary>
    /// <param name="timeout">A timeout.</param>
    /// <param name="token">A cancellation token.</param>
    /// <returns>A task which waits for the manual reset event. Returns true if the timeout has not expired. Returns false if the timeout expired.</returns>
    public Task<bool> WaitAsync(TimeSpan timeout, CancellationToken token)
    {
        return this.AwaitCompletion((int)timeout.TotalMilliseconds, token);
    }

    /// <summary>
    /// Wait for the manual reset event.
    /// </summary>
    /// <param name="timeout">A timeout.</param>
    /// <returns>A task which waits for the manual reset event. Returns true if the timeout has not expired. Returns false if the timeout expired.</returns>
    public Task<bool> WaitAsync(TimeSpan timeout)
    {
        return this.AwaitCompletion((int)timeout.TotalMilliseconds, default(CancellationToken));
    }

    /// <summary>
    /// Set the completion source.
    /// </summary>
    public void Set()
    {
        if (this.runSynchronousContinuationsOnSetThread)
        {
            this.completionSource.TrySetResult(true);
        }
        else
        {
            // Run synchronous completions in the thread pool.
            Task.Run(() => this.completionSource.TrySetResult(true));
        }
    }

    /// <summary>
    /// Reset the manual reset event.
    /// </summary>
    public void Reset()
    {
        // Grab a reference to the current completion source.
        var currentCompletionSource = this.completionSource;

        // Check if there is nothing to be done, return.
        if (!currentCompletionSource.Task.IsCompleted)
        {
            return;
        }

        // Otherwise, try to replace it with a new completion source (if it is the same as the reference we took before).
        Interlocked.CompareExchange(ref this.completionSource, new TaskCompletionSource<bool>(), currentCompletionSource);
    }

    /// <summary>
    /// Await completion based on a timeout and a cancellation token.
    /// </summary>
    /// <param name="timeoutMS">The timeout in milliseconds.</param>
    /// <param name="token">The cancellation token.</param>
    /// <returns>A task (true if wait succeeded). (False on timeout).</returns>
    private async Task<bool> AwaitCompletion(int timeoutMS, CancellationToken token)
    {
        // Validate arguments.
        if (timeoutMS < -1 || timeoutMS > int.MaxValue)
        {
            throw new ArgumentException("The timeout must be either -1ms (indefinitely) or a positive ms value <= int.MaxValue");
        }

        CancellationTokenSource timeoutToken = null;

        // If the token cannot be cancelled, then we dont need to create any sort of linked token source.
        if (false == token.CanBeCanceled)
        {
            // If the wait is indefinite, then we don't need to create a second task at all to wait on, just wait for set. 
            if (timeoutMS == -1)
            {
                return await this.completionSource.Task;
            }

            timeoutToken = new CancellationTokenSource();
        }
        else
        {
            // A token source which will get canceled either when we cancel it, or when the linked token source is canceled.
            timeoutToken = CancellationTokenSource.CreateLinkedTokenSource(token);
        }

        using (timeoutToken)
        {
            // Create a task to account for our timeout. The continuation just eats the task cancelled exception, but makes sure to observe it.
            Task delayTask = Task.Delay(timeoutMS, timeoutToken.Token).ContinueWith((result) => { var e = result.Exception; }, TaskContinuationOptions.ExecuteSynchronously);

            var resultingTask = await Task.WhenAny(this.completionSource.Task, delayTask).ConfigureAwait(false);

            // The actual task finished, not the timeout, so we can cancel our cancellation token and return true.
            if (resultingTask != delayTask)
            {
                // Cancel the timeout token to cancel the delay if it is still going.
                timeoutToken.Cancel();
                return true;
            }

            // Otherwise, the delay task finished. So throw if it finished because it was canceled.
            token.ThrowIfCancellationRequested();
            return false;
        }
    }
}

There you have it! An async friendly ManualResetEvent implementation.