Austin Wise
Go to home page
2021-06-07

Async Part 2 - Async Method Semantics

In the previous article I explained why having concurrency in a program is useful. I also briefly mentioned some older models of managing concurrency and stated that the async/await model makes it easier to create concurrent programs. At today’s WWDC, Swifts new async/await support was featured prominently in the State of the Union. They made an elegant demonstration of how this language feature can simplify code. Check it out.

This article will talk about how async functions are defined and what their semantics are. While I will be using C# for the code examples, I will also try to mention how other languages take different approaches.

Schedulers

One thing to keep in mind is an async function ultimately is running on a CPU, interfacing with the thread abstraction provided by an operating system. There could many threads running at the same time or only a single one. I will cover that in a future article, so for now we won’t worry about where our async functions are running.

The basic async function definition

Let’s take a look at a basic async function definition:

async Task<int> GetBiasAsync()
{
    return 42;
}

The first thing that stand out is the async keyword in the function signature. This is how we know we are dealing with an async. The next thing that is interesting is the return type of the function: Task<int>. In .NET, the Task class represents a promise, also known as a future. It represents a value that is not necessarily available now, but might be in the future. Another thing to note is the function name ends with “Async”. In C# this is a convention to differentiate between async functions and normal functions.

Next note the value be returned from the function. It is just an int, which does appear to be compatible with Task<int>. This is one peculiarity of the async function method syntax: the value you return needs to match the type parameter of the Task<> type in the function signature. When you use a return statement in an async function, takes care of making sure the value you return ends up in the promise. Some languages, like Rust, don’t include the promise type in the function signature. This is a key point when defining an async function: it does not return a value, it returns a promise that will have the value in the future. Let’s take a look at an async function that consumes the async function we just defined:

async Task<int> AddAsync(int a, int b)
{
    int sum = a + b;
    int c = await GetBiasAsync();
    return sum + c;
}

The new piece of syntax here is the await expression. The input to the await expression in this case is a Task<int>. The value it produces is int. The await expression just unwraps the value inside of the Task<> and gives us the value. It looks pretty simple.

The runtime behavior is more complex than the syntax belies. When execution reaches the await statement, the execution of the function is suspended. When the Task<int> reaches a terminal state (either completed, cancelled, or failed), the AddAsync will resume executing. Each await expression gives the scheduler the opportunity to do something else with the current thread.

Handling errors

Let’s take a look a look at an error handling scenario. Many languages have an exception error model, where an exception object can be thrown in a function. The exception travels up the stack, looking at all the callers of the function to see if anyone is willing to catch the exception. Once it finds a catch, the execution resumes in catch block. This can also be expressed in async/await:

async Task ThrowAsync()
{
    await Task.Delay(1000); //This about it
    throw new Exception("I've had it!");
}

async Task CatchAsync()
{
    try
    {
        await ThrowAsync();
    }
    catch (Exception ex)
    {
        // I guess I'll keep going actually.
    }
}

The ThrowAsync returns a Task object. Once flow of execution reaches the throw statement, the ThrowAsync function transitions the Task object it returned earlier into the error state. Meanwhile, the CatchAsync function resumes now that the Task object it was awaiting has reached a terminal state. It sees that the Task object is in an error state and throws the exception again. The normal catch block is executed and handles the error.

You can see from these two examples that the async/await feature is all about hiding the details of the async operations. All the programmer must do is add the extra await expressions to unwrap the value of the promises return by the async functions. Otherwise the functions behave normally.

A Pitfall

There are some ways the details of the async machinery underlying the async functions can become visible. One example is that the promises returned by async functions are generally just like any other value. You can store them in variables and return them from functions without await them. But if you are not careful, this can cause confusing results. Let’s take a look at an example in Hack. Note that Awaitable<> corresponds to C#’s Task<>. Awaitable<void> corresponds to C#’s Task.

<?hh

async function top(): Awaitable<void> {
    await HH\Asio\usleep(1000);
    throw new Exception("lol");
}

async function middle(): Awaitable<void> {
    await top();
}

function hiddeMiddle(): Awaitable<void> {
    return top();
}

async function asyncMain(): Awaitable<void> {
    try {
        await middle();
    } catch (Exception $ex) {
        echo "middle threw:\n";
        echo $ex->toString();
        echo "\n";
    }
    echo "\n";
    try {
        await hiddeMiddle();
    } catch (Exception $ex) {
        echo "hiddeMiddle threw:\n";
        echo $ex->toString();
        echo "\n";
    }
}

<<__EntryPoint>>
function main() {
    HH\Asio\join(asyncMain());
}

This outputs:

middle threw:
exception 'Exception' with message 'lol' in /home/austin/hhtest/exception_fun.php:5
Stack trace:
#0 /home/austin/hhtest/exception_fun.php(9): top()
#1 /home/austin/hhtest/exception_fun.php(18): middle()
#2 (): asyncMain()
#3 /home/austin/hhtest/exception_fun.php(36): HH\Asio\join()
#4 (): main()
#5 {main}

hiddeMiddle threw:
exception 'Exception' with message 'lol' in /home/austin/hhtest/exception_fun.php:5
Stack trace:
#0 /home/austin/hhtest/exception_fun.php(26): top()
#1 (): asyncMain()
#2 /home/austin/hhtest/exception_fun.php(36): HH\Asio\join()
#3 (): main()
#4 {main}

When we call hiddenMiddle, it returns the Awaitable without awaiting. When we await it in the asyncMain() function, hiddenMiddle() no longer appears! This is because the async machinery has to recreate a call stack based on what information is available. So it is a good idea to await a promise as soon as you receive it to ensure your stack traces make sense.

Conclusion

The async/await programming model greatly simplifies writing concurrent code. The programmer can write straight-line code without worrying too much about when the function will resume executing.