Home > Blockchain >  What thread runs a Task's continuation if you don't await the task?
What thread runs a Task's continuation if you don't await the task?

Time:12-07

I'm trying to wrap my head around control flow in C# when using async, Task, and await.

I understand how promises work, and that the returned Task<> from an async method will eventually contain the result of a computation/IO/whatever.

I think I understand that if you explicitly wait for that Task, then the current thread blocks until the Task is complete. I also think that means that the code in the async method that returns a Task will be running on a thread in a thread pool.

What I don't understand is what happens if I don't "await" the Task returned by an asynchronous method. It seems to me that the continuation is executed on the original thread that calls the async method, but I have no idea how control can return to that thread.

Here's an example. Here's I'm using UniTask which is basically Tasks for Unity:

    public async UniTask ConnectAsync(Connection connection)
    {
        Debug.Log(Thread.CurrentThread.Name); -> this prints "Main Thread"
        // Close Any Old Connections
        await DisconnectAsync();

        // Default Address
        if (string.IsNullOrEmpty(connection.Address)) { connection.Address = "localhost:6379"; }

        // Connect
        ConfigurationOptions config = new()
        {
            EndPoints =
            {
                { connection.Address, connection.Port },
            },
            User = connection.Username,
            Password = connection.Password,
        };
        m_Connection = await ConnectionMultiplexer.ConnectAsync(config);

        // Create Graph Client
        m_Graph = new(m_Connection.GetDatabase());

        // Notify
        await Editor.Controller.OnConnect();
        Debug.Log(Thread.CurrentThread.Name); -> this prints "Main Thread"
    }

If I call this method, and then neglect to await the returned Task (UniTask), both Debug.Log() show that execution is happening on the "Main Thread" (i.e. the UI thread).

How is it that without awaiting this Task, the Main Thread is able to return to this continuation? Does C# wait until the thread is in the Suspended/WaitSleepJoin state? I'm not aware of any code putting the UI thread to sleep so I'm not sure about that. I'm certainly not putting the UI to sleep.

CodePudding user response:

I understand how promises work

Good, then we can stop right there. Tasks are nothing but compiler syntactic sugar over a promise. In fact, when JavaScript copied the await/async keywords from C#, they got implemented over the native Promise object.

Now, for the remainder of this I'm going to assume that you don't know how promises work. Think of it as getting called out on your promise bluff on your CV.

There's three parts to an async method:

  1. The "synchronous" part. This is what will run when you simply call your async function, awaiting it or not, and is everything before the first await in your function. In your function this is the Debug.Log call and the synchronous part of DisconnectAsync.

  2. The "asynchronous" part, the tail of your function. This gets stored as a lambda and it captures all necessary variables on creation. This gets called after #1 and when "done" it returns the Task object from your function. When the task is fully completed, the task is set as completed. Note that this can be recursive if you have multiple tails inside your tail.

  3. All the magic of Task. For example, Task.WhenAll instantiates mutexes in your Task and then waits on them for completion. This makes Task technically disposable, and thus a memory and OS handle leak if you don't dispose every single task you create. await itself is handled through TaskCompletionSource, and you get just the task it manages. Things like that.

Note that nowhere in this did I mention threads. Tasks are to threads like what cats are to doctors. They both exist, some interact, but you have to be pretty insane to say cats are made to work only with doctors. Instead, tasks work on contexts. Thread pools are one default context. Another is single threaded contexts.

That's right, you can easily have async code run on a single thread, which is perfect for GUI in a single threaded render loop-driven game. You create a dialog, await its showing and get a result, all without any additional threads. You start an animation and await its completion, all without any threads.

CodePudding user response:

What I don't understand is what happens if I don't "await" the Task returned by an asynchronous method. It seems to me that the continuation is executed on the original thread that calls the async method, but I have no idea how control can return to that thread.

As I describe on my blog, each await (by default) captures a "context", which is SynchronizationContext.Current or TaskScheduler.Current. In this particular case, the UI context is captured and used to resume the async method (i.e., execute the continuation).

How is it that without awaiting this Task, the Main Thread is able to return to this continuation? Does C# wait until the thread is in the Suspended/WaitSleepJoin state?

It has to do with contexts, not threads. The UI context schedules work by posting to the main UI message queue. So the continuation is run when the UI thread processes its message queue; it doesn't have anything to do with thread states.

I'm not aware of any code putting the UI thread to sleep so I'm not sure about that. I'm certainly not putting the UI to sleep.

Your code just needs to return to the main loop to allow the continuation to run.

CodePudding user response:

When you call an asynchronous method and don't await the returned Task or UniTask, the method will still be executed asynchronously, but control will immediately return to the caller without waiting for the task to complete. This means that the code in the method that returns the Task or UniTask will be executed on a thread from the thread pool, while the rest of the code in the calling method will continue to execute on the original thread.

In the example you provided, the two calls to Debug.Log are executed on the "Main Thread" because the code in the ConnectAsync method is not being awaited. Instead, the method is being executed asynchronously, and control is immediately returned to the caller without waiting for the task to complete. This means that the code in the ConnectAsync method will be running on a thread from the thread pool, while the calls to Debug.Log in the caller will be executed on the "Main Thread".

If you want the calling thread to wait for the task to complete, you can use the await keyword to suspend the execution of the calling method until the task completes. This will cause the calling thread to block until the task completes, and the continuation of the ConnectAsync method will be executed on the calling thread after the task completes.

For example:

public async UniTask ConnectAsync(Connection connection)
{
    Debug.Log(Thread.CurrentThread.Name); -> this prints "Main Thread"
    // Close Any Old Connections
    await DisconnectAsync();

    // Default Address
    if (string.IsNullOrEmpty(connection.Address)) { connection.Address = "localhost:6379"; }

    // Connect
    ConfigurationOptions config = new()
    {
        EndPoints =
        {
            { connection.Address, connection.Port },
        },
        User = connection.Username,
        Password = connection.Password,
    };
    m_Connection = await ConnectionMultiplexer.ConnectAsync(config);

    // Create Graph Client
    m_Graph = new(m_Connection.GetDatabase());

    // Notify
    await Editor.Controller.OnConnect();
    Debug.Log(Thread.CurrentThread.Name); -> this prints "Main Thread"
}

// ...

// Call the ConnectAsync method and wait for it to complete
await ConnectAsync(connection);

In this example, the ConnectAsync method is still executed asynchronously, but the calling code uses await to wait for the task to complete before continuing. This means that the Debug.Log calls in the ConnectAsync method will be executed on the thread that called ConnectAsync, and the continuation of the ConnectAsync method will be executed on the same thread after the task completes.

  • Related