在.NET Core 2.1中使用通用主机实现优雅关闭

25

.NET Core 2.1引入了新的通用主机(Generic Host),可以利用Web Host的所有优势来托管非HTTP工作负载。目前关于它的信息和使用方法不是很多,但我将以下文章作为起点:

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

我的.NET Core应用程序启动后,通过RabbitMQ消息代理监听新请求,并在用户请求下关闭(通常通过控制台中的Ctrl+C)。然而, 关闭并不完美 - 应用程序返回控制权给操作系统时仍存在未完成的后台线程。我可以通过控制台消息看到它的存在 - 当我在控制台中按下Ctrl+C时,我会看到我的应用程序输出几行控制台消息,然后是操作系统命令提示符,再然后是我的应用程序的控制台输出。

这是我的代码:

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;
    }
}

当我按下Ctrl+C时,在控制台中看到以下输出(当没有请求正在处理时):

停止工作池...
命令提示符
工人id:处理完毕。

如何优雅地关闭?


1
工作者是否听取取消标记?在“// …worker logic…”中的代码应定期检查“this.CancellationToken”,并在其被信号通知时退出。 - Panagiotis Kanavos
1
@PanagiotisKanavos 是的,当然。 - Grayver
1
仍然不清楚您在VideoProcessingWorker中究竟是如何处理令牌的。您只是检查IsCancellationRequested还是将令牌传递给某些任务,以便它们可以通过抛出TaskCancelledException来取消。StopAsyc方法可能会持续无限期等待完成,因此您展示的代码看起来是正确的,问题似乎隐藏在未显示的部分中。如果您能使用更简单的代码重现问题并发布它,那就太好了。 - sich
4个回答

19

你需要 IApplicationLifetime。这为您提供了有关应用程序启动和关闭的所有所需信息。您甚至可以通过它触发关闭,使用 appLifetime.StopApplication();

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

代码片段(如果链接失效):

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

    return Task.CompletedTask;
}

7
谢谢,我已经阅读了关于这个接口及其事件的内容。但是所有的关闭逻辑不是应该在IHostedService.StopAsync方法中吗?主机不应该等待所有托管服务的StopAsync方法完成吗? - Grayver
3
没错,我应该更仔细地阅读你的问题。我认为问题可能出在你的 try finally 代码块中。你是否尝试过在 finally 中等待任务完成(使用 Task.Delay(Timeout.Infinite, cancellationToken))? - Gabsch
2
不幸的是,它没有帮助。当我在finally部分添加Task.Delay并尝试使用Ctrl+C停止宿主时,它会打印“正在停止工作池”,然后命令提示符出现,然后它会挂起几秒钟,最终崩溃并显示OperationCanceledException异常。我认为,在宿主不等待优雅关闭并准备开始“强制”关闭时,StopAsync方法中的cancellationToken参数会被取消。 - Grayver
2
抱歉回复晚了,我没有收到通知。Task.Delay 抛出 OperationCanceledException 是预期的行为。只需捕获和处理 OperationCanceledException 即可。 - Gabsch
1
所以我在OnStopping中创建了一个断点,在IISExpress中选择“停止网站”,一秒钟后达到了断点,另外3秒钟后(当我查看调用堆栈时)进程就被杀死了。 这不好,因为a)我的关闭需要一些时间,b)我必须调用异步代码。 - springy76
显示剩余3条评论

11

我将分享一些非 WebHost 项目中非常有效的模式。

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();
        }
    }
}

关于这个类的一些注意事项:

  1. 我使用 BackgroundService 抽象类来表示我的服务。它在 Microsoft.Extensions.Hosting.Abstractions 包中可用。我相信这计划在 .NET Core 3.0 中自带。
  2. ExecuteAsync 方法需要返回一个代表正在运行的服务的 Task。注意:如果您有一个同步服务,请使用 Task.Run() 包装您的 "Run" 方法。
  3. 如果您想为您的服务进行其他设置或拆卸,您可以注入应用程序生命周期服务并钩入事件。我添加了一个事件,在服务完全停止后触发。
  4. 因为您没有像 MVC 项目中那样为每个 web 请求创建新范围的自动魔法,所以必须为作用域服务创建自己的范围。将 IServiceProvider 注入服务以执行此操作。所有对该范围的依赖都应使用 AddScoped() 添加到 DI 容器中。

在 Main( string[] args ) 中设置主机,以便在 CTRL+C / SIGTERM 被调用时正常关闭:

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

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

我发现这套模式在 ASP.NET 应用程序以外的地方也非常有效。

请注意,微软已经构建了针对 .NET Standard 的内容,所以你不需要在 .NET Core 上才能利用这些新的便利。如果你在 Framework 中工作,只需添加相关的 NuGet 包即可。该包基于 .NET Standard 2.0 构建,因此您需要使用 Framework 4.6.1 或更高版本。您可以在此处找到所有基础架构的代码,并随意查看要使用的所有抽象实现: https://github.com/aspnet/Extensions


1
RunConsoleAsync 代替 RunAsync 是否可以替换使用 UseConsoleLifetime 的需要? - Aaron Hudon
1
我相信他们现在已经将代码移动到aspnetcore存储库中了,但是以前它确实是这样做的:https://github.com/dotnet/extensions/blob/ea0df662ab06848560d85545f3443964c57e9318/src/Hosting/Hosting/src/HostingHostBuilderExtensions.cs#L157 - Timothy Jannace
1
@AaronHudon 是的,它只是一个方便的方法,将控制台生命周期、构建和异步运行全部链接在一起。https://github.com/dotnet/runtime/blob/master/src/libraries/Microsoft.Extensions.Hosting/src/HostingHostBuilderExtensions.cs#L172 - Buvy

1
下面是一段丑陋的代码(例如没有使用using块),但希望您能看到以下必要的顺序,以便优雅地关闭IHost(以及其中注册的任何IHostedService实例):
  1. 调用 IHost.StartAsync()(而不是 IHost.Run(),后者是一个阻塞调用,直到主机关闭或已关闭才返回)。

  2. 然后,当您需要关闭主机时,调用 IHost.StopAsync(),它会启动优雅的关闭过程,但立即返回。

    我相信您可以调用 IHostedApplicationLifetime.StopApplication(例如,host.Services.GetService<IHostApplicationLifetime>().StopApplication()),它具有相同的行为。如果您想从 IHostedService 内部启动(优雅的)主机关闭,这可能是唯一的方法,因为主机在启动期间向容器注册了 IHostedApplicationLifetime,因此它可以被注入到您的服务中(包括您的 IHostedService 实例)。不过,我不确定 IHost 是否在容器中可用。

  3. 然后调用 IHost.Dispose(IHost 扩展了 IDisposable),它会等待所有已注册的 IHostedService 实例停止/关闭。

    您可能可以通过调用 host.StopAsync().Wait() 或 host.WaitForShutdown() 来实现这一点,但是尽管它们似乎可以工作,但它们都会打印一条消息,提示应该在主机上调用 Dispose。但 Dispose 似乎可以完成这两个方法的工作(它会等待所有 IHostedService 实例关闭),然后释放 IHost。因此,我只调用 IHost.Dispose()。

namespace LoggingTest
{
    class Program
    {
        static void Main(string[] args)
        {
            IHostBuilder hostBuilder =
                Host.CreateDefaultBuilder(args)
                    .ConfigureServices((hostBuilderContext, services) =>
                    {
                        services.AddHostedService<Worker>();
                    });

            IHost host = hostBuilder.Build();

            host.StartAsync();//if we had called host.Run instead, that method call
            //is synchronous. The only way to exit the app then would be
            //for the user to close the window (for some reason, even though
            //the default IHostLifetime that is created by the host is ConsoleLifetime
            //and I have even tried calling hostBuilder.UseConsoleLifetime() explicitly,
            //Ctrl+C is not handled by the IHostLifetime as expected). This
            //unfortunately results in an immediate (non graceful) shutdown and
            //any registered IHostedService objects, even though they were started
            //by the host, are simply terminated and not shutdown properly.
            //
            //Therefore to perform a graceful shutdown ourselves, we wait for
            //a key to be pressed, as follows:
            Console.ReadLine();

            //now we proceed with shutdowm...first get a logger before the host
            //is disposed (we wouldn't be able to get a logger once host dies
            //but given that we are obtaining it now, we can still write log messages
            //after the host has been disposed).
            ILogger<Program> logger = host.Services.GetService<ILogger<Program>>();

            // host.Services.GetService<IHostApplicationLifetime>().StopApplication();
            //
            //The above commented out line would have worked as well but I prefer
            //calling stop on the host. Even StopApplication is async and returns
            //immediately.
            //
            host.StopAsync();

            logger.LogInformation(
                "Finished calling IHostApplicationLifetime.StopApplication...");

            host.Dispose();//This line waits until all registered IHostedService
            //instances have shutdown. 

            logger.LogInformation("Finished calling IHost.Dispose...");

        }


    }

    internal class Worker : BackgroundService
    {
        private readonly ILogger<Worker> _logger;

        public Worker(ILogger<Worker> logger)
        {
            this._logger = logger;
        }

        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                _logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
                try
                {
                    await Task.Delay(1000, stoppingToken);
                }
                catch (TaskCanceledException)
                {
                    //this needs to be silenced because when token is set to cancelled
                    //and Task.Delay then returns, it actually throws a TaskCanceledException
                    //But this is normal behaviour as when IHostedService is asked to
                    //shut down by host, the token would be set to cancelled.
                }
                catch
                {
                    throw;//but any other excetion should be thrown
                }
            }

            _logger.LogInformation("Cancellation requested...worker is finishing up");
            Task.Delay(10000).Wait();
            _logger.LogInformation("Worker is now finished");

        }
    }
}


-1
Startup.cs 中,您可以使用当前进程的 Kill() 方法终止应用程序:
        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

在构建主机时,不要忘记使用UseConsoleLifetime()

Host.CreateDefaultBuilder(args).UseConsoleLifetime(opts => opts.SuppressStatusMessages = true);

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接