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 could rename 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 class TaskExtensions
{
    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:

public struct TaskAwaiter<T>
{
    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:

internal class Program
{
    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=12ms. Message=In Main, before SumAsync call
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.

9 thoughts on “Eduasync part 5: making Task<T> awaitable”

  1. The message “In SumAsync, after awaits” is executed on thread 4. So not all the async method is executed on the same thread (#1). I feel that I have missed something important here. Thanks in advantage!!!

  2. @alberto: No, in this case the async method ends up executing on different threads, because the console app doesn’t have a message pump; the continuation can’t happen on thread 1 because that’s blocked waiting for the result!

    In UI applications, the continuation *would* execute on the relevant UI thread (if the async method was called on the UI thread) as that’s what the current scheduler would dictate.

  3. In a windows form message pumb, this code causes a deadlock. I feel that is a little unfortunate because the code execution depends implicitly on the execution context, as you mention on former posts.

  4. @alberto: Yes, this same code in a UI app would deadlock. I don’t think that’s particularly terrible though – we’re explicitly using a blocking call (accessing task.Result) and it’s simply a bad idea to do that in a UI thread.

    To put it another way: making this example *work* in a UI thread without blocking would probably require more magic than I’d be happy to stomach.

  5. “(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.)”

    If I recall the discussion during the PDC correctly, the current TaskScheduler is tied to the current SynchronizationContext, so in console apps would have the same behavior as if you’d just used the current SynchronizationContext. And that behavior is to simply invoke the Send() or Post() on the current thread.

  6. “To put it another way: making this example *work* in a UI thread without blocking would probably require more magic than I’d be happy to stomach.”

    I assume by that you mean that for .NET to _implicitly_ make it work in a UI thread without blocking. We as the consumers of this new API should be able to simply “await” the task:


    int result = await task.Result;
    Log(“Final result: ” + result);

    etc.

  7. @pete.d: Yes, if you await the task (you wouldn’t await task.Result, just the task itself) all is fine.

  8. @pete.d: About synchronization contexts – what does it mean (exactly) to “simply invoke the Send() or Post() on the current thread”? There’s no message pump running, nothing to pick up the task.

    I believe the synchronization context will just dispatch calls to the thread pool, in this case, with no particular preference as to which thread picks it up.

  9. “There’s no message pump running, nothing to pick up the task.”

    Right. That’s why it gets invoked on the current thread. It bypasses any type of message queue completely.

    But, I have been imprecise, lumping Send() and Post() together. The base SynchronizationContext implementation of Send() will dispatch on the current thread. But as you say, the Post() method does go through the ThreadPool, as do any continuations for a given task.

    I have less experience with the TaskScheduler, but it appears that TaskScheduler.Current does the same thing as using Post(), which makes sense.

    In particular, I did a little experiment and could demonstrate multiple thread pool threads being used to execute continuations for a single task, as well as multiple Post()’ed delegates through a base SynchronizationContext instance.

    So, there you go: your suspicion is correct, and in a console application, the continuation will be invoked on an arbitrary thread pool thread, not necessarily the thread used to execute the task itself.

    Of course, for all practical purposes, the two are equivalent, since from the application’s point of view, any thread pool thread is equivalent to any other. But you are correct that my including Post() in my statement is not correct, and of course task continuations are more like Post() than like Send().

Comments are closed.