Question

Different thread with async/await

I've heard a lot about how a new thread is not created with async await. I decided to check what would happen if there was an while(true) in the main function and the asynchronous function.

namespace asyncTest
{
    public class Program
    {

        public static void Task1()
        {
            while (true) {
                Console.WriteLine("Task1: " + Thread.CurrentThread.ManagedThreadId);
                Thread.Sleep(1000);
            }
        }

        public static async void async()
        {
            Console.WriteLine("async start: " + Thread.CurrentThread.ManagedThreadId);
            await Task.Run(Task1);
            Console.WriteLine("async end: " + Thread.CurrentThread.ManagedThreadId);
        }

        static void Main(string[] args)
        {
            async();
            while (true) {
                Console.WriteLine("main: " + Thread.CurrentThread.ManagedThreadId);
                Thread.Sleep(1000);
            }
        }
    }
}

But for some reason my functions are executed in different threads:

enter image description here

I want to understand why they are executed in different threads if async await does not create a thread. And I want to know how high-load operations are performed asynchronously, as in this example. Who performs the asynchronous operation on an additional thread? Or are they executed in the same thread?

 3  102  3
1 Jan 1970

Solution

 3

I've heard a lot about how a new thread is not created with async await.

To quote the classic There Is No Thread by Stephen Cleary (highly recommend to read it, emphasis is mine):

This is an essential truth of async in its purest form: There is no thread.

The objectors to this truth are legion. “No,” they cry, “if I am awaiting an operation, there must be a thread that is doing the wait! It’s probably a thread pool thread. Or an OS thread! Or something with a device driver…”

Heed not those cries. If the async operation is pure, then there is no thread.

This does not mean that there are no threads involved at all though.

First of all you need to know that there are two "main" async scenarios: IO-bound and CPU-bound. From the Asynchronous programming scenarios:

The core of async programming is the Task and Task<T> objects, which model asynchronous operations. They are supported by the async and await keywords. The model is fairly simple in most cases:

  • For I/O-bound code, you await an operation that returns a Task or Task inside of an async method.
  • For CPU-bound code, you await an operation that is started on a background thread with the Task.Run method.

Your example basically simulates a CPU-bound scenario via endless cycle with Thread.Sleep in the Task1. It requires a thread to execute since Thread.Sleep is not an async operation:

Suspends the current thread for the specified amount of time.

Another case when thread is needed - to complete the continuation (i.e. what is written after await), but the thread might or might not be created here - in this case this is managed by the ThreadPool (see also the The managed thread pool doc):

Provides a pool of threads that can be used to execute tasks, post work items, process asynchronous I/O, wait on behalf of other threads, and process timers.

which manages the threads and will reuse them if there are available ones.

Note that in this case no extra thread will be created to perform the "waitng" on per await used basis.

As for IO-bound operations you can simulate an async operation with Task.Delay() for example. Consider the following modification to your code:

static async Task Main(string[] args)
{
    Task1(100);
    Task1(155);
    Task1(205);
    Task1(255);
    Task1(305);
    for (int i = 0; i < 10; i++)
    {
        Console.WriteLine("main: " + Thread.CurrentThread.ManagedThreadId);
        Thread.Sleep(500);
    }
}

public static async Task Task1(int i)
{
    while (true) {
        Console.WriteLine($"Task1 {i}: " + Thread.CurrentThread.ManagedThreadId);
        // Thread.Sleep(1000);
        await Task.Delay(1001 + i);
    }
}

depending on several factors you might see less unique threads in the output than you have simultaneously "running" async operations.

Notes:

  1. There are a lot o nuances here. For example:

    1. For IO-bound operation - it should be implemented correctly and that underlying system/device/driver supports it. For example Oracle driver for quite a long time didn't - see the Can the Oracle managed driver use async/await properly?

    2. Execution of continuation is more complicated too - it involves several factors, including presence of the SynchronizationContext. See also answers to What thread runs the code after the `await` keyword?

  2. async void

    Try avoiding this, in majority of cases it should be async Task (with exception of event handlers which usually are used in desktop/mobile apps)

  3. See also:

2024-07-20
Guru Stron

Solution

 1

By default, async/await do not create new threads. Rather, they allow us to make efficient use of the idle time of an existing thread: times where you're waiting for disk I/O, network I/O, user input, memory latency, screen refresh, awaiting Task objects, etc.

However, Task.Run() queues work on the ThreadPool. This does create a new thread.

Now we know enough to follow this code and understand the output.

We see the code starts by calling the async() method. This method first outputs a message to the Console, still from the original thread. It then invokes the Task1() method on a new thread via Task.Run(). The code in the async() method will never reach the next line to write the ending message to the Console, because it tries to await the result of Task1(), which will never exit the while (true) loop.

However, we also continue to see output from both methods: Main() and Task1(). This is because the async() method is declared as async, but is not called with await. This allows the Main() method that called it to continue running with the idle time on that original thread. We also see "async start" and "Task1" in the output first, before "main", because the Main() is not free to do this (there is no idle time) until we reach the first Thread.Sleep() in Task1().

2024-07-20
Joel Coehoorn