Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

like image 775
BFree Avatar asked Feb 22 '13 19:02

BFree


People also ask

Should NUnit tests be async?

The next version of NUnit (3.0, still in alpha) will not support async void tests. So, unless you plan on staying with NUnit 2.6. 4 forever, it's probably better to always use async Task in your unit tests.

Can unit tests be async?

Unlike async void unit tests that are quite complicated, you can have async Task unit tests, i.e., unit tests that return a Task instance. Almost all the unit test frameworks (MSTest, NUnit, etc.) provide support for such unit tests.

What is test attribute in NUnit?

The Test attribute is one way of marking a method inside a TestFixture class as a test. It is normally used for simple (non-parameterized) tests but may also be applied to parameterized tests without causing any extra test cases to be generated.


1 Answers

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. . .
like image 51
Daniel Avatar answered Sep 21 '22 12:09

Daniel