How to handle async method that has some awaits and some cpu-bound operations

Viewed 209

I'm still having trouble wrapping my head around async/await. I'm trying to figure out if I need to wrap my CPU intensive workload in a Task.Run below. Can someone tell me which is the better way to do this?

Without Task.Run:

public async Task ProcessData()
{
    //Get some data
    var httpClient = new HttpClient();
    var response = await httpClient.GetAsync("myUrl");
    string data = await response.Content.ReadAsStringAsync();

    //Calculate result
    string result = null;
    for (int i = 0; i < 10000000; i++)
    {
        //Do some CPU intensive work
    }

    //Save results
    using (var fs = new FileStream("myFile.txt", FileMode.Create))
    using (var writer = new StreamWriter(fs))
    {
        await writer.WriteLineAsync(result);
    }
}

With Task.Run:

public async Task ProcessData()
{
    //Get some data
    var httpClient = new HttpClient();
    var response = await httpClient.GetAsync("myUrl");
    string data = await response.Content.ReadAsStringAsync();

    //Calculate result
    string result = null;
    await Task.Run(() =>
    {
        for (int i = 0; i < 10000000; i++)
        {
            //Do some CPU intensive work
        }
    });

    //Save results
    using (var fs = new FileStream("myFile.txt", FileMode.Create))
    using (var writer = new StreamWriter(fs))
    {
        await writer.WriteLineAsync(result);
    }
}

My instinct is to not use the Task.Run but in that case would that mean that all code in an async method is going to be executed on a separate thread? Meaning the calling thread will not be interrupted until the very end (File stream is closed and method ends)?

Or will the caller thread be interrupted and go back to this method to do the CPU intensive part first and then go back to whatever it was doing while ProcessData() runs in the background for the FileStream part?

I hope that's clear.

Thanks in Advance

3 Answers

As describe in my async intro, an async method begins executing just like any other method, directly on the stack of the calling thread. When an await acts asynchronously, it will capture a "context", "pause" the method, and return to its caller. Later on, when the "asynchronous wait" (await) is complete, the method is resumed on that captured context.

So, this means:

would that mean that all code in an async method is going to be executed on a separate thread?

There is no separate thread. No thread at all "executes" the await because there's nothing to do. When the await completes, the method continues executing in that captured context. Sometimes this means the method is queued to a message queue, where it will eventually be run by the original calling thread (e.g., UI apps work this way). Other times this means the method is run by whatever thread pool thread is available (most other apps work this way).

Meaning the calling thread will not be interrupted until the very end (File stream is closed and method ends)? Or will the caller thread be interrupted and go back to this method to do the CPU intensive part first and then go back to whatever it was doing while ProcessData() runs in the background for the FileStream part?

The calling thread is never interrupted.

The CPU-bound portion of the work will run on whatever "context" was captured at the point of the await. This can be the original thread, or it can be a thread pool thread, depending on the context.

So, back to the original question:

Can someone tell me which is the better way to do this?

If this is code that can be called from different contexts, then I recommend not using Task.Run, but I do recommend documenting that the method does do CPU-bound work. That way, if a caller invokes this method from a UI thread, then it knows to use Task.Run when invoking it, to ensure the UI thread isn't blocked.

Use async to do CPU-bound work is for responsiveness reasons.

Why does async help here?

async and await are the best practice for managing CPU-bound work when you need responsiveness.

That's when, after the work has been scheduled, you need to do other work on the current thread concurrently, or you need to yield the thread back so it can do other things.

For example, typically you want to avoid CPU-bound work on UI thread / in UI synchronization context, because you could end up with a frozen / non-responsive UI. In this case, you could choose to use Task.Run to schedule the work to the next available thread in the thread pool.

Also, say if your code is on a thread pool thread already, it doesn't mean you should never use Task.Run, because there's valid reasons you could run something in parallel with the CPU-bound work.

For example, you want to do a computation, and while waiting on the result, you want to call a remote API for something else, so you could combine the results together when they both returned.

It is really up to your scenario. But what you might need to aware is that when you use async for CPU-bound work, there's small overhead cost incurred for context switching. Have that said, mind the times of context switching happens in your code.

It's important to note that there is a small cost to using async and it's not recommended for tight loops. It's up to you to determine how you write your code around this new capability.

There may be an elegant solution without Task.Run if you can accept some restrictions.

...
//Get some data
var httpClient = new HttpClient();
var response = await httpClient.GetAsync("myUrl").ConfigureAwait(false);
string data = await response.Content.ReadAsStringAsync().ConfigureAwait(false);

//Calculate result
...

Your cpu-bound code will run on some thread pool thread, if at least one async method before really runs asynchronously. If both finishes synchronously, then the following code would run on the calling thread. That may be a problem or may be not. (ie if naturally async code finishes synchronously, that may be because of some error. Then it may be possible that you didn't need to call cpu-intensive code at all.)

The second restriction is that you lose the original synchronisation context, and await ProcessData() may continue not on a calling thread.

You can read more about ConfigureAwait here.

Related