0

I have two implementations of concurrent execution of a Task method within a Foreach loop, both I add to a list of Tasks and execute concurrently with Task.WhenAll(Tasks).

In one implementation, I use Tasks.Add(Task.Run(() => DoSomething(item))), while in the second implementation, I omit the Task.Run() lambda function by just doing Tasks.Add(MyMethodAsync(item)).

I then execute the methods concurrently with (await Task.WhenAll(Tasks)).ToList();, as normal.

The result is that the second implementation has minimal performance improvement over a synchronous implementation, while the first implementation has very noticeable improvement (profiled at about ~11.5s compared to ~6s).

My question is, why does the second implementation not work as well? Shouldn't both Task.Run(lambda) and the direct method call both return a list of Tasks?

// The asynchronous method
public async Task<int> MyMethodAsync(Item item)
{
    Thread.Sleep(1000);  // Emulate CPU bound work
    await Task.Delay(1000);  // Emulate IO bound work
    return 0;  // placeholder int
}
// Implementation 1: with Task.Run lambda function
List<Task<int>> Tasks = new List<Task<int>>();
foreach (Item item in items)
{
    Tasks.Add(Task.Run(() => MyMethodAsync(item)));
}
List<int> itemResults = (await Task.WhenAll(Tasks)).ToList();
// Implementation 2: without Task.Run lambda function
List<Task<int>> Tasks = new List<Task<int>>();
foreach (Item item in items)
{
    Tasks.Add(MyMethodAsync(item));
}
List<int> itemResults = (await Task.WhenAll(Tasks)).ToList();
  • 2
    `DoSomething(Item item)` is *not* an asynchronous method as stated in the comment. And *twice as fast* -> any numbers to show? What are you trying to prove? – Peter Bons Jan 15 '21 at 14:15
  • Just adding `async` does not make a method asynchronous. It needs to perform an asynchronous action. Also don't confuse speed running once and resource speed. Your first implementation may run "faster" on your machine but it's also using a lot more resource. This may make it **slower** when in a proper production env – Liam Jan 15 '21 at 14:17
  • Have you profiled and or performed a benchmark? – Trevor Jan 15 '21 at 14:18
  • 1
    The method `DoSomething` actually does nothing. It just returns the value `0`. In order to make a meaningful comparison, you should give it some work to do. Usually we simulate CPU-bound work with `Thread.Sleep(1000)`, and I/O-bound work with `await Task.Delay(1000)`. – Theodor Zoulias Jan 15 '21 at 14:35
  • @Codexer so I've left out the construction of DoSomething as it was just a placeholder, with my implementation, the benchmark was: 12s with synchronous implementation, 11.5s with the second implementation, and 6s with my first implementation. My question is strictly asking about the difference between ```Tasks.Add(Task.Run(() => DoSomething(item)));``` and ```Tasks.Add(DoSomething(item));```, as that is the only change I've made between implementations 1 and 2 –  Jan 15 '21 at 14:53
  • @TheodorZoulias sorry, I should've clarified that I have an implementation in my codebase, I just wrote a placeholder method to present my question which is really focused on the difference between ```Tasks.Add(Task.Run(() => DoSomething(item)));``` and ```Tasks.Add(DoSomething(item));``` –  Jan 15 '21 at 14:57
  • @PeterBons got it, I think I understand - ```DoSomething``` itself is not asynchronous, the asynchronity comes from the method that calls ```await DoSomething(item)```? Please see my response to @Codexer for a response to your questions. –  Jan 15 '21 at 15:01
  • Justin my suggestion is to [edit] the question and include the code that is missing from the `DoSomething` method. You may think that it's not important, but it actually is. If for example you put a simple `await Task.Delay(1000)` in there, I am pretty sure that the performance of both approaches will be roughly the same. – Theodor Zoulias Jan 15 '21 at 15:07
  • @TheodorZoulias thank you, I've made edits to clarify the question. I think my questoin still remains though, as my own implementation of ```DoSomething``` (I've changed it to ```MyMethodAsync``` in the question) had CPU-bound work occurring, and the first implementation was still twiec as fast (6s compared to 11.5s). Are you suggesting that this shouldn't be happening and that both implementations should yield the same results? –  Jan 15 '21 at 15:16
  • Aha. So there is no `await` inside the `MyMethodAsync` method. Are you getting a warning in the Visual Studio, about an `async` method lacking an `await` operator? – Theodor Zoulias Jan 15 '21 at 15:24
  • @TheodorZoulias man sorry for the poorly phrased setup, there is ```await``` in my personal implementation, I didn't think it was necessary to show because I thought it would be the exterior implementation of the Task.Run( lambda ) that makes the difference, because the async method itself will be the same in both implementations –  Jan 15 '21 at 17:15
  • 1
    @justinattw It highly depends on how much CPU-bound work there is basically before the first await in your async method. – poke Jan 15 '21 at 17:18
  • 1
    Justin hmm, could you edit the question and modify the implementation of the dummy `MyMethodAsync` method, so that is a better representation of your actual method, and reproduces the observed difference in performance? – Theodor Zoulias Jan 15 '21 at 17:23
  • Hi @Servy. The OP updated their question, by showing a method implementation that is partially synchronous and partially asynchronous. Could you check if this question still qualifies as a duplicate of [that](https://stackoverflow.com/questions/49668287/why-async-method-always-blocking-thread) question, after the update? – Theodor Zoulias Jan 16 '21 at 16:37
  • @TheodorZoulias I don't see how it's not a duplicate. They want to know why the method isn't running asynchronously, the duplicate explains why it's not running asynchronously. – Servy Jan 16 '21 at 17:23
  • @Servy hmmm. AFAICS the OP is asking for an explanation about why two similar implementations, one with `Task.Run` and one without, differ in duration of execution. I don't see them asking why a method isn't running asynchronously. Am I missing something? – Theodor Zoulias Jan 16 '21 at 17:47
  • @TheodorZoulias Well the implementations aren't similar at all. One is asynchronous, and one is synchronous, the difference in behavior is strictly that one behaves asynchronously and one synchronously. The duplicate explains why. – Servy Jan 16 '21 at 18:33
  • @Servy after the last OP's edit, the `MyMethodAsync` has not a purely synchronous implementation. It now has a mixed synchronous/asynchronous implementation. Do you think that the [linked](https://stackoverflow.com/questions/49668287/why-async-method-always-blocking-thread) question covers this scenario? If not, and if I would like to post an answer to address it, would be on topic to post my answer to the linked question? – Theodor Zoulias Jan 16 '21 at 18:58
  • @TheodorZoulias Why does it matter that it's just mostly synchronous? It still explains why it's mostly synchronous and why that's a problem for them. – Servy Jan 16 '21 at 22:32
  • @Servy this can be obvious for someone who has a deep understanding of async/await already, but may not be so obvious for someone who is new to the concept and is struggling to put the pieces together. Anyway, I'll let the OP present further their case, if they think that their question is not the same as the [linked](https://stackoverflow.com/questions/49668287/why-async-method-always-blocking-thread) question. – Theodor Zoulias Jan 17 '21 at 04:33

1 Answers1

0
public async Task<int> MyMethodAsync(Item item)
{
    Thread.Sleep(1000);  // This is just a placeholder method
    return 0;  // placeholder int
}

This method, as you have written it, is solely CPU bound. It keeps the CPU busy and as such requires “full attention” of a thread in order for it be processed. It being marked as async and it returning a Task does not mean that there is anything happening asynchronously here. So this mostly equivalent to the following fully synchronous method:

public Task<int> MyMethod(Item item)
{
    Thread.Sleep(1000);
    return Task.FromResult(0); // this is synchronous!
}

So when you execute this method, it blocks the CPU for that one second and then returns something which happens to be a completed task.

Now, looking at how you are running your test, this also explains what you are seeing. Let’s take the second implementation first:

foreach (Item item in items)
{
    Tasks.Add(MyMethodAsync(item));
}

As we realized, MyMethodAsync is fully synchronous. So executing the method here blocks the loop from continuing to the next item before the method returns. That means that for 10 items, the loop completes in approximately 10 seconds and the list will only contain completed tasks then (which will be awaited on instantly).

Compare this to the first implementation:

foreach (Item item in items)
{
    Tasks.Add(Task.Run(() => MyMethodAsync(item)));
}

Task.Run is an easy way to offload something onto a thread from the thread pool. This means that you basically create a job for some other thread to execute in the beackground. And Task.Run gives you a task that represents the completion of that job. Since you only call MyMethodAsync inside the lambda function that you pass to Task.Run, this will not block the loop itself. So in the end, this will create 10 individual jobs that will be executed on other threads which will then happen to call the synchronous MyMethodAsync.

Instead of blocking the execution of your main thread which is executing the loop, you are now blocking some thread pool threads. Those threads run concurrently so this will be faster than making each synchronous call in sequence on the main thread. That is why using Task.Run here yields faster results.


That all being said, when you are using Task.Run for this, you are still blocking some threads. Since thread pool threads are generally limited, you usually want to avoid this. Of course, there are exceptions and using Task.Run for CPU-bound work to offload it to a background thread is generally okay, but you should still be careful here since it’s not uncommon that you run out of available threads.

Instead, you should try to do actual asynchronous work here. When something is truly asynchronous, that means that the thread executing the method is released to do something else until the asynchronous work is complete. That means that you are reusing threads more effectively and generally don’t block for things that don’t need to block.

If you replace your method with an asynchronous implementation, you can see this in effect here:

public async Task<int> MyMethodAsync(Item item)
{
    await Task.Delay(1000);
    return 0;
}

Task.Delay is truly asynchronous, so this method will not block the thread that called it. This should mean that none of your two implementations will block and both will just start the work while then waiting for it to complete.

I’ve tested this with 10000 items and basically saw no real difference. I had a difference of up to 20 milliseconds (in either direction) but I am willing to ignoring this due to not having a good benchmark environment for this particular test.

If anything, using Task.Run here, with an asynchronous method, should be slightly less efficient because it still schedules a thread to run the asynchronous method (meaning that scheduled thread will be released immediately). So there is a slight overhead—which still can be ignored in most applications.

poke
  • 369,085
  • 72
  • 557
  • 602
  • Sorry for the poorly structured and phrased question, MyMethodAsync is indeed an asynchronous method so I should have used ```await Task.Delay(1000)``` to have demonstrated this - it was an oversight as I was focused on the external implementation of Task.Run( lambda ). That said, I do still see a difference between the two implementations, but thank you for explaining what happens computationally, I will do further testing and check that I haven't made a mistake somewhere. –  Jan 15 '21 at 17:18
  • 2
    If you are able to show a more realistic version of your `MyMethodAsync`, then maybe we could give an explanation as to where that difference comes from. Simplified, it should depend on how much CPU-work happens before the first `await` in your method. – poke Jan 15 '21 at 17:19