Andrew Lock | .NET Escapades

Andrew Lock

in ASP.NET Core .NET Core 3.0 Generic Host ~ 7 min read.

Introducing IHostLifetime and untangling the Generic Host startup interactions
Exploring ASP.NET Core 3.0 - Part 5

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 and WebHostBuilder 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 for SIGTERM or Ctrl+C and stops the host application.
  • SystemdLifetime – Listens for SIGTERM and stops the host application, and notifies systemd about state changes (Ready and 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:

Sequence diagram for program startup

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.

Sequence diagram for Host.StartAsync()

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 the ConsoleLifetime, with a couple of extra features. The WindowsServiceLifetime is 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 IHostLifetime is different to IHostApplicationLifetime. The former contains the logic for controlling when the application starts. The latter (implemented by ApplicationLifetime) contains CancellationTokens 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:

Sequence diagram for application shut down when Ctrl+C is clicked

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.

Next, 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!

Loading comments powered by Disqus, please wait…
Andrew Lock | .Net Escapades

Stay up to the date with the latest posts!

Oops! Check your details and try again.
Thanks! Check your email for confirmation.