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

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. . .

Leave a Comment