“Make it asynchronous, then it is faster” is one of the most common misconceptions in .NET. Asynchronous means: while waiting, the thread is free for something else. Parallel means: several cores compute at the same time. One helps with waiting, the other with computing, and whoever confuses the two builds code that gets slower under load instead of faster. This lesson separates the two cleanly and shows how many calls at once is the right number: for the Kladde’s import it is four, and you measure why.
Waiting or computing?
There are two kinds of work:
- I/O-bound: the program waits for something outside, the database, the network, a file. The processor has nothing to do in the meantime. Almost everything the Kladde does is of this kind.
- CPU-bound: the processor computes the whole time, for example when evaluating large amounts of data in memory, compressing or hashing passwords (lesson 3.3.6).
For I/O, await helps: the thread goes back into the pool while waiting. For computation only another core helps, and you
get that with parallelism. How differently the two work is shown by a small program with twenty waits and sixteen calculations:
// waiting-and-computing.cs: twenty waits and sixteen calculations, one after another and side by side
#:property PublishAot=false
using System.Diagnostics;
using System.Globalization;
CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US");
Console.WriteLine($"Logical cores: {Environment.ProcessorCount}");
await Measure("20 x waiting, one after another", async () =>
{
for (int i = 0; i < 20; i++)
{
await Task.Delay(200);
}
});
await Measure("20 x waiting, Task.WhenAll", () => Task.WhenAll(Enumerable.Range(0, 20).Select(_ => Task.Delay(200))));
await Measure("16 x computing, one after another", () =>
{
for (int i = 0; i < 16; i++)
{
CountPrimes(1_000_000);
}
return Task.CompletedTask;
});
await Measure("16 x computing, Task.WhenAll without Task.Run", () =>
Task.WhenAll(Enumerable.Range(0, 16).Select(_ => Task.FromResult(CountPrimes(1_000_000)))));
await Measure("16 x computing, Task.WhenAll with Task.Run", () =>
Task.WhenAll(Enumerable.Range(0, 16).Select(_ => Task.Run(() => CountPrimes(1_000_000)))));
static async Task Measure(string name, Func<Task> work)
{
var watch = Stopwatch.StartNew();
await work();
Console.WriteLine($"{name,-46} {watch.Elapsed.TotalSeconds,5:0.00} s");
}
// Pure work for the processor: no waiting, only arithmetic.
static int CountPrimes(int limit)
{
int count = 0;
for (int n = 2; n < limit; n++)
{
bool prime = true;
for (int d = 2; d * d <= n; d++)
{
if (n % d == 0)
{
prime = false;
break;
}
}
if (prime)
{
count++;
}
}
return count;
}
On a test machine with 32 logical cores, then once more with DOTNET_PROCESSOR_COUNT=2, as if it had two; your numbers are different:
Logical cores: 32
20 x waiting, one after another 4.28 s
20 x waiting, Task.WhenAll 0.21 s
16 x computing, one after another 2.42 s
16 x computing, Task.WhenAll without Task.Run 2.73 s
16 x computing, Task.WhenAll with Task.Run 0.23 s
Logical cores: 2
20 x waiting, one after another 4.25 s
20 x waiting, Task.WhenAll 0.21 s
16 x computing, one after another 2.32 s
16 x computing, Task.WhenAll without Task.Run 2.37 s
16 x computing, Task.WhenAll with Task.Run 1.19 s
Four observations:
- Waiting at the same time is almost free. Twenty waits of 200 milliseconds take 0.21 seconds together, with two cores just as with 32. Waiting needs no core (lesson 3.11.1).
Task.WhenAlldoes not make computation parallel. WithoutTask.Runeach calculation already runs at the call, on the calling thread, one after the other. TheTaskat the end is only a wrapper around a finished result.Task.Runspreads computation across cores. With 32 cores it gets about ten times faster, with two cores only twice as fast. More than the number of cores is not possible.- A method with
asyncin its name is not automatically asynchronous. What it does before its first realawait, it does on the thread of the caller.