Await can be used on things other than Task objects - it's simply a matter of implementing the members the compiler wants. Here, I endeavor to explain how to await anything using custom awaitable objects and also using TaskAwaiter.
Introduction
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.
Conceptualizing this Mess
While I was creating an awaitable socket library I learned a little bit about how to extend awaitable features to things other than Task
s. 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.
Awaitable Types
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.
Awaiter Types
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.
The TaskAwaiter
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!
Coding this Mess
The Simple Case: Using TaskAwaiter
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;
and:
await new TimeSpan(0, 0, 0, 2);
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.
The Not So Simple Case: Creating a Custom Awaiter Type
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<int>
you'd return int
here. I hope that makes sense. This is where you want to do the primary work for the task. This method blocks, meaning your code can be synchronous. If retrieving the result failed (meaning the operation failed), this method is where you'd throw
.
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:
static class LazyUtility
{
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)
{
if (null != continuation)
Task.Run(continuation);
}
}
public static Awaiter<T> GetAwaiter<T>(this Lazy<T> lazy)
{
return new Awaiter<T>(lazy);
}
}
This extends Lazy<T>
to be awaitable. All the work is done by Lazy<T>
when we call its Value
property in GetResult()
. This way, if you have a long running initialization, you can complete it asynchronously simply by awaiting your Lazy<T>
instance.
Note we're never spawning a Task
here. Again, GetResult()
can block, like it would here, if your Lazy<T>
's initialization code is long running.
Also note that we take a Lazy<T>
argument in the constructor, which is important for obvious reasons.
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<T>
creates the value. Note that we're never creating a Task
ourselves except when we run the continuation
in OnCompleted()
. This makes for a more efficient way to do awaiting than creating a Task
for it.
Now we can do:
var result = await myLazyT;
It should be noted that whatever you do in this class should be thread safe. Lazy<T>
already is.
Hopefully, that should get you started creating awaitable objects. Enjoy!
History
- 24th July, 2020 - Initial submission