Yes, this has been a busy few days for blog posts. One of the comments on my previous blog post suggested there may be some confusion over how the control flow works with async. It’s always very possible that I’m the one who’s confused, so I thought I’d investigate.
This time I’m not even pretending to come up with a realistic example. As with my previous code, I’m avoiding creating a .NET Task myself, and sticking with my own custom "awaiter" classes. Again, the aim is to give more insight into what the C# compiler is doing for us. I figure other blogs are likely to concentrate on useful patterns and samples – I’m generally better at explaining what’s going on under the hood from a language point of view. That’s easier to achieve when almost everything is laid out in the open, instead of using the built-in Task-related classes.
That’s not to say that Task doesn’t make an appearance at all, however – because the compiler generates one for us. Note in the code below how the return type of DemonstrateControlFlow is Task<int>, whereas the return value is only an int. The compiler uses Task<T> to wrap up the asynchronous operation. As I mentioned before, that’s the one thing the compiler does which actually requires knowledge of the framework.
The aim of the code is purely to demonstrate how control flows. I have a single async method which executes 4 other "possibly asynchronous" operations:
- The first operation completes synchronously
- The second operation completes asynchronously, starting a new thread
- The third operation completes synchronously
- The fourth operation completes asynchronously
- The result is then "returned"
At various points in the code I log where we’ve got to and on what thread. In order to execute a "possibly asynchronous" operation, I’m simply calling a method and passing in a string. If the string is null, the operation completes syncrhonously. If the string is non-null, it’s used as the name of a new thread. The BeginAwait method creates the new thread, and returns true to indicate that the operation is completing asynchronously. The new thread waits half a second (to make things clearer) and then executes the continuation passed to BeginAwait. If you remember, that continuation represents "the rest of the method" – the work to do after the asynchronous operation has completed.
Without further ado, here’s the complete code. As is almost always the case with my samples, it’s a console app:
using System.Threading;
using System.Threading.Tasks;
public class ControlFlow
{
static void Main()
{
Thread.CurrentThread.Name = "Main";
Task<int> task = DemonstrateControlFlow();
LogThread("Main thread after calling DemonstrateControlFlow");
// Waits for task to complete, then retrieves the result
int result = task.Result;
LogThread("Final result: " + result);
}
static void LogThread(string message)
{
Console.WriteLine("Thread: {0} Message: {1}",
Thread.CurrentThread.Name, message);
}
static async Task<int> DemonstrateControlFlow()
{
LogThread("Start of method");
// Returns synchronously (still on main thread)
int x = await MaybeReturnAsync(null);
LogThread("After first await (synchronous)");
// Returns asynchronously (return task to caller, new
// thread is started by BeginAwait, and continuation
// runs on that new thread).
x += await MaybeReturnAsync("T1");
LogThread("After second await (asynchronous)");
// Returns synchronously – so we’re still running on
// the first extra thread
x += await MaybeReturnAsync(null);
LogThread("After third await (synchronous)");
// Returns asynchronously – starts up another new
// thread, leaving the first extra thread to terminate,
// and executing the continuation on the second extra thread.
x += await MaybeReturnAsync("T2");
LogThread("After fourth await (asynchronous)");
// Sets the result of the task which was returned ages ago;
// when this occurs, the main thread
return 5;
}
/// <summary>
/// Returns a ResultFetcher which can have GetAwaiter called on it.
/// If threadName is null, the awaiter will complete synchronously
/// with a return value of 1. If threadName is not null, the
/// awaiter will complete asynchronously, starting a new thread
/// with the given thread name for the continuation. When EndAwait
/// is called on such an asynchronous waiter, the result will be 2.
/// </summary>
static ResultFetcher<int> MaybeReturnAsync(string threadName)
{
return new ResultFetcher<int>(threadName, threadName == null ? 1 : 2);
}
}
/// <summary>
/// Class returned by MaybeReturnAsync; only exists so that the compiler
/// can include a call to GetAwaiter, which returns an Awaiter[T].
/// </summary>
class ResultFetcher<T>
{
private readonly string threadName;
private readonly T result;
internal ResultFetcher(string threadName, T result)
{
this.threadName = threadName;
this.result = result;
}
internal Awaiter<T> GetAwaiter()
{
return new Awaiter<T>(threadName, result);
}
}
/// <summary>
/// Awaiter which actually starts a new thread (or not, depending on its
/// constructor arguments) and supplies the result in EndAwait.
/// </summary>
class Awaiter<T>
{
private readonly string threadName;
private readonly T result;
internal Awaiter(string threadName, T result)
{
this.threadName = threadName;
this.result = result;
}
internal bool BeginAwait(Action continuation)
{
// If we haven’t been given the name of a new thread, just complete
// synchronously.
if (threadName == null)
{
return false;
}
// Start a new thread which waits for half a second before executing
// the supplied continuation.
Thread thread = new Thread(() =>
{
Thread.Sleep(500);
continuation();
});
thread.Name = threadName;
thread.Start();
return true;
}
/// <summary>
/// This is called by the async method to retrieve the result of the operation,
/// whether or not it actually completed synchronously.
/// </summary>
internal T EndAwait()
{
return result;
}
}
And here’s the result:
Thread: Main Message: After first await (synchronous)
Thread: Main Message: Main thread after calling DemonstrateControlFlow
Thread: T1 Message: After second await (asynchronous)
Thread: T1 Message: After third await (synchronous)
Thread: T2 Message: After fourth await (asynchronous)
Thread: Main Message: Final result: 5
A few things to note:
- I’ve used two separate classes for the asynchronous operation: the one returned by the
MaybeReturnAsync
method (ResultFetcher<T>
), and theAwaiter<T>
class returned byResultFetcher<T>.GetAwaiter()
. In the previous blog post I used the same class for both aspects, andGetAwaiter()
returned this. It’s not entirely clear to me under what situations a separate awaiter class is desirable. It feels like it should mirror theIEnumerable<T>
/IEnumerator<T>
reasoning for iterators, but I haven’t thought through the details of that just yet. - If a "possibly asynchronous" operation actually completes synchronously, it’s almost as if we didn’t use "await" at all. Note how the "After first await" is logged on the Main thread, and "After third await" is executed on T1 (the same thread as the "After second await" message). I believe there could be some interesting differences if
BeginAwait
throws an exception, but I’ll investigate that in another post. - When the first "properly asynchronous" operation executes, that’s when control is returned to the main thread. It doesn’t have the result yet of course, but it has a task which it can use to find out the result when it’s ready – as well as checking the status and so on.
- The compiler hasn’t created any threads for us – the only extra threads were created explicitly when we began an asynchronous operation. One possible difference between this code and a real implementation is that
MaybeReturnAsync
doesn’t actually start the operation itself at all. It creates something which is able to start the operation, but waits until theBeginAwait
call before it starts the thread. This made our example easier to write, because it meant we could wait until we knew what the continuation would be before we started the thread. - The return statement in our async method basically sets the result in the task. At that point in this particular example, our main thread is blocking, waiting for the result – so it becomes unblocked as soon as the task has completed.
If you’ve been following along with Eric, Anders and Mads, I suspect that none of this is a surprise to you, other than possibly the details of the methods called by the compiler (which are described clearly in the specification). I believe it’s worth working through a simple-but-useless example like this just to convince you that you know what’s going on. If the above isn’t clear – either in terms of what’s going on or why, I’d be happy to draw out a diagram with what’s happening on each thread. As I’m rubbish at drawing, I’ll only do that when someone asks for it.
Next topic: well, what would you like it to be? I know I’ll want to investigate exception handling a bit soon, but if there’s anything else you think I should tackle first, let me know in the comments.