Building Async Coordination Primitives, Part 2: AsyncAutoResetEvent

Stephen Toub - MSFT

In my last post, I discussed building an asynchronous version of a manual-reset event.  This time, we’ll build an asynchronous version of an auto-reset event.

A manual-reset event is transitioned to the signaled state when requested to do so (i.e. calling Set()), and then it remains in that state until it’s manually transitioned back to the non-signaled state (i.e. a call to Reset()).  In contrast, an auto-reset event also transitions to the signaled state when requested to do so, but it then transitions back to the non-signaled state automatically when a wait operation completes due to that signal.  So, for example, if four threads waits on an manual-reset event, and someone else sets the event, all four waits will complete.  In contrast, if four threads wait on an auto-reset event, and someone else sets the event, just one of the four waiters will complete, and the other three waiters will remain waiting until more signals arrive. (One tricky aspect of an auto-reset event that can often be the cause of bugs is that it doesn’t keep track of how many signals it received.  For example, if no threads are waiting, then the event is signaled twice, and then two threads wait on the event, only one of those waits will complete.)

Here’s the shape of the type we’re building here:

public class AsyncAutoResetEvent
{
    public Task WaitAsync();
    public void Set();
}

To start, we’ll need a few members.  With our previous AsyncManualResetEvent, we were able to get away with using a single TaskCompletionSource<TResult> instance at a time, since setting the event should wake up everyone currently waiting.  But with AsyncAutoResetEvent, we need to be able to treat individually waiters differently, since if multiple are waiting and a signal arrives, only one of the waiters should complete.  So, we’ll need a collection of TaskCompletionSource<TResult> instances.  Additionally, a signal might arrive while there are no waiters, so we need to keep track of that with a Boolean.  And, finally, as we’ll see in a moment, there are some cases where we could reuse an already completed task as a minor performance optimization, so we’ll hang on to one of those, too:

private readonly static Task s_completed = Task.FromResult(true);
private readonly Queue<TaskCompletionSource<bool>> m_waits = new Queue<TaskCompletionSource<bool>>();
private bool m_signaled;

Now let’s implement WaitAsync.  If m_signaled is true when WaitAsync is invoked, then we can hand back our already completed Task, since this wait call can consume that signal; this also means we then need to reset m_signaled to be false.  If m_signaled was false, then we’ll create a new TaskCompletionSource<bool>, queue it, and return its Task… that Task will be completed later on when someone calls Set and its this waiters turn to be woken.  Note, though, that there are multiple operations here which need to happen atomically, and as such, I’ll use a lock on m_waits to ensure proper synchronization (this lock will only be held for a brief period of time).

public Task WaitAsync()
{
    lock (m_waits)
    {
        if (m_signaled)
        {
            m_signaled = false;
            return s_completed;
        }
        else
        {
            var tcs = new TaskCompletionSource<bool>();
            m_waits.Enqueue(tcs);
            return tcs.Task;
        }
    }
}

And now, we’ll implement Set.  The Set method needs to first check whether there are any outstanding waiters, meaning whether the queue of TaskCompletionSource<bool> has anything in it.  If it does, Set needs to dequeue one of them and complete it.  If instead the queue is empty, then it simply needs to set m_signaled to true.  Again, these operations need to happen atomatically, and in a synchronized manner with WaitAsync, so the bulk of the body of the method is is wrapped in a small lock on m_waits.  One important thing to note here, though.  In the previous post, I talked about a ramification of calling {Try}Set* methods on TaskCompletionSource<TResult>, that any synchronous continuations off of the TaskCompletionSource<TResult>’s Task could run synchronously as part of the call.  If we were to invoke SetResult here while holding the lock, then synchronous continuations off of that Task would be run while holding the lock, and that could lead to very real problems.  So, while holding the lock we grab the TaskCompletionSource<bool> to be completed, but we don’t complete  it yet, delaying doing so until the lock has been released:

public void Set()
{
    TaskCompletionSource<bool> toRelease = null;
    lock (m_waits)
    {
        if (m_waits.Count > 0)
            toRelease = m_waits.Dequeue();
        else if (!m_signaled)
            m_signaled = true;
    }
    if (toRelease != null
        toRelease.SetResult(true);
}

That’s it, an asynchronous auto-reset event.

Next time, we’ll try our hand at implementing an asynchronous countdown event.

0 comments

Discussion is closed.

Feedback usabilla icon