简体   繁体   中英

How is concurrency handled when not using Task.Run() to schedule the work?

If we fill a list of Tasks that need to do both CPU-bound and I/O bound work, by simply passing their method declaration to that list (Not by creating a new task and manually scheduling it by using Task.Start ), how exactly are these tasks handled?

I know that they are not done in parallel, but concurrently.

Does that mean that a single thread will move along them, and that single thread might not be the same thread in the thread pool, or the same thread that initially started waiting for them all to complete/added them to the list?

EDIT: My question is about how exactly these items are handled in the list concurrently - is the calling thread moving through them, or something else is going on?

Code for those that need code:

public async Task SomeFancyMethod(int i)
{
    doCPUBoundWork(i);
    await doIOBoundWork(i);
}


//Main thread

List<Task> someFancyTaskList = new List<Task>();
for (int i = 0; i< 10; i++)
    someFancyTaskList.Add(SomeFancyMethod(i));
// Do various other things here --
// how are the items handled in the meantime?
await Task.WhenAll(someFancyTaskList);

Thank you.

Asynchronous methods always start running synchronously. The magic happens at the first await . When the await keyword sees an incomplete Task , it returns its own incomplete Task . If it sees a complete Task , execution continues synchronously.

So at this line:

someFancyTaskList.Add(SomeFancyMethod(i));

You're calling SomeFancyMethod(i) , which will:

  1. Run doCPUBoundWork(i) synchronously.
  2. Run doIOBoundWork(i) .
  3. If doIOBoundWork(i) returns an incomplete Task , then the await in SomeFancyMethod will return its own incomplete Task .

Only then will the returned Task be added to your list and your loop will continue. So the CPU-bound work is happening sequentially (one after the other).

There is some more reading about this here: Control flow in async programs (C#)

As each I/O operation completes, the continuations of those tasks are scheduled. How those are done depends on the type of application - particularly, if there is a context that it needs to return to (desktop and ASP.NET do unless you specify ConfigureAwait(false) , ASP.NET Core doesn't ). So they might run sequentially on the same thread, or in parallel on ThreadPool threads.

If you want to immediately move the CPU-bound work to another thread to run that in parallel, you can use Task.Run :

someFancyTaskList.Add(Task.Run(() => SomeFancyMethod(i)));

If this is in a desktop application, then this would be wise, since you want to keep CPU-heavy work off of the UI thread. However, then you've lost your context in SomeFancyMethod , which may or may not matter to you. In a desktop app, you can always marshall calls back to the UI thread fairly easily.

I assume you don't mean passing their method declaration , but just invoking the method, like so:

var tasks = new Task[] { MethodAsync("foo"), 
                         MethodAsync("bar") };

And we'll compare that to using Task.Run :

var tasks = new Task[] { Task.Run(() => MethodAsync("foo")), 
                         Task.Run(() => MethodAsync("bar")) };

First, let's get the quick answer out of the way. The first variant will have lower or equal parallelism to the second variant. Parts of MethodAsync will run the caller thread in the first case, but not in the second case. How much this actually affects the parallelism depends entirely on the implementation of MethodAsync .

To get a bit deeper, we need to understand how async methods work. We have a method like:

async Task MethodAsync(string argument)
{
  DoSomePreparationWork();
  await WaitForIO();
  await DoSomeOtherWork();
}

What happens when you call such a method? There is no magic . The method is a method like any other, just rewritten as a state machine (similar to how yield return works). It will run as any other method until it encounters the first await . At that point, it may or may not return a Task object. You may or may not await that Task object in the caller code. Ideally, your code should not depend on the difference. Just like yield return , await on a (non-completed.) task returns control to the caller of the method, Essentially: the contract is:

  • If you have CPU work to do, use my thread.
  • If whatever you do would mean the thread isn't going to use the CPU, return a promise of the result (a Task object) to the caller.

It allows you to maximize the ratio of what CPU work each thread is doing. If the asynchronous operation doesn't need the CPU, it will let the caller do something else. It doesn't inherently allow for parallelism, but it gives you the tools to do any kind of asynchronous operation, including parallel operations. One of the operations you can do is Task.Run , which is just another asynchronous method that returns a task, but which returns to the caller immediately.

So, the difference between:

MethodAsync("foo");
MethodAsync("bar");

and

Task.Run(() => MethodAsync("foo"));
Task.Run(() => MethodAsync("bar"));

is that the former will return (and continue to execute the next MethodAsync ) after it reaches the first await on a non-completed task , while the latter will always return immediately.

You should usually decide based on your actual requirements:

  • Do you need to use the CPU efficiently and minimize context switching etc., or do you expect the async method to have negligible CPU work to do? Invoke the method directly.
  • Do you want to encourage parallelism or do you expect the async method to do interesting amounts of CPU work? Use Task.Run .

Here is your code rewritten without async/await, with old-school continuations instead. Hopefully it will make it easier to understand what's going on.

public Task CompoundMethodAsync(int i)
{
    doCPUBoundWork(i);
    return doIOBoundWorkAsync(i).ContinueWith(_ =>
    {
        doMoreCPUBoundWork(i);
    });
}

// Main thread
var tasks = new List<Task>();
for (int i = 0; i < 10; i++)
{
    Task task = CompoundMethodAsync(i);
    tasks.Add(task);
}
// The doCPUBoundWork has already ran synchronously 10 times at this point

// Do various things while the compound tasks are progressing concurrently

Task.WhenAll(tasks).ContinueWith(_ =>
{
    // The doIOBoundWorkAsync/doMoreCPUBoundWork have completed 10 times at this point
    // Do various things after all compound tasks have been completed
});

// No code should exist here. Move everything inside the continuation above.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM