Updated on 2020-07-24
Using await in scenarios where you want to await custom items with or without using Task.Run()
The async/await keywords are a powerful way to add asynchronicity to your applications, improving performance and responsiveness. If you know how to do it, you can make your own awaitable members and types and take advantage of awaiting on anything you like. Here, we peek behind the scenes at await and walk through a couple of different methods for making something awaitable, plus how to choose which one is appropriate.
While I was creating an awaitable socket library I learned a little bit about how to extend awaitable features to things other than Tasks. Thanks to some kind folks here, I happened upon this article by Stephen Toub, who I follow anyway. It explains what I'm about to, but he's very brief about it, and he writes his articles for an advanced audience which makes for a challenging read. We're going to revisit some of his code, and I'll endeavor to make it more accessible to a wider audience of developers.
An awaitable type is one that includes at least a single instance method called GetAwaiter() which retrieves an instance of an awaiter type. These can also be implemented as extension methods. Theoretically, you could make an extension method for say, int and make it return an awaiter that represents the current asynchronous operation, such as delaying by the specified integer amount. Using it would be like await 1500 to delay for 1500 milliseconds. We'll be doing exactly that later. The point is that anything that implements GetAwaiter() (either directly or via an extension method) and returns an awaiter object can be awaited on. Task exposes this, and it's the reason a task can be awaited on.
The type that GetAwaiter() returns must implement System.Runtime.CompilerServices.INotifyCompletion or the corresponding ICriticalNotifyCompletion interfaces. In addition to implementing the interface's OnCompleted() method, it must also implement two members, called IsCompleted and GetResult() that aren't part of any interface.
TaskAwaiter exposes all of the awaiter object members, and can be returned from Task. Sometimes, we'll be starting a new task and returning its awaiter in order to simplify things. However, since it's only returned by Task, we can't use it to return things not associated with a task. If you want to make something awaitable that does not use Task to perform its work, you must create your own awaiter object.
Let's get to the code!
On an static class, we can implement the following extension method:
internal static TaskAwaiter GetAwaiter(this int milliseconds)
=> Task.Delay(milliseconds).GetAwaiter();
Now you can perform an await on an int and it will wait for the specified number of milliseconds. Remember, anything that starts a Task (like Task.Delay() does) can be used this way. Like I said though, if your operation does not spawn a task whose awaiter you can return, you must implement your own awaiter. Let's look at another example similar to that of the above - this one from Stephen Toub:
public static TaskAwaiter GetAwaiter(this TimeSpan timeSpan)
{
return Task.Delay(timeSpan).GetAwaiter();
}
You can see this does the same thing, except for with TimeSpan instead of int, meaning you can await a TimeSpan instance too. You don't have to use extension methods if you can put the GetAwaiter() method directly on your type, in which case it shouldn't be static. Doing this will make your type awaitable just like the extension methods do for other types.
Now we can do:
await 1500; // wait for 1500ms
and:
await new TimeSpan(0, 0, 0, 2); // wait for 2 seconds
I don't actually recommend awaiters on most simple types because it's vague. What I mean is await 1500 says nothing about what it does, and that makes it harder to read. I feel the same with awaiting on a TimeSpan. This code is here to illustrate the concept. With the next bit of code, we'll produce something a little more realistic.
Sometimes, it doesn't make sense to spawn a Task to fulfill an operation. This can be the case if you're wrapping an asynchronous programming pattern that doesn't use Task. It can also be the case if your operation itself is simple. If you use all struct types for your awaitable type and/or your awaiter type, they will avoid heap allocation. As far as I'm told, running a Task requires at least one object to be allocated on the managed heap. Furthermore, a Task is simply complicated because it needs to be all things to all people. What we really want is a slim way to await.
In this case, we need to create an object implementing one of two interfaces: INotifyCompletion or INotifyCriticalCompletion. The latter does not copy the execution context, which means its potentially faster, but very dangerous as it can elevate code's privilege. Normally, you'll want to use the former, as the risks to code access security usually outweigh any performance gain. The single method, OnCompleted() gets called when the operation completes. This is where you would do any continuation. We'll get to that. Note that OnCompleted() should be public to avoid the framework boxing your struct, which it must do to access the interface. Boxing causes a heap allocation. If the method is public however, it can skip the boxing and access the method directly, I believe. I haven't dived into the IL to verify it yet, but it's not unlikely so this way we can handle that scenario efficiently.
We must also implement IsCompleted and GetResult() which aren't part of any actual interface. The compiler generates code to call these methods, so it's not a runtime thing where interfaces or abstract classes would be the only way. The compiler doesn't need to access things through interfaces because there's no binary contract involved. It's simply that the compiler is generating the code to call the method at the source level, not having to resolve the call by calling through the interface's vtable (the list of function pointers that point to methods for an object in .NET) at runtime. I hope that's clear, but if it's a little confusing don't worry, as it's not important to understand this detail fully in order to use this technique.
In case it's not totally obvious, the IsCompleted property indicates whether or not the operation has been completed.
The GetResult() method takes no arguments and the return type is the same return type of your pseudo-task's result. It can be void if it has no result. If this were the equivalent of a Task
I was trying to think of a good use case for creating your own awaiter that wasn't too complex, and was having a difficult time of it. Fortunately, Sergey Tepliakov produced a fine example here in which I only had to modify OnCompleted() and IsCompleted a little bit. We'll explore it below:
// modified from
// https://devblogs.microsoft.com/premier-developer/extending-the-async-methods-in-c/
// premodifed source by Sergey Tepliakov
static class LazyUtility
{
// our awaiter type
public struct Awaiter<T> : INotifyCompletion
{
private readonly Lazy<T> _lazy;
public Awaiter(Lazy<T> lazy) => _lazy = lazy;
public T GetResult() => _lazy.Value;
public bool IsCompleted => _lazy.IsValueCreated;
public void OnCompleted(Action continuation)
{
// run the continuation if specified
if (null != continuation)
Task.Run(continuation);
}
}
// extension method for Lazy<T>
// required for await support
public static Awaiter<T> GetAwaiter<T>(this Lazy<T> lazy)
{
return new Awaiter<T>(lazy);
}
}
This extends Lazy
Note we're never spawning a Task here. Again, GetResult() can block, like it would here, if your Lazy
Also note that we take a Lazy
We're running continuation in OnCompleted() which allows for chaining tasks together with Task.ContinueWith() for example.
You can see that we're also forwarding to IsValueCreated in IsCompleted. This lets the framework know if the work in GetResult() has finished which it has once Lazy
Now we can do:
var result = await myLazyT; // awaitable initialization of myLazyT
It should be noted that whatever you do in this class should be thread safe. Lazy
Hopefully, that should get you started creating awaitable objects. Enjoy!