Search code examples
c#.net-corecancellation-tokenapplication-lifecycle

Why is IsCancellationRequested not set to true on stopping a BackgroundService in .NET Core 3.1?


I've read most articles I can find about IHostApplicationLifetime and CancellationToken's in .NET Core 3.1, but I cannot find a reason why this is not working.

I have a simple BackgroundService which look like the following:

    public class AnotherWorker : BackgroundService
    {
        private readonly IHostApplicationLifetime _hostApplicationLifetime;

        public AnotherWorker(IHostApplicationLifetime hostApplicationLifetime)
        {
            _hostApplicationLifetime = hostApplicationLifetime;
        }

        public override Task StartAsync(CancellationToken cancellationToken)
        {
            Console.WriteLine($"Process id: {Process.GetCurrentProcess().Id}");
            _hostApplicationLifetime.ApplicationStarted.Register(() => Console.WriteLine("Started"));
            _hostApplicationLifetime.ApplicationStopping.Register(() => Console.WriteLine("Stopping"));
            _hostApplicationLifetime.ApplicationStopped.Register(() => Console.WriteLine("Stopped"));

            return Task.CompletedTask;
        }

        protected override Task ExecuteAsync(CancellationToken stoppingToken)
        {
            Console.WriteLine("Executing");
            return Task.CompletedTask;
        }

        public override async Task StopAsync(CancellationToken cancellationToken)
        {
        // This actually prints "Stop. IsCancellationRequested: False". Why?
            Console.WriteLine($"Stop. IsCancellationRequested: {cancellationToken.IsCancellationRequested}");
            await base.StopAsync(cancellationToken);
        }
    }

The ConsoleLifetime is added by default, which listens to Ctrl+C and SIGTERM and informs the IHostApplicationLifetime. I guess IHostApplicationLifetime in turn should then cancel all CancellationTokens? Here's a good article on the subject. So why is the output from the above code snippet the following?

Hosting starting
Started
Hosting started
(sends SIGTERM with `kill -s TERM <process_id>`)
Applicationis shuting down...
Stop. IsCancellationRequested: False
Stopped
Hosting stopped

I would expect it to log Stop. IsCancellationRequested: True

I want to be able to pass this token around, to other service calls, for them to have the capability to shutdown gracefully.


Solution

  • There are a lot of different cancellation tokens here, and several different abstractions (IHostApplicationLifetime, IHostedService, BackgroundService). It takes a while to untangle everything. The blog post you linked to is fantastic, but doesn't go into detail on the CancellationTokens.

    First, if you're going to use BackgroundService, I recommend reading the code. Also, I strongly recommend not overriding StartAsync and StopAsync; BackgroundService uses these in a very particular way.

    IHostedService has two methods. StartAsync starts the service running (possibly asynchronously); it takes a CancellationToken that indicates the "start" operation should be cancelled (I haven't checked, but I assume this token is only triggered if the app is shutdown almost immediately). Note that StartAsync needs to complete before the hosted service is considered in the "started" or "running" state. Similarly, StopAsync stops the service (possibly asynchronously). StopAsync is invoked when the application begins its graceful shutdown. There's a timeout for the graceful shutdown period, after which the application begins its "I'm serious now" shutdown. The CancellationToken for StopAsync represents the transition from "graceful" to "I'm serious now". So it's not set during that graceful shutdown timeout window.

    If you use BackgroundService instead of IHostedService directly (like most people do), you get a different CancellationToken in ExecuteAsync. This one is set when BackgroundService.StopAsync is invoked - i.e., when the application has started its graceful shutdown. So it's roughly equivalent to IHostApplicationLifetime.ApplicationStopping, but scoped to a single hosted service. You can expect the BackgroundWorker.ExecuteAsync CancellationToken to be set shortly after IHostApplicationLifetime.ApplicationStopping is set.

    Note that all of these CancellationTokens represent something different:

    • IHostedService.StartAsync's CancellationToken means "abort the starting of this service".
    • IHostedService.StopAsync's CancellationToken means "stop this service right now; you're out of the grace period".
    • IHostApplicationLifetime.ApplicationStopping means "the graceful shutdown sequence for this entire application has started; everyone please stop what you are doing".
      • As part of the graceful shutdown sequence, all IHostedService.StopAsync methods are invoked.
    • BackgroundService.ExecuteAsync's CancellationToken means "stop this service".

    An interesting note is that BackgroundService types don't normally see the "I'm serious now" signal; they only see the "stop this service" signal. This is likely because the "I'm serious now" signal represented by a CancellationToken is somewhat confusing.

    If you look into the code for Host, the shutdown sequence has even more cancellation tokens used in its shutdown sequence:

    1. IHost.StopAsync takes a CancellationToken meaning "the stop should no longer be graceful".
    2. It then starts a CancellationToken-based timeout for the graceful timeout period.
    3. ... and another linked CancellationToken that is fired if either the IHost.StopAsync token is fired or if the timer elapsed. So this one also means "the stop should no longer be graceful".
    4. Next it calls IHostApplicationLifetime.StopApplication, which cancels the IHostApplicationLifetime.ApplicationStopping CancellationToken.
    5. It then invokes StopAsync for each IHostedService, passing the "stop should no longer be graceful" token.
      • All BackgroundService types have their own CancellationToken (which was passed to ExecuteAsync during startup), and those cancellation tokens are cancelled by StopAsync.
    6. Finally, it invokes IHostApplicationLifetime.NotifyStopped, which cancels the IHostApplicationLifetime.ApplicationStopped CancellationToken.

    I count 3 for the "no longer graceful" signal (one passed in, one timer, and one linking those two), plus 2 on IHostApplicationLifetime, plus 1 for each BackgroundService, for a total of 5 + n cancellation tokens used during shutdown. :)