.NET async/await Model’s Control Flow and Thread Behavior
A simple experiment on how control flows and when threads are created.
.NET offers writing asynchronous code which resembles synchronous code. Asynchronous programming model of .NET is a language-level construct, meaning we don’t use classes to juggle threads or to avoid data races, compiler writes those codes for us.
So, we developers focus more on our algorithms by putting multi-threading issues out of mind. All needed is the usage of async and await keywords. Sounds great! 😎
There are lots of resources about when and how to use this model, I developed several programs using it, but I can never develop empathy on how control really flows and exactly when threads are created. So, I performed a simple experiment by a console application.
Synchronous Example
As a starting point, let’s look at a synchronous code and its output. At below code, loop at Main() repeats until user presses escape key. Main() calls F1(), which calls F2(), which calls F3(). Thread.Sleep() methods emulate CPU-bound work.
LogState() method logs time in milliseconds, current thread id, active thread count and step. Step being a string that indicates current method’s name, position in the method, with heading spaces to visualize call depth.
- > F2 means start of Function level 2
- – F2 means middle of Function level 2
- < F2 means end of Function level 2
using System.Diagnostics;
namespace AsyncAwaitMechanism
{
class Program
{
static void Main()
{
do
{
Stopwatch sw = Stopwatch.StartNew();
Functions functions = new(sw);
functions.LogTitle();
functions.LogState(" > Main");
functions.F1();
functions.LogState(" < Main");
} while (Console.ReadKey(true).Key != ConsoleKey.Escape);
}
}
class Functions(Stopwatch sw)
{
public int msDelay = 50;
public void F1()
{
LogState(" > F1");
Thread.Sleep(msDelay);
LogState(" - F1");
F2();
LogState(" < F1");
}
void F2()
{
LogState(" > F2");
Thread.Sleep(msDelay);
LogState(" - F2");
F3();
LogState(" < F2");
}
void F3()
{
LogState(" > F3");
// emulate CPU-bound work
Thread.Sleep(msDelay);
LogState(" < F3");
}
public void LogTitle()
{
Console.WriteLine("Time\tTh.Id\tTh.Cnt\tStep");
}
public void LogState(string step)
{
// log time, current thread id, active thread count and step
ThreadPool.GetMaxThreads(out int maxWorkerCount, out _);
ThreadPool.GetAvailableThreads(out int availableWorkerCount, out _);
int activeWorkerThreads = maxWorkerCount - availableWorkerCount;
Console.WriteLine(sw.ElapsedMilliseconds
+ "\t" + Environment.CurrentManagedThreadId
+ "\t" + activeWorkerThreads
+ "\t" + step);
}
}
}
Example output is below. There is only the primary thread with id 1, no worker threads, expected step sequence, nothing special. Since the primary thread is always busy with running the sequence, it would freeze window at a GUI application.
Time Th.Id Th.Cnt Step
0 1 0 > Main
0 1 0 > F1
51 1 0 - F1
52 1 0 > F2
115 1 0 - F2
115 1 0 > F3
178 1 0 < F3
179 1 0 < F2
179 1 0 < F1
179 1 0 < Main
Asynchronous Example
When a method body uses await, that method must be an async Task. Caller should call that method via await, so caller method must be an async Task as well. This causes an async/await chain which propagates through the call stack. This chain’s end is async Task Main() at console applications, and async void EventHandler() at GUI applications.
Below code is asynchronous version of the previous code.
using System.Diagnostics;
namespace AsyncAwaitMechanism
{
class Program
{
static async Task Main()
{
do
{
Stopwatch sw = Stopwatch.StartNew();
Functions functions = new(sw);
functions.LogTitle();
functions.LogState(" > Main");
await functions.F1();
functions.LogState(" < Main");
} while (Console.ReadKey(true).Key != ConsoleKey.Escape);
}
}
class Functions(Stopwatch sw)
{
public int msDelay = 50;
public async Task F1()
{
LogState(" > F1");
await Task.Delay(msDelay);
LogState(" - F1");
await F2();
LogState(" < F1");
}
async Task F2()
{
LogState(" > F2");
await Task.Delay(msDelay);
LogState(" - F2");
await Task.Run(F3);
LogState(" < F2");
}
void F3()
{
LogState(" > F3");
// emulate CPU-bound work
Thread.Sleep(msDelay);
LogState(" < F3");
}
public void LogTitle()
{
Console.WriteLine("Time\tTh.Id\tTh.Cnt\tStep");
}
public void LogState(string step)
{
// log time, current thread id, active thread count and step
ThreadPool.GetMaxThreads(out int maxWorkerCount, out _);
ThreadPool.GetAvailableThreads(out int availableWorkerCount, out _);
int activeWorkerThreads = maxWorkerCount - availableWorkerCount;
Console.WriteLine(sw.ElapsedMilliseconds
+ "\t" + Environment.CurrentManagedThreadId
+ "\t" + activeWorkerThreads
+ "\t" + step);
}
}
}
Example output from first 2 loops is below. Step sequence is the same as before, but this time, while it starts on the primary thread with id 1, it is handed to a worker thread at first await Task line.
The important observations here are:
- await functions.F1() line in Main() body does not hand the sequence to a worker thread, the sequence continues on the primary thread instead.
- await Task.Delay(msDelay) line in F1() body does hand the sequence to a worker thread. The primary thread’s control jumps back to running application’s event loop, to handle things like GUI actions.
- At all following loops, only a worker thread runs the sequence, including Main(). It’s because Main() itself is async and worker thread’s control never leaves it whilst the do loop in it repeats.
Time Th.Id Th.Cnt Step
3 1 0 > Main
4 1 0 > F1
55 6 1 - F1
55 6 1 > F2
119 6 1 - F2
119 11 1 > F3
183 11 1 < F3
183 11 1 < F2
183 11 1 < F1
183 11 1 < Main
Time Th.Id Th.Cnt Step
0 11 1 > Main
0 11 1 > F1
54 11 2 - F1
56 11 1 > F2
119 11 1 - F2
119 11 2 > F3
181 11 1 < F3
182 11 1 < F2
182 11 1 < F1
182 11 1 < Main
Simple Experiment
So, where do we draw the line between the primary thread’s domain and worker threads’ domain? The top most async method in the call stack, of type Task or void, is the soft boundary between the primary thread and worker threads.
In the previous example, that boundary was the Main(). In this experiment, we will move that boundary from Main() to F1(), and see what will happen.
In order to achieve this, first we make Main() a void. Now we must call functions.F1() without await. Doing so will give compiler warning CS4014. To fix this warning, we make F1() an async void. Note that this is an exceptional type which is normally used at event handlers, and exceptions thrown in an async void method can’t be caught outside of that method.
Example output from first 2 loops is below. Step sequence is not the same as before this time.
Time Th.Id Th.Cnt Step
3 1 1 > Main
4 1 1 > F1
6 1 2 < Main
65 6 1 - F1
66 6 1 > F2
129 6 1 - F2
130 8 1 > F3
192 8 1 < F3
192 8 1 < F2
193 8 1 < F1
Time Th.Id Th.Cnt Step
0 1 0 > Main
0 1 0 > F1
0 1 0 < Main
61 8 2 - F1
61 8 1 > F2
125 8 2 - F2
125 8 2 > F3
189 8 1 < F3
190 8 1 < F2
190 8 1 < F1
The important observations here are:
- Step sequence still is handed to a worker thread at first await Task line of F1(). But since we made Main() a non-async void, the primary thread’s control jumped back to running Main(), instead of application’s event loop as in the previous example.
- When the primary thread hands a task to a worker thread, its control jumps back to the first non-async caller in the call stack.
- At all following loops, the primary thread continues on the do loop, and a worker thread continues on the sub-sequence. It’s because Main() is not async this time, and the primary thread’s control never leaves it whilst the do loop in it repeats.
To generalize the observations in this article, below explanation can be written about the async/await mechanism.
When the primary thread’s control hits an async method, it marks that point and enters worker thread’s domain. And when it hits an await Task line, it hands the task to a worker thread, which is assigned by thread pool. The primary thread then jumps back to the point it had marked, and continues running its own domain. The call at that marked point is the soft boundary between the primary thread’s domain and worker threads’ domain.
For the sake of completeness, code of this simple experiment with comments is below.
using System.Diagnostics;
namespace AsyncAwaitMechanism
{
class Program
{
static void Main()
{
do
{
Stopwatch sw = Stopwatch.StartNew();
Functions functions = new(sw);
functions.LogTitle();
functions.LogState(" > Main");
// calling an async void method without await keyword
// makes a synchronous call
functions.F1();
functions.LogState(" < Main");
// pause the primary thread to allow worker threads
// to write their logs
Thread.Sleep(functions.msDelay * 4);
} while (Console.ReadKey(true).Key != ConsoleKey.Escape);
}
}
class Functions(Stopwatch sw)
{
public int msDelay = 50;
public async void F1()
{
LogState(" > F1");
// an await Task while at the primary thread
// executes Task on a new worker thread
// and the primary thread's control jumps back
// to the first synchronous caller
await Task.Delay(msDelay);
LogState(" - F1");
// an await while at a worker thread
// executes Task on the same or on a new worker thread
// this behavior depends on the thread pool
// and its threads' active/passive states
await F2();
LogState(" < F1");
}
async Task F2()
{
LogState(" > F2");
await Task.Delay(msDelay);
LogState(" - F2");
await Task.Run(F3);
LogState(" < F2");
}
void F3()
{
LogState(" > F3");
// emulate CPU-bound work
Thread.Sleep(msDelay);
LogState(" < F3");
}
public void LogTitle()
{
Console.WriteLine("Time\tTh.Id\tTh.Cnt\tStep");
}
public void LogState(string step)
{
// log time, current thread id, active thread count and step
ThreadPool.GetMaxThreads(out int maxWorkerCount, out _);
ThreadPool.GetAvailableThreads(out int availableWorkerCount, out _);
int activeWorkerThreads = maxWorkerCount - availableWorkerCount;
Console.WriteLine(sw.ElapsedMilliseconds
+ "\t" + Environment.CurrentManagedThreadId
+ "\t" + activeWorkerThreads
+ "\t" + step);
}
}
}
Happy coding 🤓
References
Asynchronous programming scenarios
Task asynchronous programming model