As mentioned in the section on Singleton dependencies, a Singleton registered dependency must either have no state or should contain only state that may be shared across all users on the same server.
And, as mentioned in the section on Scoped dependencies, a Scoped registered dependency isolates an individual user's state away from everyone else (or even the same user accessing the same website in a different browser tab).
But what about thread-safety? When running a server-side application it is very likely that a Singleton registered dependency will be used by more than one thread at a time. Even if we register our dependency as Scoped, it is entirely possible that different components will be rendered by different threads, this is described in detail in the section Multi-threaded rendering.
Because of this, we must consider thread-safety when writing our services.
However, sometimes we do not own the source code for the services we consume, and they might not be thread-safe
(one example being EntityFrameworkCore's DbContext
class).
Demonstrating the problem
First, we'll modify the standard server-side Blazor project so the WeatherForecastService
is no longer thread-safe.
We'll detect if more than one thread is using the service at the same time and throw an InvalidOperationException
,
just as the DbContext
class does.
We'll achieve this by keeping a thread-safe Int32
field that we can increment as the method starts,
and decrement as the method finishes.
If the value is already > 0
when we try to increment it then we can deduce that another thread is already executing the
method and then throw an exception.
After creating the project, edit the /Data/WeatherForecastService.cs file and add a new volatile int
field:
private volatile int Locked;
At the beginning of the method we'll use Interlocked.CompareExchange
framework method to ensure the Locked
value is currently 0
, and then change it from 0
to 1
.
At the end of the method, we'll use Interlocked.Decrement to change the value of Locked
back down to 0
.
We'll also need a delay in the method,
otherwise we have too little a chance of having two threads executing it at exactly the same time.
The GetForecastAsyc
should be altered to the following code.
public class WeatherForecastService
{
private volatile int Locked;
private static readonly string[] Summaries = new[]
{
"Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching"
};
public async Task<WeatherForecast[]> GetForecastAsync(DateTime startDate)
{
if (Interlocked.CompareExchange(ref Locked, 1, 0) > 0)
throw new InvalidOperationException(
"A second operation started on this context before a previous operation completed. Any "
+ "instance members are not guaranteed to be thread-safe.");
try
{
await Task.Delay(3000);
var rng = new Random();
return Enumerable.Range(1, 5).Select(index => new WeatherForecast
{
Date = startDate.AddDays(index),
TemperatureC = rng.Next(-20, 55),
Summary = Summaries[rng.Next(Summaries.Length)]
}).ToArray();
}
finally
{
Interlocked.Decrement(ref Locked);
}
}
}
- Line 3
A private volatile int field is added to keep track of the number of threads are currently executing the same method. - Line 12
Interlocked.CompareExchange
is used to set theLocked
value to1
, but only if it is currently0
. The original value ofLocked
is returned from the method, if it is greater than0
then we throw anInvalidOperationException
. - Line 19
We introduce anawait Task.Delay
of 3 seconds to simulate a long-running process. This will increase the risk of two threads clashing, and also ensure our code actually runs asynchronously. (See Multi-threaded rendering). - Line 30
Once the method is complete, decrement theLocked
count from1
back down to0
so that another thread may execute the method without receiving an exception.
Now run the application, and attempt to open the /fetchdata page in two browser tabs at the same time, and our `InvalidOperationException` should be thrown.
Fix 1: Using a Scoped dependency
Changing the WeatherForecastService
from a Singleton
dependency to a Scoped
one (in Startup.cs) will prevent
the thread reentrancy problem occurring across our users.
public void ConfigureServices(IServiceCollection services)
{
services.AddRazorPages();
services.AddServerSideBlazor();
services.AddScoped<WeatherForecastService>();
}
Running the application again we'll see that we are able to open many tabs without causing a threading conflict.
If you've read the section on Scoped dependencies,
it will be obvious why.
Each tab receives its own unique instance of the WeatherForecastService
and therefore only a single thread is using each
service at any one time.
However, we are only guaranteed our service will not be used by threads from other users of our application. It does not guarantee our component will not be used by multiple threads at all. The section on Multi-threaded rendering explains how server-side Blazor applications can utilize multiple threads to render the user interface.
Potentially having multiple threads rendering for a single user means we still have the possibility of thread reentrancy on a service instance that is shared across multiple components.
If making the service thread-safe is not possible, then one option in this scenario is to ensure that every component has its own unique instance of our service injected.
Demonstrating the problem with scoped dependencies
Create a new page named Conflict.Razor and give it the following simple mark-up.
@page "/conflict"
<FetchData />
<FetchData />
Having two occurrences of FetchData
being rendered within our page will require two components to access the WeatherForecastService
,
and because the GetForecastAsync
method has an await
in it.
This means the thread rendering the first <FetchData/>
will be able to progress on to rendering the second <FetchData/>
before the first one has finished.
Run the application again and navigate to the /conflict page, and again we'll see our InvalidOperationException
is
thrown again.
Fix 2: Descending from OwningComponentBase
Blazor has a generic component class named OwningComponentBase<T>
.
When an instance of this class is created, it will first create its own IServiceProvider
(used to resolve dependencies),
and will then use that service provider to create a new instance of T
(which it then stores in a property named Service
.
Because OwningComponentBase<T>
owns its own unique IServiceProvider
(hence the naming **Owning**ComponentBase
),
this means the T
resolved from the service provider will be unique to our component.
When our OwningComponentBase<T>
component is disposed, its IServiceProvider
(Service
property) is disposed too,
which in turn will dispose of every instance it created - in this case, our WeatherForecastService
.
OwningComponentBase
OwningComponentBase
Create a new component in /Pages named OwnedFetchDataPage.razor and enter the folloing mark-up.
@page "/owned-fetchdata"
<OwnedFetchData/>
<OwnedFetchData />
In the /Shared folder create a new component named OwnedFetchData.razor and copy over the mark-up from the FetchData.razor file.
At the moment, the OwnedFetchData
component has the same flaw,
it is injected with the same instance of WeatherForecastService
that is shared with other components in the current
browser tab.
To fix this, follow these steps.
- At the top of the page remove
@inject WeatherForecastService ForecastService
- Replace the removed line with
@inherits OwningComponentBase<WeatherForecastService>
- Remove the line of code
forecasts = await ForecastService.GetForecastAsync(DateTime.Now);
- Replace it with
forecasts = await Service.GetForecastAsync(DateTime.Now);
Run the application again and navigate to the /owned-fetchdata page.
Because each instance of the OwnedFetchData
component owns its own instance of WeatherForecastService
,
they are able to interact with the service independently of each other, and without causing thread reentrancy problems.
If our server-side application were accessing a database,
we might descend our component from OwningComponentBase<MyDbContext>
and fetch data from the database into an array for
rendering.