Many years ago, C# introduced a way to run asynchronous operations that truly changed how we write concurrent code. The C# async API used to push the frontier on many aspects of concurrent execution. The introduction of async / await, along with a monadic like API, made this beautiful language (C#) very desirable when coding multi-threading workloads. However, the time has passed, and other languages continue to update their APIs while .NET has kept unchanged.
In this post, we will explore some of the C# async API features while comparing them to what modern Java offers. This is not intended to criticize any of these features, but instead, to analyze them by writing some small pieces of codes that might actually improve these APIs.
The following code shows how to run a simple task in C#.
Notice that we are using .Result to get the value returned from the task. As you might expect, this operation will block until the value becomes available, in other words, it blocks until the task is completed.
A popular approach to non-blocking operation in C# is the use of async / await, one of the most interesting features in this language.
By using await we can specify the next step in the computation (.WriteLine) without thinking about callbacks and most importantly, without blocking. The code after the await keyword becomes the callback through a series of transformation that the compiler does for us.
The equivalent (in C#) to the latest code will be the following.
Notice that the lambda inside .ContinueWith is the same code after the awaitin the previous example. In fact, that is what the compiler does when transforming one into the other. In both ways, the computations are executed without any blocking, maximizing the asynchronous execution.
These two options that we just saw show the only ways we have to chain computational stages in C#, and in most cases, they are just enough.
In Java, the previous example looks almost the same.
Notice that they are basically the same, only changing the API constructs on each of the platforms.
One interesting distinction is that in Java, the .thenApply function receives the result of the previous computation, and in C#, .ContinueWith receives a task instead, and then we have to extract the .Result from it.
In Java, we can also use await through libraries like the one offered by Electronic Arts (EA).
Please, notice that await is not a reserved keyword in Java, but instead, a function to be called. However, it is used exactly in the same way as in C#. The EA library does some bytecode manipulation in order to obtain the same results generated by the C# compiler.
When chaining computational stages in C#, we are limited to the constructs we just saw above, but let’s take a deeper look at them.
Notice that.ContinueWith provides overloaded functions returning different types such as Task<T> and Task. In other words, it can be used for chaining stages where the stage returns a new value or where the stage just run some side-effecting operation and returns nothing (void).
In Java, this is done by using different monadic operations that do not share the same name. The Java API reduces the number of overloaded functions, and groups them by name based on their functionality.
Let’s look at how the same example is accomplished in Java
Apart from the naming changes, this is exactly the same functionality. However, notice .thenApply and .thenAccept have different meanings and the intentions behind them are clearly marked in their names. That is not the case in C#, where .ContinueWith is the only method used.
Inner Async Operations
Now, let’s look at where C# falls a little behind.
Let’s suppose we have something like this.
And then, we want to combine these two operations. A natural way to do it will be the following.
However, final is a Task<Task<string>> which is definitely not the value we want.
The problem is that.ContinueWith does not flatten its result.
In order to get this done, we will have to write another function in the following way.
Even when this works, we will not be able to chain operation any longer, breaking the pattern we have been following since the beginning. Also, it is very specific, so we might want to generalize this function somehow (continue reading).
Java, on the other hand, has all kind of suitable functions to be used.
Notice how.thenCompose is flattening the result from str obtaining CompletionStage<String> which is the value we expect.
It is a fact that the C# API was designed long before others. Even when it has a very simplistic approach, and it makes extended use of async / await, it might need some refinements in order to catch up.
Luckily for us, C# has extension methods, and implementing this missing functionality is just a matter of understanding all these pieces that need to work together.
We are going to add three functions on top of the existing API. Map,FlatMap, and ForEach.
- Map is basically the same as.ContinueWith but we are going to use this new name since it goes well with what others use.
- FlatMap flattens the results of previous tasks, so it is the equivalent to .thenCompose in Java.
- ForEach will be used to chain tasks that do not return any values. This is an already existing functionality, but having a separated function for it makes the intentions clear.
Notice that we are extending Task<T>, but we are chaining operations using await.
Now, we implement FlatMap.
This is another extension using a generic implementation of our previous flatten. Notice that fn returns Task<Result>instead of Result as in Map. Then the inner task is flattened using await.
Finally, we add ForEach.
ForEach is used for side effects and void operations, fn doesn’t return any value. ForEach is equivalent to .thenAccept in Java.
Now, we can use this constructs to write the previous example as follows.
C# async API is powerful and simple enough to survive for almost 11 years without major changes. However, there are some gaps that can be filled with simplicity and without modification of existing constructs by using C# extension methods.
Java, on the other hand, can be overloaded with so many different ways to do the same, but its API covers all kind of use cases. A balanced approach is probably found in languages like Scala, where most constructs, such as the one we have seen, are used throughout the entire language in order to maintain some standards across different APIs.
The point is that we should be able to recognize these faults, and then work in order to correct them with minimum effort. C# already provides the tools to incorporate new features with simplicity, so let’s use them.