25

.NET Core 2.1 introduced new Generic Host, which allows to host non-HTTP workloads with all benefits of Web Host. Currently, there is no much information and recipes with it, but I used following articles as a starting point:

https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/generic-host?view=aspnetcore-2.1

https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-2.1

https://learn.microsoft.com/en-us/dotnet/standard/microservices-architecture/multi-container-microservice-net-applications/background-tasks-with-ihostedservice

My .NET Core application starts, listens for new requests via RabbitMQ message broker and shuts down by user request (usually by Ctrl+C in console). However, shutdown is not graceful - application still have unfinished background threads while it returns control to OS. I see it by console messages - when I press Ctrl+C in console I see few lines of console output from my application, then OS command prompt and then again console output from my application.

Here is my code:

Program.cs

public class Program
{
    public static async Task Main(string[] args)
    {
        var host = new HostBuilder()
            .ConfigureHostConfiguration(config =>
            {
                config.SetBasePath(AppContext.BaseDirectory);
                config.AddEnvironmentVariables(prefix: "ASPNETCORE_");
                config.AddJsonFile("hostsettings.json", optional: true);
            })
            .ConfigureAppConfiguration((context, config) =>
            {
                var env = context.HostingEnvironment;
                config.AddJsonFile("appsettings.json", optional: true, reloadOnChange: true);
                config.AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true);
                if (env.IsProduction())
                    config.AddDockerSecrets();
                config.AddEnvironmentVariables();
            })
            .ConfigureServices((context, services) =>
            {
                services.AddLogging();
                services.AddHostedService<WorkerPoolHostedService>();
                // ... other services
            })
            .ConfigureLogging((context, logging) =>
            {
                if (context.HostingEnvironment.IsDevelopment())
                    logging.AddDebug();

                logging.AddSerilog(dispose: true);

                Log.Logger = new LoggerConfiguration()
                    .ReadFrom.Configuration(context.Configuration)
                    .CreateLogger();
            })
            .UseConsoleLifetime()
            .Build();

        await host.RunAsync();
    }
}

WorkerPoolHostedService.cs

internal class WorkerPoolHostedService : IHostedService
{
    private IList<VideoProcessingWorker> _workers;
    private CancellationTokenSource _stoppingCts = new CancellationTokenSource();

    protected WorkerPoolConfiguration WorkerPoolConfiguration { get; }
    protected RabbitMqConfiguration RabbitMqConfiguration { get; }
    protected IServiceProvider ServiceProvider { get; }
    protected ILogger<WorkerPoolHostedService> Logger { get; }

    public WorkerPoolHostedService(
        IConfiguration configuration,
        IServiceProvider serviceProvider,
        ILogger<WorkerPoolHostedService> logger)
    {
        this.WorkerPoolConfiguration = new WorkerPoolConfiguration(configuration);
        this.RabbitMqConfiguration = new RabbitMqConfiguration(configuration);
        this.ServiceProvider = serviceProvider;
        this.Logger = logger;
    }

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        var connectionFactory = new ConnectionFactory
        {
            AutomaticRecoveryEnabled = true,
            UserName = this.RabbitMqConfiguration.Username,
            Password = this.RabbitMqConfiguration.Password,
            HostName = this.RabbitMqConfiguration.Hostname,
            Port = this.RabbitMqConfiguration.Port,
            VirtualHost = this.RabbitMqConfiguration.VirtualHost
        };

        _workers = Enumerable.Range(0, this.WorkerPoolConfiguration.WorkerCount)
            .Select(i => new VideoProcessingWorker(
                connectionFactory: connectionFactory,
                serviceScopeFactory: this.ServiceProvider.GetRequiredService<IServiceScopeFactory>(),
                logger: this.ServiceProvider.GetRequiredService<ILogger<VideoProcessingWorker>>(),
                cancellationToken: _stoppingCts.Token))
            .ToList();

        this.Logger.LogInformation("Worker pool started with {0} workers.", this.WorkerPoolConfiguration.WorkerCount);
    }

    public async Task StopAsync(CancellationToken cancellationToken)
    {
        this.Logger.LogInformation("Stopping working pool...");

        try
        {
            _stoppingCts.Cancel();
            await Task.WhenAll(_workers.SelectMany(w => w.ActiveTasks).ToArray());
        }
        catch (AggregateException ae)
        {
            ae.Handle((Exception exc) =>
            {
                this.Logger.LogError(exc, "Error while cancelling workers");
                return true;
            });
        }
        finally
        {
            if (_workers != null)
            {
                foreach (var worker in _workers)
                    worker.Dispose();
                _workers = null;
            }
        }
    }
}

VideoProcessingWorker.cs

internal class VideoProcessingWorker : IDisposable
{
    private readonly Guid _id = Guid.NewGuid();
    private bool _disposed = false;

    protected IConnection Connection { get; }
    protected IModel Channel { get; }
    protected IServiceScopeFactory ServiceScopeFactory { get; }
    protected ILogger<VideoProcessingWorker> Logger { get; }
    protected CancellationToken CancellationToken { get; }

    public VideoProcessingWorker(
        IConnectionFactory connectionFactory,
        IServiceScopeFactory serviceScopeFactory,
        ILogger<VideoProcessingWorker> logger,
        CancellationToken cancellationToken)
    {
        this.Connection = connectionFactory.CreateConnection();
        this.Channel = this.Connection.CreateModel();
        this.Channel.BasicQos(prefetchSize: 0, prefetchCount: 1, global: false);
        this.ServiceScopeFactory = serviceScopeFactory;
        this.Logger = logger;
        this.CancellationToken = cancellationToken;

        #region [ Declare ]

        // ...

        #endregion

        #region [ Consume ]

        // ...

        #endregion
    }

    // ... worker logic ...

    public void Dispose()
    {
        if (!_disposed)
        {
            this.Channel.Close(200, "Goodbye");
            this.Channel.Dispose();
            this.Connection.Close();
            this.Connection.Dispose();
            this.Logger.LogDebug("Worker {0}: disposed.", _id);
        }
        _disposed = true;
    }
}

So, when I press Ctrl+C I see following output in console (when there is no request processing):

Stopping working pool...
command prompt
Worker id: disposed.

How to shutdown gracefully?

Grayver
  • 383
  • 1
  • 3
  • 7
  • 1
    Do the workers listen to the cancellation token? The code in `// ... worker logic ..` should check `this.CancellationToken` periodically and exit when it's signalled – Panagiotis Kanavos Oct 24 '19 at 11:23
  • 1
    @PanagiotisKanavos yes, sure – Grayver Mar 21 '20 at 12:47
  • 1
    It's still not clear what exactly you're doing with the token in the VideoProcessingWorker. Are you just checking IsCancellationRequested or you're passing the token so some tasks so they can be cancelled with throwing TaskCancelledException . The StopAsyc method can last up to infinity waiting for completion, so the code you're showing looks to be correct and the problem seems to be hiding in the not shown part. It would be nice if you could reproduce the problem with a simpler code which you could publish. – sich May 31 '21 at 09:04

3 Answers3

19

You need IApplicationLifetime. This provides you with all the needed information about application start and shutdown. You can even trigger the shutdown with it via appLifetime.StopApplication();

Look at https://github.com/aspnet/Docs/blob/66916c2ed3874ed9b000dfd1cab53ef68e84a0f7/aspnetcore/fundamentals/host/generic-host/samples/2.x/GenericHostSample/LifetimeEventsHostedService.cs

Snippet(if the link becomes invalid):

public Task StartAsync(CancellationToken cancellationToken)
{
    appLifetime.ApplicationStarted.Register(OnStarted);
    appLifetime.ApplicationStopping.Register(OnStopping);
    appLifetime.ApplicationStopped.Register(OnStopped);

    return Task.CompletedTask;
}
Gabsch
  • 471
  • 4
  • 10
  • 7
    Thanks, I've read about this interface and it's events. But isn't all shutdown logic should be in IHostedService.StopAsync? Isn't host should wait while all hosted services' StopAsync method will be completed? – Grayver Jun 28 '18 at 14:32
  • 3
    You are right, i should have read your question more carefully. I think the problem may be in your try finally. Have you tried to wait for the finished tasks in the finally (with Task.Delay(Timeout.Infinite, cancellationToken))? – Gabsch Jun 29 '18 at 12:42
  • 2
    No, unfortunately it doesn't help. When I add Task.Delay in finally section and try to stop host with Ctrl+C, it prints "Stopping working pool", then command prompt appears, then it hangs for a few seconds and crashes with OperationCanceledException. I suppose that cancellationToken argument in StopAsync method is cancelled when host is not going to wait for graceful shutdown and is about to start "hard" shutdown. – Grayver Jul 10 '18 at 21:23
  • 2
    Sorry for my late answer. I didn't get the notification. That Task.Delay throws OperationCanceledException is an expected behavior. Just catch and handle OperationCanceledException. – Gabsch Aug 13 '18 at 15:03
  • 1
    So I created a breakpoint inside OnStopping, selected "stop website" in IISExpress, a second later the breakpoint was reached and another 3 seconds later (while I took a look at the callstack) the process just got killed. Not nice because a) my shutdown will take some seconds and b) I'll have to call async code. – springy76 Sep 04 '18 at 11:28
  • 1
    The suggestion to `await Task.Delay(Timeout.Infinite, cancellationToken)` in StopAsync proved very useful in my app. This stretched the time available to complete all shutdown activities (like writing to logs) to the full amount allowed (which is 5 secs by default). Btw, even though I caught locally the exception thown by the timeout of the delay, I still had to put a try/catch block around the call to host.RunAsync in Program.cs, to catch an OperationCanceledExceptions that comes from an internal version of the StopAsync function (which I think calls ours). – Jan Hettich Oct 21 '18 at 11:23
  • 1
    By the way, someone reading this may know the answer to the following related question: https://stackoverflow.com/questions/52915015/how-to-use-hostoptions-shutdowntimeout-to-change-the-time-out-in-generic-net-co – Jan Hettich Oct 21 '18 at 11:53
  • 1
    `IApplicationLifetime` is now obsolete. Use `IHostApplicationLifetime` instead. – AgentFire Aug 23 '21 at 13:09
11

I'll share some patterns I think works very well for non-WebHost projects.

namespace MyNamespace
{
    public class MyService : BackgroundService
    {
        private readonly IServiceProvider _serviceProvider;
        private readonly IApplicationLifetime _appLifetime;

        public MyService(
            IServiceProvider serviceProvider,
            IApplicationLifetime appLifetime)
        {
            _serviceProvider = serviceProvider;
            _appLifetime = appLifetime;
        }

        protected override Task ExecuteAsync(CancellationToken stoppingToken)
        {
            _appLifetime.ApplicationStopped.Register(OnStopped);

            return RunAsync(stoppingToken);
        }

        private async Task RunAsync(CancellationToken token)
        {
            while (!token.IsCancellationRequested)
            {
                using (var scope = _serviceProvider.CreateScope())
                {
                    var runner = scope.ServiceProvider.GetRequiredService<IMyJobRunner>();
                    await runner.RunAsync();
                }
            }
        }

        public void OnStopped()
        {
            Log.Information("Window will close automatically in 20 seconds.");
            Task.Delay(20000).GetAwaiter().GetResult();
        }
    }
}

A couple notes about this class:

  1. I'm using the BackgroundService abstract class to represent my service. It's available in the Microsoft.Extensions.Hosting.Abstractions package. I believe this is planned to be in .NET Core 3.0 out of the box.
  2. The ExecuteAsync method needs to return a Task representing the running service. Note: If you have a synchronous service wrap your "Run" method in Task.Run().
  3. If you want to do additional setup or teardown for your service you can inject the app lifetime service and hook into events. I added an event to be fired after the service is fully stopped.
  4. Because you don't have the auto-magic of new scope creation for each web request as you do in MVC projects you have to create your own scope for scoped services. Inject IServiceProvider into the service to do that. All dependencies on the scope should be added to the DI container using AddScoped().

Set up the host in Main( string[] args ) so that it shuts down gracefully when CTRL+C / SIGTERM is called:

IHost host = new HostBuilder()
    .ConfigureServices( ( hostContext, services ) =>
    {
        services.AddHostedService<MyService>();
    })
    .UseConsoleLifetime()
    .Build();

host.Run();  // use RunAsync() if you have access to async Main()

I've found this set of patterns to work very well outside of ASP.NET applications.

Be aware that Microsoft has built against .NET Standard so you don't need to be on .NET Core to take advantage of these new conveniences. If you're working in Framework just add the relevant NuGet packages. The package is built against .NET Standard 2.0 so you need to be on Framework 4.6.1 or above. You can find the code for all of the infrastructure here and feel free to poke around at the implementations for all the abstractions you are working with: https://github.com/aspnet/Extensions

Timothy Jannace
  • 1,401
  • 12
  • 18
  • 1
    Does `RunConsoleAsync` instead of `RunAsync` replace the need to use `UseConsoleLifetime` ? – Aaron Hudon May 05 '20 at 18:37
  • 1
    I believe they've moved the code into the aspnetcore repo now, but yes that's exactly what it used to do: https://github.com/dotnet/extensions/blob/ea0df662ab06848560d85545f3443964c57e9318/src/Hosting/Hosting/src/HostingHostBuilderExtensions.cs#L157 – Timothy Jannace May 06 '20 at 01:54
  • 1
    @AaronHudon yes it is just a convenience method that chains console lifetime, build, & run async all together. https://github.com/dotnet/runtime/blob/master/src/libraries/Microsoft.Extensions.Hosting/src/HostingHostBuilderExtensions.cs#L172 – Buvy Nov 17 '20 at 18:24
-1

In Startup.cs, you can terminate the application with the Kill() method of the current process:

        public void Configure(IHostApplicationLifetime appLifetime)
        {
            appLifetime.ApplicationStarted.Register(() =>
            {
                Console.WriteLine("Press Ctrl+C to shut down.");
            });

            appLifetime.ApplicationStopped.Register(() =>
            {
                Console.WriteLine("Shutting down...");
                System.Diagnostics.Process.GetCurrentProcess().Kill();
            });
        }

Program.cs

Don't forget to use UseConsoleLifetime() while building the host.

Host.CreateDefaultBuilder(args).UseConsoleLifetime(opts => opts.SuppressStatusMessages = true);
Alper Ebicoglu
  • 8,884
  • 1
  • 49
  • 55