1

A couple days ago I asked about the Purpose of async/await in web servers, and got in-depth answers explaining how in fully asynchronous code, it frees up the CPU completely while also releasing the calling thread to the thread pool.

But what about the degraded cases? What if your code is saddled with a legacy library that still uses synchronous I/O, or worse, CPU-intensive computation? When I can't be "asynchronous all the way down", should I still make my webmethods asynchronous?

4
  • What does making async methods mean in this context?
    – Ccm
    Commented May 30 at 8:20
  • @Ccm I guess, still declaring your web methods async and making them await everything that can be awaited, only at some point in the execution plan there's one fat synchronous method call?
    – Medinoc
    Commented May 30 at 8:23
  • So the case is you use a modern framework with async await support but you reference a legacy library that's fully sync?
    – Ccm
    Commented May 30 at 8:37
  • @Ccm yes, exactly.
    – Medinoc
    Commented May 30 at 8:39

5 Answers 5

5

This is known as "async over sync" and is generally considered an anti pattern.

If your IO is still synchronous you will have a blocked thread, so there is no scalability benefit to wrapping this in a task. Adding asynchronicity will just add unnecessary complexity without clear benefit.

In an UI application, async can be used to offload slow work to a background thread to make the application more responsive. This is best done at the "edges", i.e. close to the UI.

See Should I expose asynchronous wrappers for synchronous methods for a longer article on the subject.

2

The devil is in the details. Simply saying "I use sync i/o" or "I have cpu-intensive computations" doesn't say much.

For example, I might have a separate threads or threadpool running those sync i/o and cpu heavy tasks. And then I accept and process requests on async machinery, while blocking calls are delegated to separate threadpool. That is completely fine, and can have nice benefits of better performance tuning. And if you have to use blocking calls then I suggest you do that.

In fact libuv runs file i/o as sync operation over a separate threadpool. Rust's tokio runtime also has a separate threadpool for blocking operations. It even exposes spawn_blocking method for that. And many other popular runtimes do similar things. So this is not an exotic idea at all.

However, in practice it is rare for someone to design such system. More often you will have blocking calls on async (green) threads. This may reduce the webserver's concurrency to the number of threads used. Well, to be honest that depends on how long those calls are. Sometimes you can get await with it. But in worst case this can even deadlock, effectively stopping your server (e.g. when someone tries to do blocking wait for an async task inside async runtime). Bad situation.

The point is that you want your server to still be responsive, even when it does heavy computations or sync i/o. Because if heavy computation takes 40s instead of 20s then this is often less impactful than user signing in in 20.2s instead of 0.2s. That's literally why we invented concurrency over single threaded cpus. Moreover would you really accept situation where your server doesn't respond to heartbeat/status requests because it is doing computations? Good luck monitoring that system.

So the rule of thumb is: avoid blocking calls in async functions.

2
  • You can have as many blocking calls in an async function as you want. The deadlock occurs when you force a task that is pure async to run synchronously.
    – Ccm
    Commented May 30 at 9:40
  • @Ccm yes, you can do anything you want. Which doesn't mean you should. Plus I already said the same thing about deadlock.
    – freakish
    Commented May 30 at 9:53
1

There is no problem returning Task from a method, even if nothing async happens inside (other than potentially violating KISS). This is what Task.CompletedTask is for. Remember, any method marked "async" is actually sync until it encounters an await. Task.Completedtask is a task that returns immediately. I tend to design my web API service library interfaces with async only methods (i.e. Task and Task<T>) and forgo a synchronous version. If someone has to do only sync stuff inside, they can use Task.CompletedTask. Purists will say this is a performance sink - it theoretically is but the hit is negligible.

You can mix sync IO with async IO and you will be fine. For the sync calls, you will not get the benefits described in the previous question.

For long running CPU tasks, the conversation is a bit more complicated. The Task returned by Task.Run allows you to track the status of the long running task and also control how the task is scheduled, so it depends on the specific details of your project. For Web API this is pretty niche. I think I only ever used it once, and that was to simulate some sort of background task. It has obvious uses in UI applications though, and I use it extensively there.

Using Task.Run to do sync IO on a background thread in a web API yields no benefit under normal circumstances, but it may be useful for UI applications to avoid UI block. But you need to carefully measure the impact of the sync IO. If the sync IO complete very fast, there's no need to use Task.Run to offload the work to a background thread. It may take longer for Task.Run to finish than a direct call, due to thread scheduling. You probably want to batch several IO calls that will take a reasonably long amount of time and not reading 10 bytes from a file, i.e. reading, decoding and playing an audio file is done on a background thread to not interfere with the UI.

Note that you can wrap old style BeginXxxx and EndXxxx async calls to use Tasks through TaskCompletionSource.

When it comes to performance of a single request, remember to always profile your code before trying anything impressive.

1

If your application consists only of CPU intensive tasks or legacy blocking calls, there's little advantage in hosting them in an asynchronous environment. Doing so only adds a layer of abstraction.

However, in many cases, one is doing a combination of tasks, some of which are CPU intensive or blocking and some of which are not. In these cases, the tools like threadpools can be used to isolate the CPU intensive and/or blocking tasks and the rest can be done in an asynchronous environment.

Remember that "async/await" almost always means cooperative multitasking. This is in contrast with preemptive multitasking. Preemptive multitasking is much more difficult to debug and work through. Async/await is basically equivalent to having every section of code between await calls wrapped in critical sections. If one has a race case in async/await code, the result is an unspecified ordering of the code, which is something that I find the human mind is generally comfortable thinking through. If one has a race case in preemptive multitasking, the result is almost universally undefined behavior, which has all sorts of nasty consequences (in fact, it has all of the nasty consequences).

Cooperative multitasking context switches can be faster than preemptive multitasking switches (for many languages). It's faster because one has the assistance of the routine being swapped out rather than having to go over its head to seamlessly hide the context switches. For tasks which involve an extremely large number of context switches, this can be very important.

Related, there's typically a limit to how many threads one creates, defined by the OS when the program starts up. Coroutines are typically just an allocated block of memory, so they typically have no limit other than running out of memory. I'm not sure if this is a useful thing for a webserver, but it's a thing. For your legacy IO/blocking code, this wont matter (as they're running everything in a threadpool), but if you have legacy IO/blocking code in the same application as some quick calculations, this can matter.

0

As JonasH pointed out, there is no performance advantage (IN A WEB SERVER) to wrap synchronous logic in an async wrapper. ※For things like GUI based apps, you may want to move to a separate thread to avoid impacting framerate, for example

However, two use-cases come to mind:
Parallelism.
Fire and Forget.

Parallelism
No advantage on a device already running at full capacity, but will result in a performance increase when not at full capacity.

Fire and Forget
If your processing logic is not required for the response, you can start off a task and return your response early. This is important if working with a system that has timeouts involved and the response is not dependent on the task results.

However, in .NET this is a little bit more complicated due to context lifespans.
Further reading on the subject:
https://michaelceber.medium.com/net-core-web-api-how-to-correctly-fire-and-forget-database-commands-from-controller-end-point-cad7a879630

1
  • 1
    Thanks for adding the link about fire-and-forget, since I also had a question about that (independently of this one).
    – Medinoc
    Commented May 30 at 9:23

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.