Introducing the new IHostedLifecycleService Interface in .NET 8

Introducing the new IHostedLifecycleService Interface in .NET 8

As regular readers will be aware, an area of .NET which I follow closely is Microsoft.Extensions.Hosting. I’ve already blogged about a change in .NET 8, where new concurrency options have been introduced to support parallel running of the StartAsync and StopAsync across multiple IHostedServices.

In this post, we’ll look at some new lifecycle events introduced for hosted services in .NET 8. Note that this post relates to .NET 8, which is currently in preview at the time of writing. The types and the implementation may change before the final release of .NET 8 in November. To follow along, you will require preview 7 of .NET 8.

Introducing IHostedLifecycleService

The main change is the inclusion of a new interface in the Microsoft.Extensions.Hosting namespace named IHostedLifecycleService. This interface inherits from the existing IHostedService interface, extending it to add methods for new lifecycle events which occur before or after the existing StartAsync and StopAsync methods. These provide a way to hook into more specific application lifetime events for some advanced scenarios.

The interface is defined as follows:

public partial interface IHostedLifecycleService : Microsoft.Extensions.Hosting.IHostedService
{
    Task StartingAsync(CancellationToken cancellationToken);
    Task StartedAsync(CancellationToken cancellationToken);
    Task StoppingAsync(CancellationToken cancellationToken);
    Task StoppedAsync(CancellationToken cancellationToken);    
}

The StartingAsync method for all registered hosted services that implement this interface will run very early in the application lifecycle before StartAsync (from the IHostedService) is called on any registered hosted services. This may be used to perform some very early validation checks before an application is started, such as checking for critical requirements or dependencies being available. This allows an application to potentially fail startup before any hosted services have begun executing their primary workload. Other uses include “pre-heating” and initialising singletons and other state the application uses.

StartedAsync will be invoked on implementations after the completion of all StartAsync (from IHostedService) methods for registered hosted services. This may be used to validate the application state or conditions just before marking the application as started successfully.

StoppingAsync and StoppedAsync work similarly during application shutdown and provide advanced hooks for pre-shutdown and post-shutdown verifications.

Before moving onto the finer details, it’s worth discussing why Microsoft has created a new derived interface rather than taking advantage of default interface implementations to update the existing IHostedService interface. This indeed would have been a good case for default interface implementations to add these to IHostedService with a default no-op implementation. The reason is apparent when we look at the runtime targets for this library. The hosting packages multitarget various target frameworks. This includes netstandard2.0, which was locked down before the default interface implementation feature was introduced. Therefore, in order to continue supporting this target, the derived interface design was used instead.

As part of the PR to introduce this interface, a new option, StartupTimeout, has also been added to HostOptions. This allows a TimeSpan to be provided that will control the maximum time allowed for the startup of all hosted services. When configured with a non-infinite value (the default), the cancellation token passed to the startup lifecycle events be linked to a CancellationTokenSource configured with the provided value.

Using the New Interface

Using .NET 8 preview 7, we can look at a general example of how this new interface might be utilised. One fairly common piece of startup work I see in applications is to initialise databases.

While in production, we can expect such databases to be online, available and seeded; in other environments, such as CI, we may require a dummy database to be created and seeded with sample data. Various solutions exist to deal with this, but one potential choice is to use a hosted service to perform the work conditionally. This can be more complicated when other hosted services depend on an available database since those must then be started in the correct order after the database is ready. In .NET 7, this is achievable since hosted services start in sequence and in their registration order.

Therefore, in .NET 7, we can achieve something along these lines:

public class ServiceA : IHostedService
{
    public Task StartAsync(CancellationToken cancellationToken)
    {
        // INIT DB
 
        return Task.CompletedTask;
    }
 
    public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
 
public class ServiceB : BackgroundService
{
    protected override Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // USE DB
 
        return Task.CompletedTask;
    }
}

To register these services with the DI container so that the Host executes them, we must ensure we specifically add them in the correct order.

builder.Services.AddHostedService<ServiceA>();
builder.Services.AddHostedService<ServiceB>();

Because .NET 7 executes the StartAsync method of each service in sequence rather than concurrently, we know that by the time ServiceB.StartAsync is invoked, the database should have completed initialising in ServiceA.

While this behaviour is also true in .NET 8 by default, it is now also possible to configure the Host to start them concurrently. If we wanted to change this option, our application may break, as ServiceB would be triggered at the same time as ServiceA. This may not be a significant concern, but if there are other hosted services in the application, by switching to concurrent execution, we could reduce the overall startup time of the application.

With the introduction of the new IHostedLifecycleService in .NET 8, we could move the database initialisation work earlier in the lifecycle while also taking advantage of concurrent hosted service startup.

public class ServiceA : IHostedService, IHostedLifecycleService
{
    public Task StartingAsync(CancellationToken cancellationToken)
    {
        // INIT DB
 
        return Task.CompletedTask;
    }
 
    public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
 
    public Task StartedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
 
    public Task StoppingAsync(CancellationToken cancellationToken) => Task.CompletedTask;
 
    public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
 
    public Task StoppedAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
 
public class ServiceB : BackgroundService
{
    protected override Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // USE DB
 
        return Task.CompletedTask;
    }
}

In the sample code above, we define two hosted services. ServiceA implements the new IHostedLifecycleService in addition to IHostedService. We want to perform our database initialisation very early in the app lifecycle, before any of the primary workloads. Therefore, we can include our database setup code inside the StartingAsync method.

ServiceB, which derives from BackgroundService, can now safely use the database inside its ExecuteAsync method, since ExecuteAsync is invoked by the underlying implementation of StartAsync defined in the IHostedService interface. As a result, it isn’t invoked until all StartingAsync methods for registered services have completed.

We would register these services with the DI container in the same way as in .NET, but it no longer matters what order we add them.

builder.Services.AddHostedService<ServiceB>();
builder.Services.AddHostedService<ServiceA>();

Even in this order, the StartingAsync of ServiceA will be executed before ServiceB.StartAsync. We can even configure the concurrent start and stop behaviour without breaking our logic.

builder.Services.Configure<HostOptions>(options =>
{
    options.ServicesStartConcurrently = true;
    options.ServicesStopConcurrently = true;
});

Diving into the Details

After introducing the new interface, most core changes are implemented inside the internal Host class, which implements the IHost interface. This class defines the main Host, built when creating new applications from templates such as ASP.NET Core and Worker Service. The IHost interface defines a StartAsync and StopAsync method invoked when applications start or stop.

The first meaningful change introduces extra logic at the beginning of the StartAsync method to implement the new StartupTimeout feature.

CancellationTokenSource? cts = null;
CancellationTokenSource linkedCts;
if (_options.StartupTimeout != Timeout.InfiniteTimeSpan)
{
	cts = new CancellationTokenSource(_options.StartupTimeout);
	linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken, _applicationLifetime.ApplicationStopping);
}
else
{
	linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _applicationLifetime.ApplicationStopping);
}

As we can see from this updated code, in all cases, the cancellation token passed into StartAsync can cause cancellation, as can the ApplicationStopping token exposed on IHostApplicationLifetime, which will be marked as cancelled if a shutdown has been triggered.

When HostOptions.StartupTimeout is not equal to InfiniteTimeSpan a CancellationTokenSource is created, passing the TimeSpan into the constructor. Its token can then be added to a linked token source to ensure this third condition can also trigger aborting the startup. This new option allows application developers to provide an expected upper time limit for an expected “normal” startup, such that in exceptional circumstances, a long delay can proactively trigger the abortion of the startup. Such situations, potentially caused by unavailable external dependencies, should be tracked and logged so that they can be investigated.

After the linkedCts is created, it will be used to access the CancellationToken that is then passed into any subsequent asynchronous methods invoked during the startup process.

CancellationToken token = linkedCts.Token;

The first of these async methods is the invocation of IHostLifetime.WaitForStartAsync, which is an early hook in the hosting lifetime, is awaited at this point. This is another advanced hook that can potentially delay startup until signalled by an external event. This concept has been available since .NET Core 3.0.

// This may not catch exceptions.
await _hostLifetime.WaitForStartAsync(token).ConfigureAwait(false);
               
token.ThrowIfCancellationRequested();

The next lines in the implementation prepare some variables and fields.

List<Exception> exceptions = new();
_hostedServices = Services.GetRequiredService<IEnumerable<IHostedService>>();
_hostedLifecycleServices = GetHostLifecycles(_hostedServices);
bool concurrent = _options.ServicesStartConcurrently;
bool abortOnFirstException = !concurrent;

After setting up a list to contain any exceptions during startup, all registered IHostedServices are retrieved from the container. The GetHostLifecycles method is used to loop over the IHostedService implementations and determine which, if any, also implement IHostedLifecycleService.

The next piece of code executes the StartingAsync method for each IHostedLifecycleService.

if (_hostedLifecycleServices is not null)
{
	// Call StartingAsync().
	await ForeachService(_hostedLifecycleServices, token, concurrent, abortOnFirstException, exceptions,
		(service, token) => service.StartingAsync(token)).ConfigureAwait(false);
}

ForeachService is a helper method that executes the services concurrently or sequentially based on the HostOptions setting passed in as an argument.

private static async Task ForeachService<T>(
	IEnumerable<T> services,
	CancellationToken token,
	bool concurrent,
	bool abortOnFirstException,
	List<Exception> exceptions,
	Func<T, CancellationToken, Task> operation)
{
	if (concurrent)
	{
		// The beginning synchronous portions of the implementations are run serially in registration order for
		// performance since it is common to return Task.Completed as a noop.
		// Any subsequent asynchronous portions are grouped together run concurrently.
		List<Task>? tasks = null;

		foreach (T service in services)
		{
			Task task;
			try
			{
				task = operation(service, token);
			}
			catch (Exception ex)
			{
				exceptions.Add(ex); // Log exception from sync method.
				continue;
			}

			if (task.IsCompleted)
			{
				if (task.Exception is not null)
				{
				   
exceptions.AddRange(task.Exception.InnerExceptions); // Log exception from async method.
				}
			}
			else
			{
				tasks ??= new();
				tasks.Add(Task.Run(() => task, token));
			}
		}

		if (tasks is not null)
		{
			Task groupedTasks = Task.WhenAll(tasks);

			try
			{
				await groupedTasks.ConfigureAwait(false);
			}
			catch (Exception ex)
			{
			   
exceptions.AddRange(groupedTasks.Exception?.InnerExceptions ?? new[] { ex }.AsEnumerable());
			}
		}
	}
	else
	{
		foreach (T service in services)
		{
			try
			{
				await operation(service, token).ConfigureAwait(false);
			}
			catch (Exception ex)
			{
				exceptions.Add(ex);
				if (abortOnFirstException)
				{
					return;
				}
			}
		}
	}
}

The code branches based on the boolean concurrent parameter. Let’s focus on the code used to invoke the functions of each service concurrently.

As the comments state, the implementation first invokes the operation delegate, in this case, StartingAsync, on each service. It’s entirely possible that many of the IHostedLifecycleService implementations no-op on most of their methods by returning a cached Task.CompletedTask. In these cases, the code runs synchronously as the is nothing to await. The above code special cases this and checks if any tasks return immediately as completed or throw synchronous exceptions. For these completed tasks, any exceptions thrown within them are added to the exceptions list.

For any tasks which are not completed at this point, they are running asynchronously. These tasks are added to the list of tasks. Once all tasks have been started, they are awaited with WhenAll, meaning they run concurrently until all registered services have completed their work. Any exceptions are also captured here.

In the non-concurrent path, the code is simpler since it can simply await each operation in order. In this configuration, each service must complete its work before the next is invoked.

Returning to the Host.StartAsync method, the process is repeated for IHostedService.StartAsync and IHostedLifecycleService.StartedAsync. The method concludes by logging and rethrowing any captured exceptions before triggering the notification that the hosted application has now started.

The implementation for the new lifecycle events for Stopping and Stopped is almost identical, so we need not dive into that here.

Summary

Microsoft continues to refine and enhance the hosting concepts in .NET in each release. In .NET 8, focus has been put on introducing more control over the startup behaviour of hosted services. In this latest piece of work, they have introduced advanced, fine-grain control over code which runs before, during and after startup and shutdown. The new IHostedLifecycleService interface arrived in .NET 8 preview 7, which is now available.


Have you enjoyed this post and found it useful? If so, please consider supporting me:

Buy me a coffeeBuy me a coffee Donate with PayPal

Steve Gordon

Steve Gordon is a Pluralsight author, 6x Microsoft MVP, and a .NET engineer at Elastic where he maintains the .NET APM agent and related libraries. Steve is passionate about community and all things .NET related, having worked with ASP.NET for over 21 years. Steve enjoys sharing his knowledge through his blog, in videos and by presenting talks at user groups and conferences. Steve is excited to participate in the active .NET community and founded .NET South East, a .NET Meetup group based in Brighton. He enjoys contributing to and maintaining OSS projects. You can find Steve on most social media platforms as @stevejgordon