Why a simple await Task.Delay(1) enables parallel execution?

Viewed 1368

I would like to ask simple question about code bellow:

static void Main(string[] args)
{
    MainAsync()
        //.Wait();
        .GetAwaiter().GetResult();
}

static async Task MainAsync()
{
    Console.WriteLine("Hello World!");

    Task<int> a = Calc(18000);
    Task<int> b = Calc(18000);
    Task<int> c = Calc(18000);

    await a;
    await b;
    await c;

    Console.WriteLine(a.Result);
}

static async Task<int> Calc(int a)
{
    //await Task.Delay(1);
    Console.WriteLine("Calc started");

    int result = 0;

    for (int k = 0; k < a; ++k)
    {
        for (int l = 0; l < a; ++l)
        {
            result += l;
        }
    }

    return result;
}

This example runs Calc functions in synchronous way. When the line //await Task.Delay(1); will be uncommented, the Calc functions will be executed in a parallel way.

The question is: Why, by adding simple await, the Calc function is then async? I know about async/await pair requirements. I'm asking about what it's really happening when simple await Delay is added at the beginning of a function. Whole Calc function is then recognized to be run in another thread, but why?

Edit 1:

When I added a thread checking to code:

static async Task<int> Calc(int a)
{
    await Task.Delay(1);
    Console.WriteLine(Thread.CurrentThread.ManagedThreadId);

    int result = 0;

    for (int k = 0; k < a; ++k)
    {
        for (int l = 0; l < a; ++l)
        {
            result += l;
        }
    }

    return result;
}

it is possible to see (in console) different thread id's. If await Delay line is deleted, the thread id is always the same for all runs of Calc function. In my opinion it proves that code after await is (can be) runned in different threads. And it is the reason why code is faster (in my opinion of course).

4 Answers

It's important to understand how async methods work.

First, they start running synchronously, on the same thread, just like every other method. Without that await Task.Delay(1) line, the compiler will have warned you that the method would be completely synchronous. The async keyword doesn't, by itself, make your method asynchronous. It just enables the use of await.

The magic happens at the first await that acts on an incomplete Task. At that point the method returns. It returns a Task that you can use to check when the rest of the method has completed.

So when you have await Task.Delay(1) there, the method returns at that line, allowing your MainAsync method to move to the next line and start the next call to Calc.

How the continuation of Calc runs (everything after await Task.Delay(1)) depends on if there is a "synchronization context". In ASP.NET (not Core) or a UI application, for example, the synchronization context controls how the continuations run and they would run one after the other. In a UI app, it would be on the same thread it started from. In ASP.NET, it may be a different thread, but still one after the other. So in either case, you would not see any parallelism.

However, because this is a console app, which does not have a synchronization context, the continuations happen on any ThreadPool thread as soon as the Task from Task.Delay(1) completes. That means each continuation can happen in parallel.

Also worth noting: starting with C# 7.1 you can make your Main method async, eliminating the need for your MainAsync method:

static async Task Main(string[] args)

An async function returns the incomplete task to the caller at its first incomplete await. After that the await on the calling side will await that task to become complete.

Without the await Task.Delay(1), Calc() does not have any awaits of its own, so will only return to the caller when it runs to the end. At this point the returned Task is already complete, so the await on the calling site immediately uses the result without actually invoking the async machinery.

layman's version....

nothing in the process is yielding CPU time back without 'delay' and so it doesn't give anything else CPU time, you are confusing this with multiple threaded code. "async and await" is not about multiple threaded but about using the CPU (thread/threads) when its doing non CPU work" aka writing to disk. Writing to disk does not need the thread (CPU). So when something is async, it can free the thread and be used for something else instead of waiting for non CPU (oi task) to complete.

@sunside is saying the same thing just more technically.

static async Task<int> Calc(int a)
{
    //faking a asynchronous .... this will give this thread to something else 
    // until done then return here...
    // does not make sense... as your making this take longer for no gain.
    await Task.Delay(1);


    Console.WriteLine("Calc started");

    int result = 0;
   
    for (int k = 0; k < a; ++k)
    {
        for (int l = 0; l < a; ++l)
        {
            result += l;
        }
    }

    return result;
}

vs

static async Task<int> Calc(int a)
{
    
    using (var reader = File.OpenText("Words.txt"))
    {
        //real asynchronous .... this will give this thread to something else 
        var fileText = await reader.ReadToEndAsync();
        // Do something with fileText...
    }
        
    Console.WriteLine("Calc started");

    int result = 0;
   
    for (int k = 0; k < a; ++k)
    {
        for (int l = 0; l < a; ++l)
        {
            result += l;
        }
    }

    return result;
}

the reason it looks like its in "parallel way" is that its just give the others tasks CPU time.

example; aka without delay

  • await a; do this (no actual aysnc work)
  • await b; do this (no actual aysnc work)
  • await c; do this (no actual aysnc work)

example 2;aka with delay

  • await a; start this then pause this (fake async), start b but come back and finish a
  • await b; start this then pause this (fake async), start c but come back and finish b
  • await c; start this then pause this (fake async), come back and finish c

what you should find is although more is started sooner, the overall time will be longer as it as to jump between tasks for no benefit with a faked asynchronous task. where as, if the await Task.Delay(1) was a real async function aka asynchronous in nature then the benefit would be it can start the other work using the thread which would of been blocked... while it waits for something which does not require the thread.

update silly code to show its slower... Make sure you are in "Release" mode you should always ignore the first run... these test are silly and you would need to use https://github.com/dotnet/BenchmarkDotNet to really see the diff

static void Main(string[] args)
{
    Console.WriteLine("Exmaple1 - no Delay, expecting it to be faster, shorter times on average");
    for (int i = 0; i < 10; i++)
    {
        Exmaple1().GetAwaiter().GetResult();
    }

    Console.WriteLine("Exmaple2- with Delay, expecting it to be slower,longer times on average");
    for (int i = 0; i < 10; i++)
    {
        Exmaple2().GetAwaiter().GetResult();
    }
}


static async Task Exmaple1()
{
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.Start();
    Task<int> a = Calc1(18000); await a;
    Task<int> b = Calc1(18000); await b;
    Task<int> c = Calc1(18000); await c;
    stopwatch.Stop();
    Console.WriteLine("Time elapsed: {0}", stopwatch.Elapsed);
}

static async Task<int> Calc1(int a)
{
    int result = 0;
    for (int k = 0; k < a; ++k) { for (int l = 0; l < a; ++l) { result += l; } }
    return result;
}

static async Task Exmaple2()
{
    Stopwatch stopwatch = new Stopwatch();
    stopwatch.Start();
    Task<int> a = Calc2(18000); await a;
    Task<int> b = Calc2(18000); await b;
    Task<int> c = Calc2(18000); await c;
    stopwatch.Stop();
    Console.WriteLine("Time elapsed: {0}", stopwatch.Elapsed);
}

static async Task<int> Calc2(int a)
{
    await Task.Delay(1);
    int result = 0;
    for (int k = 0; k < a; ++k){for (int l = 0; l < a; ++l) { result += l; } }
    return result;
}

By using an async/await pattern, you intend for your Calc method to run as a task:

Task<int> a = Calc(18000);

We need to establish that tasks are generally asynchronous in their nature, but not parallel - parallelism is a feature of threads. However, under the hood, some thread will be used to execute your tasks on. Depending on the context your running your code in, multiple tasks may or may not be executed in parallel or sequentially - but they will be (allowed to be) asynchronous.

One good way of internalizing this is picturing a teacher (the thread) answering question (the tasks) in class. A teacher will never be able to answer two different questions simultaneously - i.e. in parallel - but she will be able to answer questions of multiple students, and can also be interrupted with new questions in between.


Specifically, async/await is a cooperative multiprocessing feature (emphasis on "cooperative") where tasks only get to be scheduled onto a thread if that thread is free - and if some task is already running on that thread, it needs to manually give way. (Whether and how many threads are available for execution is, in turn, dependent on the environment your code is executing in.)

When running Task.Delay(1) the code announces that it is intending to sleep, which signals to the scheduler that another task may execute, thereby enabling asynchronicity. The way it's used in your code it is, in essence, a slightly worse version of Task.Yield (you can find more details about that in the comments below).

Generally speaking, whenever you await, the method currently being executed is "returned" from, marking the code after it as a continuation. This continuation will only be executed when the task scheduler selects the current task for execution again. This may happen if no other task is currently executing and waiting to be executed - e.g. because they all yielded or await something "long-running".

In your example, the Calc method yields due to the Task.Delay and execution returns to the Main method. This, in turn enters the next Calc method and the pattern repeats. As was established in other answers, these continuations may or may not execute on different threads, depending on the environment - without a synchronization context (e.g. in a console application), it may happen. To be clear, this is neither a feature of Task.Delay nor async/await, but of the configuration your code executes in. If you require parallelism, use proper threads or ensure that your tasks are started such that they encourage use of multiple threads.

In another note: Whenever you intend to run synchronous code in an asynchronous manner, use Task.Run() to execute it. This will make sure it doesn't get in your way too much by always using a background thread. This SO answer on LongRunning tasks might be insightful.

Related