making Task<T> awaitable
Eduasync part 5: making Task<T> awaitable
In part 3 we looked at what the C# 5 compiler required for you to "await" something. The sample used a class which actually had an instance method called GetAwaiter, but I mentioned that it could also be an extension method.
In this post, we'll use that ability to make Task<T> awaitable - at which point we have everything we need to actually see some asynchronous behaviour. Just like the last part, the code here is pretty plain - but by the end of the post we'll have a full demonstration of asynchrony. I should make it clear that this isn't absolutely everything we'll want, but it's a good start.
TaskAwaiter<T>
As it happens, I'm using the same type name as the async CTP does for "something which can await a task" - TaskAwaiter. It's in a different namespace though, and we couldrename it with no ill-effects (unlike AsyncTaskMethodBuilder, for example). Indeed, in an old version of similar code I called this type Garçon - so GetAwaiter would return a waiter, so to speak. (Yes, you can use non-ASCII characters in identifiers in C#. I wouldn't really advise it though.)
All the task awaiter needs is a reference to the task that it's awaiting - it doesn't need to know anything about the async method which is waiting for it, for example. Task<T> provides everything we need: a property to find out whether it's completed, another to fetch the result, and the ContinueWith method to specify a continuation. The last part is particularly important - without that, we'd really have a hard time implementing this efficiently.
The extension method on Task<T> is trivial:
{
public static TaskAwaiter<T> GetAwaiter<T>(this Task<T> task)
{
return new TaskAwaiter<T>(task);
}
}
The TaskAwaiter<T> type itself is slightly less so, but only slightly:
{
private readonly Task<T> task;
internal TaskAwaiter(Task<T> task)
{
this.task = task;
}
public bool IsCompleted { get { return task.IsCompleted; } }
public void OnCompleted(Action action)
{
SynchronizationContext context = SynchronizationContext.Current;
TaskScheduler scheduler = context == null ? TaskScheduler.Current
: TaskScheduler.FromCurrentSynchronizationContext();
task.ContinueWith(ignored => action(), scheduler);
}
public T GetResult()
{
return task.Result;
}
}
IsCompleted is obviously trivial - Task<T> provides us exactly what we need to know. It's just worth noting that IsCompleted will return true if the task is cancelled, faulted or completed normally - it's not the same as checking for success. However, it represents exactly what we want to know here.
OnCompleted has two very small aspects of interest:
- ContinueWith takes an Action<Task<T>> or an Action<Task>, not just an Action. That means we have to create a new delegate to wrap the original continuation. I can't currently think of any way round this with the current specification, but it's slightly annoying. If the compiler could work with an OnCompleted(Action<object>) method then we could pass that into Task<T>.ContinueWith due to contravariance of Action<T>. The compiler could generate an appropriate MoveNext(object) method which just called MoveNext() and stash an Action<object> field instead of an Action field... and do so only if the async method actually required it. I'll email the team with this as a suggestion - they've made other changes with performance in mind, so this is a possibility. Other alternatives:
- In .NET 5, Task<T> could have ContinueWith overloads accepting Action as a continuation. That would be simpler from the language perspective, but the overload list would become pretty huge.
- I would expect Task<T> to have a "real" GetAwaiter method in .NET 5 rather than the extension method; it could quite easily just return "this", possibly with some explicitly implemented IAwaiter<T> interface to avoid polluting the normal API. That could then handle the situation more natively.
- We're using the current synchronization context if there is one to schedule the new task. This is the bit that lets continuations keep going on the UI thread for WPF and WinForms apps. If there isn't a synchronization context, we just use the current scheduler. For months this was incorrect in Eduasync; I was using TaskScheduler.Current in all cases. It's a subtle difference which has a huge effect on correctness; apologies for the previous inaccuracy. Even the current code is a lot cruder than it could be, but it should be better than it was...
GetResult looks and is utterly trivial - it works fine for success cases, but it doesn't do what we really want if the task has been faulted or cancelled. We'll improve it in a later part.
Let's see it in action!
Between this and the AsyncTaskMethodBuilder we wrote last time, we're ready to see an end-to-end asynchronous method demo. Here's the full code - it's not as trivial as it might be, as I've included some diagnostics so we can see what's going on:
{
private static readonly DateTimeOffset StartTime = DateTimeOffset.UtcNow;
private static void Main(string[] args)
{
Log("In Main, before SumAsync call");
Task<int> task = SumAsync();
Log("In Main, after SumAsync returned");
int result = task.Result;
Log("Final result: " + result);
}
private static async Task<int> SumAsync()
{
Task<int> task1 = Task.Factory.StartNew(() => { Thread.Sleep(500); return 10; });
Task<int> task2 = Task.Factory.StartNew(() => { Thread.Sleep(750); return 5; });
Log("In SumAsync, before awaits");
int value1 = await task1;
int value2 = await task2;
Log("In SumAsync, after awaits");
return value1 + value2;
}
private static void Log(string text)
{
Console.WriteLine("Thread={0}. Time={1}ms. Message={2}",
Thread.CurrentThread.ManagedThreadId,
(long)(DateTimeOffset.UtcNow - StartTime).TotalMilliseconds,
text);
}
}
And here's the result of one run:
Thread=1. Time=51ms. Message=In SumAsync, before awaits
Thread=1. Time=55ms. Message=In Main, after SumAsync returned
Thread=4. Time=802ms. Message=In SumAsync, after awaits
Thread=1. Time=802ms. Message=Final result: 15
So what's going on?
- We initially log before we even start the async method. We can see that the thread running Main has ID 1.
- Within SumAsync, we start two tasks using Task.Factory.StartNew. Each task just has to sleep for a bit, then return a value. Everything's hard-coded.
- We log before we await anything: this occurs still on thread 1, because async methods run synchronously at least as far as the first await.
- We hit the first await, and because the first task hasn't completed yet, we register a continuation on it, and immediately return to Main.
- We log that we're in Main, still in thread 1.
- When the first await completes, a thread from the thread pool will execute the continuation. (This may well be the thread which executed the first task; I don't know the behaviour of the task scheduler used in console apps off the top of my head.) This will then hit the second await, which also won't have finished - so the first continuation completes, having registered a second continuation, this time on the second task. If we changed the Sleep calls within the tasks, we could observe this second await actually not needing to wait for anything.
- When the second continuation fires, we log that fact. Two things to notice:
- It's almost exactly 750ms after the earlier log messages. That proves that the two tasks has genuinely been executing in parallel.
- It's on thread 4.
- The final log statement occurs immediately after we return from the async method - thread 1 has been blocked on the task.Result property fetch, but when the async method completes, it unblocks and shows the result.
I think you'll agree that for the very small amount of code we've had to write, this is pretty nifty.
Conclusion
We've now implemented enough of the functionality which is usually in AsyncCtpLibrary.dll to investigate what the compiler's really doing for us. Next time I'll include a program showing one option for using the same types within hand-written code... and point out how nasty it is. Then for the next few parts, we'll look at what the C# 5 compiler does when we let it loose on code like the above... and show why I didn't just have "int value = await task1 + await task2;" in the sample program.
If you've skimmed through this post reasonably quickly, now would be a good time to go back and make sure you're really comfortable with where in this sample our AsyncTaskMethodBuilder is being used, and where TaskAwaiter is being used. We've got Task<T> as the core type at both boundaries, but that's slightly coincidental - the boundaries are still very different, and it's worth making sure you understand them before you try to wrap your head round the compiler-generated code.