In this post I describe how ASP.NET Core 3.0 has been re-platformed on top of the generic host, and some of the benefits that brings. I show a new abstraction introduced in 3.0, IHostLifetime
and describe its role for managing the lifecycle of applications, especially worker services.
In the second half of the post I look in detail at the interactions between classes and their roles during application startup and shutdown. I go into quite a bit of detail about things you generally shouldn't have to deal with, but I found it useful for my own understanding even if no one else cares! 🙂
Background: Re-platforming ASP.NET Core onto the Generic Host
One of the key features of ASP.NET Core 3.0 is that the whole stack has been re-written to sit on top of the .NET Generic Host. The .NET Generic Host was introduced in ASP.NET Core 2.1, as a "non-web" version of the existing WebHost
used by ASP.NET Core. The generic host allowed you to re-use many of the DI, configuration, and logging abstractions of Microsoft.Extensions in non-web scenarios.
While this was definitely an enviable goal, it had some issues in the implementation. The generic host essentially duplicated many of the abstractions required by ASP.NET Core, creating direct equivalents, but in a different namespace. A good example of the problem is IHostingEnvironment
- this has existed in ASP.NET Core in the Microsoft.AspNetCore.Hosting since version 1.0. But in version 2.1, a new IHostingEnvironment
was added in the Microsoft.Extensions.Hosting namespace. Even though the interfaces are identical, having both causes issues for generic libraries trying to use the abstractions.
With 3.0, the ASP.NET Core team were able to make significant changes that directly address this issue. Instead of having two separate Hosts/Stacks, they were able to re-write the ASP.NET Core stack so that it sits on top of the .NET generic host. That means it can truly re-use the same abstractions, resolving the issue described above. This move was also partly motivated by the desire to build additional non-HTTP stacks on top of the generic host, such as the gRPC features introduced in ASP.NET Core 3.0.
But what does it really mean for ASP.NET Core 3 to have been "re-built" or "re-platformed" on top of the generic host? Fundamentally, it means that the Kestrel web server (that handles HTTP requests and calls into your middleware pipeline) now runs as an IHostedService
. I've written a lot about creating hosted services on my blog, and Kestrel is now just one more service running in the background when your app starts up.
One point that's worth highlighting - the existing
WebHost
andWebHostBuilder
implementations that you're using in ASP.NET Core 2.x apps are not going away in 3.0. They're no longer the recommended approach, but they're not being removed, or even marked obsolete (yet). I expect they'll be marked obsolete in the next major release however, so it's worth considering the switch.
So that covers the background. We have a generic host, and Kestrel is run as an IHostedService
. However, another feature introduced in ASP.NET Core 3.0 is the IHostLifetime
interface, which allows for alternative hosting models.
Worker services and the new IHostLifetime interface
ASP.NET Core 3.0 introduced the concept of "worker services" and an associated new application template. Worker services are intended to give you long-running applications that you can install as a Windows Service or as a systemd service. There are two main features to these services:
- They use
IHostedService
implementations to do the "work" of the application. - They manage the lifetime of the app using an
IHostLifetime
implementation.
IHostedService
has been around for a long time, and allows you to run background services. It is the second point which is the interesting one here. The IHostLifetime
interface is new for .NET Core 3.0, and has two methods:
public interface IHostLifetime
{
Task WaitForStartAsync(CancellationToken cancellationToken);
Task StopAsync(CancellationToken cancellationToken);
}
We'll be looking in detail at exactly where IHostLifetime
comes in to play in later sections, but in summary:
WaitForStartAsync
is called when the generic host is starting, and can be used to start listening for shutdown events, or to delay the start of the application until some event occurs.StopAsync
is called when the generic host is stopping.
There are currently three different IHostLifetime
implementations in .NET Core 3.0:
ConsoleLifetime
– Listens forSIGTERM
or Ctrl+C and stops the host application.SystemdLifetime
– Listens forSIGTERM
and stops the host application, and notifiessystemd
about state changes (Ready
andStopping
)WindowsServiceLifetime
– Hooks into the Windows Service events for lifetime management
By default the generic host uses the ConsoleLifetime
, which provides the behaviour you're used to in ASP.NET Core 2.x, where the application stops when it receives the SIGTERM
signal or a Ctrl+C from the console. When you create a Worker Service (Windows or systemd service) then you're primarily configuring the IHostLifetime
for the app.
Understanding application start up
It was while I was digging into this new abstraction that I started to get very confused. When does this get called? How does it relate to the ApplicationLifetime
? Who calls the IHostLifetime
in the first place? To get things straight in my mind, I spent some time tracing out the interactions between the key players in a default ASP.NET Core 3.0 application.
In this post, we're starting from a default ASP.NET Core 3.0 Program.cs file, such as the one I examined in the first post in this series:
public class Program
{
public static void Main(string[] args)
{
CreateHostBuilder(args).Build().Run();
}
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
}
In particular, I'm interested in what that Run()
call does, once you've built your generic Host
object.
Note that I'm not going to give an exhaustive description of the code - I'll be skipping anything that I consider uninteresting or tangential. My aim is to get an overall feel for the interactions. Luckily, the source code is always available if you want to go deeper!
Run()
is an extension method on HostingAbstractionsHostExtensions
that calls RunAsync()
and blocks until the method exits. When that method exits, the application exits, so everything interesting happens in there! The diagram below gives an overview of what happens in RunAsync()
, I'll discuss the details below:
Program.cs invokes the Run()
extension method, which invokes the RunAsync()
extension method. This in turn calls StartAsync()
on the IHost
instance. The StartAsync
method does a whole bunch of things like starting the IHostingService
s (which we'll come to later), but the method returns relatively quickly after being called.
Next, the RunAsync()
method calls another extension method called WaitForShutdownAsync()
. This extension method does everything else shown in the diagram. The name is pretty descriptive; this method configures itself so that it will pause until the ApplicationStopping
cancellation token on IHostApplicationLifetime
is triggered (we'll look at how that token gets triggered shortly).
The extension method achieves this using a TaskCompletionSource
, and await
-ing the associated Task
. This isn't a pattern I've needed to use before and it looked interesting, so I've added it below (adapted from HostingAbstractionsHostExtensions)
public static async Task WaitForShutdownAsync(this IHost host)
{
// Get the lifetime object from the DI container
var applicationLifetime = host.Services.GetService<IHostApplicationLifetime>();
// Create a new TaskCompletionSource called waitForStop
var waitForStop = new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously);
// Register a callback with the ApplicationStopping cancellation token
applicationLifetime.ApplicationStopping.Register(obj =>
{
var tcs = (TaskCompletionSource<object>)obj;
// When the application stopping event is fired, set
// the result for the waitForStop task, completing it
tcs.TrySetResult(null);
}, waitForStop);
// Await the Task. This will block until ApplicationStopping is triggered,
// and TrySetResult(null) is called
await waitForStop.Task;
// We're shutting down, so call StopAsync on IHost
await host.StopAsync();
}
This extension method explains how the application is able to "pause" in a running state, with everything running in background tasks. Lets look in more depth at the IHost.StartAsync()
method call at the top of the previous diagram.
The startup process in Host.StartAsync()
In the previous diagram we were looking at the HostingAbstractionsHostExtensions
extension methods which operate on the interface IHost
. If we want to know what typically happens when we call IHost.StartAsync()
then we need to look at a concrete implementation. The diagram below shows the StartAsync()
method for the generic Host
implementation that is used in practice. Again, we'll walk through the interesting parts below.
As you can see from the diagram above, there's a lot more moving parts here! The call to Host.StartAsync()
starts by calling WaitForStartAsync()
on the IHostLifetime
instance I described earlier in this post. The behaviour at this point depends on which IHostLifetime
you're using, but I'm going to assume we're using the ConsoleLifetime
for this post, (the default for ASP.NET Core apps).
The
SystemdLifetime
behaves very similarly to theConsoleLifetime
, with a couple of extra features. TheWindowsServiceLifetime
is quite different, and derives fromSystem.ServiceProcess.ServiceBase
.
The ConsoleLifetime.WaitForStartAsync()
method (shown below) does one important thing: it adds event listeners for SIGTERM
requests and for Ctrl+C in the console. It is these events that are fired when application shutdown is requested. So it's the IHostLifetime
that is typically responsible for controlling when the application shuts down.
public Task WaitForStartAsync(CancellationToken cancellationToken)
{
// ... logging removed for brevity
// Attach event handlers for SIGTERM and Ctrl+C
AppDomain.CurrentDomain.ProcessExit += OnProcessExit;
Console.CancelKeyPress += OnCancelKeyPress;
// Console applications start immediately.
return Task.CompletedTask;
}
As shown in the code above, this method completes immediately and returns control to Host.StartAsync()
. At this point, the Host loads all the IHostedService
instances and calls StartAsync()
on each of them. This includes the GenericWebHostService
that starts the Kestrel web server (which is started last, hence my previous post on async startup tasks).
Once all the IHostedService
s have been started, Host.StartAsync()
calls IHostApplicationLifetime.NotifyStarted()
to trigger any registered callbacks (typically just logging) and exits.
Note that
IHostLifetime
is different toIHostApplicationLifetime
. The former contains the logic for controlling when the application starts. The latter (implemented byApplicationLifetime
) containsCancellationTokens
against which you can register callbacks to run at various points in the application lifecycle.
At this point the application is in a "running" state, with all background services running, Kestrel handling requests, and the original WaitForShutdownAsync()
extension method waiting for the ApplicationStopping
event to fire. Finally, let's take a look at what happens when you type Ctrl+C in the console.
The shutdown process
The shutdown process occurs when the ConsoleLifetime
receives a SIGTERM
signal or a Ctrl+C (cancel key press) from the console. The diagram below shows the interaction between all the key players in the shutdown process:
When the Ctrl+C termination event is triggered the ConsoleLifetime
invokes the IHostApplicationLifetime.StopApplication()
method. This triggers all the callbacks that were registered with the ApplicationStopping
cancellation token. If you refer back to the program overview, you'll see that trigger is what the original RunAsync()
extension method was waiting for, so the await
ed task completes, and Host.StopAsync()
is invoked.
Host.StopAsync()
starts by calling IHostApplicationLifetime.StopApplication()
again. This second call is a noop when run for a second time, but is necessary because technically there are other ways Host.StopAsync()
could be triggered.
Next, Host
shuts down all the IHostedService
s in reverse order. Services that started first will be stopped last, so the GenericWebHostedService
is shut down first.
After shutting down the services, IHostLifetime.StopAsync
is called, which is a noop for the ConsoleLifetime
(and also for SystemdLifetime
, but does work for WindowsServiceLifetime
). Finally, Host.StopAsync()
calls IHostApplicationLifetime.NotifyStopped()
to run any associated handlers (again, mostly logging) before exiting.
At this point, everything is shutdown, the Program.Main
function exits, and the application exits.
Summary
In this post I provided some background on how ASP.NET Core 3.0 has been re-platformed on top of generic host, and introduced the new IHostLifetime
interface. I then described in detail the interactions between the various classes and interfaces involved in application startup and shutdown for a typical ASP.NET Core 3.0 application using the generic host.
This was obviously a long one, and goes in to more detail than you'll need generally. Personally I found it useful looking through the code to understand what's going on, so hopefully it'll help someone else too!