BFree BFree - 3 months ago 19
C# Question

How does nunit successfully wait for async void methods to complete?

When using

async/await
in C#, the general rule is to avoid
async void
as that's pretty much a fire and forget, rather a
Task
should be used if no return value is sent from the method. Makes sense. What's strange though is that earlier in the week I was writing some unit tests for a few
async
methods I wrote, and noticed that NUnit suggested to mark the
async
tests as either
void
or returning
Task
. I then tried it, and sure enough, it worked. This seemed really strange, as how would the nunit framework be able to run the method and wait for all asynchronous operations to complete? If it returns Task, it can just await the task, and then do what it needs to do, but how can it pull it off if it returns void?

So I cracked open the source code and found it. I can reproduce it in a small sample, but I simply cannot make sense of what they're doing. I guess I don't know enough about the SynchronizationContext and how that works. Here's the code:

class Program
{
static void Main(string[] args)
{
RunVoidAsyncAndWait();

Console.WriteLine("Press any key to continue. . .");
Console.ReadKey(true);
}

private static void RunVoidAsyncAndWait()
{
var previousContext = SynchronizationContext.Current;
var currentContext = new AsyncSynchronizationContext();
SynchronizationContext.SetSynchronizationContext(currentContext);

try
{
var myClass = new MyClass();
var method = myClass.GetType().GetMethod("AsyncMethod");
var result = method.Invoke(myClass, null);
currentContext.WaitForPendingOperationsToComplete();
}
finally
{
SynchronizationContext.SetSynchronizationContext(previousContext);
}
}
}

public class MyClass
{
public async void AsyncMethod()
{
var t = Task.Factory.StartNew(() =>
{
Thread.Sleep(1000);
Console.WriteLine("Done sleeping!");
});

await t;
Console.WriteLine("Done awaiting");
}
}

public class AsyncSynchronizationContext : SynchronizationContext
{
private int _operationCount;
private readonly AsyncOperationQueue _operations = new AsyncOperationQueue();

public override void Post(SendOrPostCallback d, object state)
{
_operations.Enqueue(new AsyncOperation(d, state));
}

public override void OperationStarted()
{
Interlocked.Increment(ref _operationCount);
base.OperationStarted();
}

public override void OperationCompleted()
{
if (Interlocked.Decrement(ref _operationCount) == 0)
_operations.MarkAsComplete();

base.OperationCompleted();
}

public void WaitForPendingOperationsToComplete()
{
_operations.InvokeAll();
}

private class AsyncOperationQueue
{
private bool _run = true;
private readonly Queue _operations = Queue.Synchronized(new Queue());
private readonly AutoResetEvent _operationsAvailable = new AutoResetEvent(false);

public void Enqueue(AsyncOperation asyncOperation)
{
_operations.Enqueue(asyncOperation);
_operationsAvailable.Set();
}

public void MarkAsComplete()
{
_run = false;
_operationsAvailable.Set();
}

public void InvokeAll()
{
while (_run)
{
InvokePendingOperations();
_operationsAvailable.WaitOne();
}

InvokePendingOperations();
}

private void InvokePendingOperations()
{
while (_operations.Count > 0)
{
AsyncOperation operation = (AsyncOperation)_operations.Dequeue();
operation.Invoke();
}
}
}

private class AsyncOperation
{
private readonly SendOrPostCallback _action;
private readonly object _state;

public AsyncOperation(SendOrPostCallback action, object state)
{
_action = action;
_state = state;
}

public void Invoke()
{
_action(_state);
}
}
}


When running the above code, you'll notice that the Done Sleeping and Done awaiting messages show up before the Press any key to continue message, which means the async method is somehow being waited on.

My question is, can someone care to explain what's happening here? What exactly is the
SynchronizationContext
(I know it's used to post work from one thread to another) but I'm still confused as to how we can wait for all the work to be done. Thanks in advance!!

Answer

A SynchronizationContext allows posting work to a queue that is processed by another thread (or by a thread pool) -- usually the message loop of the UI framework is used for this. The async/await feature internally uses the current synchronization context to return to the correct thread after the task you were waiting for has completed.

The AsyncSynchronizationContext class implements its own message loop. Work that is posted to this context gets added to a queue. When your program calls WaitForPendingOperationsToComplete();, that method runs a message loop by grabbing work from the queue and executing it. If you set a breakpoint on Console.WriteLine("Done awaiting");, you will see that it runs on the main thread within the WaitForPendingOperationsToComplete() method.

Additionally, the async/await feature calls the OperationStarted() / OperationCompleted() methods to notify the SynchronizationContext whenever an async void method starts or finishes executing.

The AsyncSynchronizationContext uses these notifications to keep a count of the number of async methods that are running and haven't completed yet. When this count reaches zero, the WaitForPendingOperationsToComplete() method stops running the message loop, and the control flow returns to the caller.

To view this process in the debugger, set breakpoints in the Post, OperationStarted and OperationCompleted methods of the synchronization context. Then step through the AsyncMethod call:

  • When AsyncMethod is called, .NET first calls OperationStarted()
    • This sets the _operationCount to 1.
  • Then the body of AsyncMethod starts running (and starts the background task)
  • At the await statement, AsyncMethod yields control as the task is not yet complete
  • currentContext.WaitForPendingOperationsToComplete(); gets called
  • No operations are available in the queue yet, so the main thread goes to sleep at _operationsAvailable.WaitOne();
  • On the background thread:
    • at some point the task finishes sleeping
    • Output: Done sleeping!
    • the delegate finishes execution and the task gets marked as complete
    • the Post() method gets called, enqueuing a continuation that represents the remainder of the AsyncMethod
  • The main thread wakes up because the queue is no longer empty
  • The message loop runs the continuation, thus resuming execution of AsyncMethod
  • Output: Done awaiting
  • AsyncMethod finishes execution, causing .NET to call OperationComplete()
    • the _operationCount is decremented to 0, which marks the message loop as complete
  • Control returns to the message loop
  • The message loop finishes because it was marked as complete, and WaitForPendingOperationsToComplete returns to the caller
  • Output: Press any key to continue. . .