Notes
Saved for later

Exploring implicit async in C#


This post describes an imagined scenario. The examples here will not work in real life!

When async was introduced in C# it allowed us to write complex code in much cleaner syntax.

But the new syntax, although cleaner, still introduced a bit extra code to write.

A simple synchronous code could look like this.

void GenerateAndPrintResult()
{
    var x = GetResult();
    Console.WriteLine(x);
}

string GetResult();

The asynchronous alternative looks like this.

async Task GenerateAndPrintResult()
{
    var x = await GetResultAsync();
    Console.WriteLine(x);
}

Task<string> GetResultAsync();

What if we could keep the benefits of async without having to write the extra syntax.

Implicit await/async

Let’s only change the GetResult function

Task<string> GetResult();

and let the compiler generate the implicit code.

async Task GenerateAndPrintResult()
{
    var x = await GetResult();
    Console.WriteLine(x);
}

Every call to a function that returns Task will insert await.

Every function that calls an async function will itself return Task.

This will progress to any code calling GenerateAndPrintResult() down the call chain.

Change in signature

The problem with this solution is that for compiled libraries the signature has now changed and old code would no longer be compatible with these changes.

Introducing the async keyword

With the new implicit await we sometimes still want the Task rather than the awaiter value.

For example when starting many operations at once and await them all at the end.

For this we introduce the async keyword.

void GenerateAndPrintResult()
{
    var a = async GetResultA();
    var b = async GetResultB();
    Task.WaitAll(a, b);
    Console.WriteLine(a + b);
}

Task<string> GetResultA();
Task<string> GetResultB();

Error reporting, limits to the implicit code

When the compiler converts calling functions to themselves return Task we might eventually reach a place where the conversion can’t be done automatically. For example the call is made from a library that’s not being compiled.

void Main()
{
    LibraryA.Schedule(GenerateAndPrintResult);
}

void GenerateAndPrintResult()
{
    var x = GetResult();
    Console.WriteLine(x);
}

Task<string> GetResult();

This would generate a compiler error that must be handled.

Can't cast from Task to void

Where do we report this error?

A. At the top of the call chain, at LibraryA.Schedule, where the compiler no longer can solve the problem by the implicit inserts?

B. At every call that triggered the implicit code. In this example it’s only one function, GetResult, but it would be every function in the project.

Let’s stick with A and instead introduce a new keyword.

Introduce the sync keyword

The sync keyword would tell the compiler to stop implicit async/await/Task generation and report the error there.

sync void GenerateAndPrintResult()
{
    var x = GetResult(); //← Error here
    Console.WriteLine(x);
}

Task<string> GetResult();

Manually we could break the chain like this.

void GenerateAndPrintResult()
{
    var x = (async GetResult()).Result;
    Console.WriteLine(x);
}

Task<string> GetResult();

We could also use the sync keyword on a statement to await the result.

void GenerateAndPrintResult()
{
    var x = sync GetResult();
    Console.WriteLine(x);
}

Task<string> GetResult();

No you may be thinking, “aren’t we just replacing a noisy async/await with another noisy sync keyword”, and you’re probably right.

Properties

While we’re at it let’s add async support to properties.

void GenerateAndPrintResult()
{
    var x = Result;
    Result = "world";
    Console.WriteLine(x);
}

public Task<string> Result { get; set; }

The generated code would be

async Task GenerateAndPrintResult()
{
    var x = await get_Result();
    await set_Result("world");
    Console.WriteLine(x);
}

public Task<string> get_Result();
public Task set_Result(string value);