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:
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 theDebug.Log
call and the synchronous part ofDisconnectAsync
.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.All the magic of
Task
. For example,Task.WhenAll
instantiates mutexes in yourTask
and then waits on them for completion. This makesTask
technically disposable, and thus a memory and OS handle leak if you don't dispose every single task you create.await
itself is handled throughTaskCompletionSource
, 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.