The awaitable pattern: writing your own awaitable

 
 
  • Gérald Barré

This post is part of the series 'Async/await in C#'. Be sure to check out the rest of the blog posts of the series!

Ask most C# developers what you can await and the answer is "a Task". That is the common case, but it is not the rule. await is resolved the same way foreach is: structurally. The compiler looks for a shape, and Task happens to have it.

Knowing the shape is what makes the next post, on the generated state machine, readable. It also lets you write awaitables of your own, which is occasionally very useful.

#The shape

For await expr to compile, expr needs a GetAwaiter() method. The type it returns, the awaiter, needs three members and one interface:

C#
public readonly struct MyAwaiter : INotifyCompletion
{
    public bool IsCompleted { get; }        // has the operation already finished?
    public TResult GetResult();             // the value, or rethrow the exception
    public void OnCompleted(Action continuation); // call this when it finishes
}

That is the whole contract. There is no IAwaitable interface, because there is no interface at all. The compiler binds these members by name, which has two consequences worth knowing.

GetAwaiter can be an extension method. You can make a type awaitable without owning it. The sample makes TimeSpan awaitable in three lines:

C#
public static class TimeSpanAwaitableExtensions
{
    public static TaskAwaiter GetAwaiter(this TimeSpan timeSpan) => Task.Delay(timeSpan).GetAwaiter();
}
C#
await TimeSpan.FromMilliseconds(200);

The awaiter can be a struct. It usually is. TaskAwaiter, ValueTaskAwaiter and YieldAwaitable.YieldAwaiter are all structs, which is why awaiting an already completed task does not allocate an awaiter.

#How the three members are used

The compiler generates roughly this at each await:

C#
var awaiter = expr.GetAwaiter();
if (!awaiter.IsCompleted)
{
    // save state, register the continuation, and return to the caller
    awaiter.OnCompleted(MoveNext);
    return;
}
var result = awaiter.GetResult();

So:

  • IsCompleted decides whether the method suspends at all. Returning true means execution continues straight through, on the same thread, with no scheduling and no allocation. This is the fast path that makes ValueTask worthwhile, and it is why an await on cached data costs almost nothing.
  • OnCompleted is only called when IsCompleted was false. It receives the continuation and is responsible for eventually running it. Where it runs it is entirely up to you, which is the hook the next two examples use.
  • GetResult is called on both paths. It returns the value and, importantly, it is where exceptions are rethrown. An awaiter reports failure by throwing from GetResult, not by some separate error channel.

GetResult returning void is fine: that is exactly what await on a non-generic Task does.

#ICriticalNotifyCompletion

There is a second interface, ICriticalNotifyCompletion, which adds UnsafeOnCompleted. Almost every real awaiter implements it.

The difference is the ExecutionContext. OnCompleted is expected to capture the current ExecutionContext and restore it before running the continuation. UnsafeOnCompleted is allowed not to.

"Unsafe" sounds alarming and is not: the compiler-generated state machine already captured the ExecutionContext when the method started, and restores it around the whole MoveNext call. Capturing it again per await would be duplicated work. So the state machine calls AwaitUnsafeOnCompleted whenever the awaiter offers it, and falls back to the safe version otherwise.

Implement both. UnsafeOnCompleted should do the work and OnCompleted can delegate to it if you have no ambient state of your own to flow.

#A useful custom awaitable

Task.Yield() returns to the current SynchronizationContext, which in a UI application means it comes back to the UI thread. Sometimes you want the opposite: get off whatever thread you are on and onto the pool, unconditionally.

C#
public readonly struct ThreadPoolAwaitable : ICriticalNotifyCompletion
{
    public ThreadPoolAwaitable GetAwaiter() => this;

    public bool IsCompleted => false;

    public void GetResult()
    {
    }

    public void OnCompleted(Action continuation) => UnsafeOnCompleted(continuation);

    public void UnsafeOnCompleted(Action continuation)
        => ThreadPool.UnsafeQueueUserWorkItem(static state => ((Action)state!)(), continuation);
}
C#
await default(ThreadPoolAwaitable);
// everything from here runs on a thread pool thread

Three things in that type are worth naming:

  • It is its own awaiter. GetAwaiter() returns this. This is a common trick that saves an allocation and a level of indirection, and YieldAwaitable does the same.
  • IsCompleted is always false. That is deliberate: if it returned true the continuation would run inline on the current thread, which is exactly what we are trying to avoid.
  • It is a readonly struct. Nothing is allocated to await it.

Running the sample:

start: thread 1
after awaiting a TimeSpan: thread 5
after awaiting ThreadPoolAwaitable: thread 7, pool thread: True

#What a custom awaitable costs

Awaiting something already complete, through four different awaitables:

MethodMeanRatioAllocated
AwaitTask5.728 ns1.0072 B
AwaitValueTask5.720 ns1.0072 B
AwaitCustomStruct5.113 ns0.8972 B
AwaitCustomClass5.494 ns0.9672 B
Benchmark project, measured with BenchmarkDotNet

They are all the same, within a few tenths of a nanosecond. That is the useful result: a hand-written awaitable is not a performance compromise, and using Task is not a performance win.

The 72 bytes in every row is not the awaitable. It is the Task<int> that the benchmark's own async method has to return. None of these awaits allocated anything of their own, because IsCompleted was true in each case and no continuation was ever registered.

#When to write one

Rarely, and that is fine. Most of the time the right answer is Task, ValueTask, or TaskCompletionSource from the previous post. A custom awaitable earns its place when:

  • You want await to mean "switch context", as Task.Yield() and ThreadPoolAwaitable do. There is no result to carry, only a scheduling decision.
  • You are wrapping something with a genuinely different completion model and want to avoid a Task allocation per operation. That is what IValueTaskSource is for, and it gets its own post later in this series.
  • You want to make an existing type awaitable without changing it, which the extension method trick covers.

What a custom awaitable does not give you is composition. It does not work with Task.WhenAll, WaitAsync, or anything else that expects a Task. If you need those, return a Task and be done.

#What to take away

  • await is a structural pattern, not an interface. GetAwaiter(), then IsCompleted, GetResult(), OnCompleted().
  • GetAwaiter can be an extension method, so any type can be made awaitable.
  • IsCompleted returning true is the fast path: no suspension, no scheduling, no allocation.
  • GetResult is where exceptions come back out.
  • ICriticalNotifyCompletion.UnsafeOnCompleted skips a redundant ExecutionContext capture. Implement it.
  • Awaiters are usually structs, and a hand-written one costs the same as Task.

The next post uses all of this: it takes an async method, decompiles it, and shows the state machine calling GetAwaiter, IsCompleted and GetResult exactly as described here.

#Additional resources

Do you have a question or a suggestion about this post? Contact me!

Follow me:
Enjoy this blog?