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
WebHostand
WebHostBuilderimplementations 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
IHostedServiceimplementations to do the "work" of the application.
- They manage the lifetime of the app using an
IHostLifetimeimplementation.
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:
WaitForStartAsyncis 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.
StopAsyncis called when the generic host is stopping.
There are currently three different
IHostLifetime implementations in .NET Core 3.0:
ConsoleLifetime– Listens for
SIGTERMor Ctrl+C and stops the host application.
SystemdLifetime– Listens for
SIGTERMand stops the host application, and notifies
systemdabout state changes (
Readyand
Stopping)
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
IHostingServices (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
SystemdLifetimebehaves very similarly to the
ConsoleLifetime, with a couple of extra features. The
WindowsServiceLifetimeis quite different, and derives from
System.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
IHostedServices have been started,
Host.StartAsync() calls
IHostApplicationLifetime.NotifyStarted() to trigger any registered callbacks (typically just logging) and exits.
Note that
IHostLifetimeis different to
IHostApplicationLifetime. The former contains the logic for controlling when the application starts. The latter (implemented by
ApplicationLifetime) contains
CancellationTokensagainst 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
awaited 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.
Host shuts down all the
IHostedServices 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!