What really happens when you click a button in the Blazor UI is a little hazy for many. In this post/article I'll provide a fairly high level conceptual demonstration of what's going on behind the scenes.
If you're already conversant with the topic, this is probably not for you. The real code implementation is a little different and more complex. Go dig into the code base on GitHub, examime generated code in SharpLabs, or read some of the deep dive articles by the experts and the code writers.
I use a modified version of Counter
for this demonstration.
It has an asynchronous IncrementCount
method that yields control and behaves like a true asynchronous operation.
IHandleEvent.HandleEventAsync
is overridden: it simply calls the event handler, no built in calls to StateHasChanged
. These are now in IncrementCountAsync
so we can see what's really happening.
There's a loader alert displayed while the async operation is running to show the UI is responsive.
Here's Counter
:
@page "/counter" @implements IHandleEvent <PageTitle>Counter</PageTitle> <h1>Counter</h1> <p role="status">Current count: @currentCount</p> <button class="btn btn-primary" @onclick="IncrementCountAsync">Click me</button> @if(_loading) { <div class="alert alert-danger m-2">Loading</div> } @code { private int currentCount = 0; private bool _loading = false; private async Task IncrementCountAsync() { _loading = true; var awaiter = DoSomeAsyncWork.GetNextAsync(currentCount); if(!awaiter.IsCompleted) { this.StateHasChanged(); currentCount = await awaiter; } _loading = false; this.StateHasChanged(); } async Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem item, object? obj) { await item.InvokeAsync(obj); } }
DoSomeAsyncWork
is the class wrapper for the async operation. It has one static public method: GetNextAsync
. It uses a timer to provide the async functionality. We'll see how it works as we walk through the demo.
public class DoSomeAsyncWork { private TaskCompletionSource<int> _taskCompletionSource = new(); private Timer? _timer; private int _count; private Task<int> GetAsync(int value, int delay = 1000) { _count = value; _timer = new(OnTimerExpired, null, delay, 0); _taskCompletionSource = new(); return _taskCompletionSource.Task; } private void OnTimerExpired(object? state) { _count++; _taskCompletionSource.SetResult(_count); _timer?.Dispose(); } public static Task<int> GetNextAsync(int value, int delay = 2000) { var work = new DoSomeAsyncWork(); return work.GetAsync(value, delay); } }
All UI based applications use a synchronisation context to manage UI activity.
The Blazor Synchronisation Context manages operations that apply changes to the DOM. It's designed to:
At the heart of a Synchronisation Context is a message loop/pump/queue [call it what you wish] that processes delegates posted on queues.
A short digression on delegates if you don't know what they are. A delegate is a type that represents references to methods with a particular signature: parameter list and return type. When you instantiate a delegate, you can associate its instance with any method with a compatible signature and return type. You can invoke (or call) the method through the delegate instance.
In the Blazor context there's:
InvokeAsync
on a component posts the submitted delegate to this queue.RenderFragments
to the synchronisation context's Post queue. Calling StateHasChanged
places a render fragment in this queue.We'll see these queues in action shortly.
When Counter
is rendered, the Blazor JS environment registers a handler with the browser on the button click event.
When you click the button, that event is fired, and transmitted through JSInterop into the Blazor SPA session. The relevant event handler, in our case GetNextCountAsync
, is queued on the UI Event Queue.
Assuming the synchronisation context is idle, GetNextCountAsync
executes immediately. This is our primary execution code block.
The execution sequence is:
_loading
to true in the Counter
instance.GetNextAsync
.DoSomeAsyncWork
.GetAsync
on the instance.TaskCompletionSource<int>
instance.Task
associated with the TaskCompletionSource<int>
instance.Dissecting this:
Step 1 updates _loading
, mutating the state of the component. It's internal state is now out of sync with it's displayed state.
Steps 2, 3 and 4 call GetNextAsync
which jumps the object context to an instance of DoSomeAsyncWork
created by the static method.
Step 5 saves the count internally. It can be incremented and returned when the async operation completes.
Step 6 creates a System.Threading.Timer
, passing it a TimerCallback
delegate [OnTimerExpired
], a null
state and a period of delay
milliseconds. The repeat interval is 0 so it only runs once.
A short digression on timers. A DotNetCore application has one [and only one] Timer : I refer to this as the Timer Service. It's an object that implements the singleton pattern. It has a queue of registered timers and a message loop to service that queue running on a background thread. It manages system TimeOut timers as well as timers we create. When we create our timer, it's automatically added to the queue. When it completes, the timer service runs the registered
TimerCallback
on a threadpool thread, passing it the provided nullablestate
object.
When we post the timer to the queue, we pass responsibility to invoke the call back to the timer service.
Step 7 creates a TaskCompletionSource
instance.
Another digression.
TaskCompletionSource
provides mechanisms for manually creating and controlling aTask
. It's associatedTask
is "running" when theTaskCompletionSource
initializes:IsCompleted
isfalse
. It can be set to cancelled, an exception or complete at any time. When initialized, the Task captures the current synchronisation context which it uses to post registered continuations ifConfigureAwait
istrue
[the default].
Step 8 returns the associated Task
: note IsCompleted
is false
.
GetNextAsync
is now complete. Object execution is now back in IncrementCountAsync
in Counter
. It checks the state of the returned Task.
It's incomplete so calls StateHasChanged
: that's OK because the execution context is the synchronisation context. StateHasChanged
passes the component render fragment to the renderer, which wraps it in an anonymous method and posts it to the synchronisation context's Post queue.
Note that the synchronisation context now has a queued post as well as the running code.
The next step is to await the awaiter provided by the returned task.
Yet another digression.
async .... await
will only await a method that returns an object that implements the awaitable pattern: aGetAwaiter
method that returns an object that implements the awaiter pattern.Task
in all it's guises implements this pattern.
In the compiled code any code block following an await
is bundled up into a separate code block. Our's looks like this::
{
currentCount = awaiter.Result;
_loading = false;
this.StateHasChanged();
}
The main code block's final action is to add this continuation to the awaiter. It passes responsibility for running the continuation to the process behind the awaitable. In our case TaskCompletionSource
, when it completes.
At this point it's worth looking at what we have:
DoSomeAsyncWork
in memory.OnTimerElapsed
method of the DoSomeAsyncWork
instance.Task
owned by the DoSomeAsyncWork
instance with a continuation associated with it.RenderFragment
on the synchronisation context.The synchronisation context message loop now executes the queued render fragment. This updates the component's DOM section based on Counter
's state and pushes those updates to the UI. In our case it displays the alert. This in turn triggers an OnAfterRender
UI event which gets queued on the UI Event queue.
The render fragment execution is complete so the synchronisation context is idle. It executes any registered OnAfterRender{Async]
handlers. We don't have any, so it quickly completes.
Pause.
There's nothing happening. Our primary execution thread has run to completion. The Ui has been updated to reflect the current state of Countwr
. The synchronisation context is idle. The timer hasn't expired.
When the timer completes, the timer service schedules the callback on a Threadpool thread. Note, a threadpool thread, not the synchronisation context: the Timer Service has no concept of a synchronisation context.
This code gets executed on that thread:
_count++; _taskCompletionSource.SetResult(_count); _timer?.Dispose();
The key bit of activity is setting the result on _taskCompletionSource
.
Behind the scenes, the TaskCompletionSource
:
Result
property.ConfigureAwait
has been set to true. If not, posts any continuations to the Threadpool.The important action is the execution context switching. The callback code runs on a background threadpool thread, but the continuations are switched to the saved synchronisation context.
The synchronisation context isn't busy, so it runs the posted continuation:
{
currentCount = awaiter.Result;
_loading = false;
this.StateHasChanged();
}
It sets currentCount
to the result from the task, sets the Counter
state, and schedules another render [the details of which we have already covered above].
UI code is executed as blocks of code posted to the synchronisation context as delegates.
A thread can only do one thing at once. It can't watch for something to happen while it's doing something else.
When a method yields control in an await
, whatever is being awaited is running on another thread. It must implement the Awaitable
pattern and is responsible for:
The various incarnations of Task
are the most common awaitables you will use.
When a method yields control, it's finished. There's no black magic. If the Task returned by the method is Not Completed, the method has passed the buck to another process running on another thread to complete the job.
In reality the compiler totally reworks the code in every async .. await
method into a new method and an internal Async State Machine where the original method is split into code blocks based on await statements. I'll cover the Async State Machine in another article.