如何确保优雅地关闭 IHostedService

我最近遇到一个问题,当我们的应用程序关闭时,它没有在我们的 IHostedService 实现中运行 StopAsync 方法。结果发现,这是由于一些服务对关机信号响应时间过长造成的。在这篇文章中,我展示了一个问题的示例,讨论了它发生的原因以及如何避免它。

使用 IHostedService 运行后台服务

ASP.NET Core 2.0 引入了 IHostedService 接口用于运行后台任务。该接口包括两个方法:

public interface IHostedService
{
    Task StartAsync(CancellationToken cancellationToken);
    Task StopAsync(CancellationToken cancellationToken);
}

StartAsync 在应用程序启动时被调用。在 ASP.NET Core 2.x 中,这在应用程序开始处理请求之后发生的,而在 ASP.NET Core 3.x 中,托管服务是在应用程序开始处理请求之前启动的。

StopAsync 在应用程序接收到关闭(SIGTERM)信号时被调用,例如当您在控制台窗口中按下 CTRL+C,或者应用程序被宿主系统停止时。这允许您关闭任何打开的连接,释放资源,并根据需要清理您的类。

在实际操作中,实现这个接口实际上有一些细微之处,这意味着你通常希望从辅助类 BackgroundService 派生。

如果你想了解更多,Steve Gordon 在 Pluralsight 上有一门课程“Building ASP.NET Core Hosted Services and .NET Core Worker Services”。

关闭 IHostedService 实现时遇到的问题

我最近看到的问题是在应用程序关闭时导致抛出 OperationCanceledException:

Unhandled exception. System.OperationCanceledException: The operation was canceled.
   at System.Threading.CancellationToken.ThrowOperationCanceledException()
   at Microsoft.Extensions.Hosting.Internal.Host.StopAsync(CancellationToken cancellationToken)

我将这个问题追踪到了一个特定的 IHostedService 实现上。我们使用 IHostedService 作为我们每个 Kafka 消费者的主机。具体的细节并不重要——关键在于关闭 IHostedService 相对较慢:可能需要几秒钟来取消订阅。

Part of the problem is the way the Kafka library (and underlying librdkafka library) uses synchronous, blocking Consume calls, instead of async, cancellable calls. There’s not a great way around that.

通过一个例子最容易理解这个问题。

展示问题

最简单理解这个问题的方式是创建一个包含两个 IHostedService 实现的应用程序:

  • NormalHostedService 在启动和关闭时记录日志,然后立即返回。
  • SlowHostedService 在启动和停止时记录日志,但完成关机需要 10 秒。

以下是为这两个类提供的实现。 NormalHostedService 非常简单:

public class NormalHostedService : IHostedService
{
    readonly ILogger<NormalHostedService> _logger;

    public NormalHostedService(ILogger<NormalHostedService> logger)
    {
        _logger = logger;
    }

    public Task StartAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("NormalHostedService started");
        return Task.CompletedTask;
    }

    public Task StopAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("NormalHostedService stopped");
        return Task.CompletedTask;
    }
}

SlowHostedService 几乎完全相同,但它有一个 Task.Delay,需要 10 秒来模拟缓慢关机过程:

public class SlowHostedService : IHostedService
{
    readonly ILogger<SlowHostedService> _logger;

    public SlowHostedService(ILogger<SlowHostedService> logger)
    {
        _logger = logger;
    }

    public Task StartAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("SlowHostedService started");
        return Task.CompletedTask;
    }

    public async Task StopAsync(CancellationToken cancellationToken)
    {
        _logger.LogInformation("SlowHostedService stopping...");
        await Task.Delay(10_000);
        _logger.LogInformation("SlowHostedService stopped");
    }
}

我在实践中遇到的 IHostedService 只需要 1 秒就能关闭,但我们有很多这样的设备,所以总体效果和上面提到的是一样的!

在这种情况下,服务在 ConfigureServices 中注册的顺序很重要——为了演示这个问题,我们需要先关闭 SlowHostedService服务是按相反的顺序关闭的,这意味着我们需要最后注册它:

public void ConfigureServices(IServiceCollection services)
{
    services.AddHostedService<NormalHostedService>();
    services.AddHostedService<SlowHostedService>();
}

当我们运行应用程序时,您会像往常一样看到启动日志:

info: ExampleApp.NormalHostedService[0]
      NormalHostedService started
info: ExampleApp.SlowHostedService[0]
      SlowHostedService started
...
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.

然而,如果您按下 CTRL+C 关闭应用程序,会出现一个问题。 SlowHostedService 完成关闭,但随后抛出了一个 OperationCanceledException

info: Microsoft.Hosting.Lifetime[0]
      Application is shutting down...
info: ExampleApp.SlowHostedService[0]
      SlowHostedService stopping...
info: ExampleApp.SlowHostedService[0]
      SlowHostedService stopped

Unhandled exception. System.OperationCanceledException: The operation was canceled.
   at System.Threading.CancellationToken.ThrowOperationCanceledException()
   at Microsoft.Extensions.Hosting.Internal.Host.StopAsync(CancellationToken cancellationToken)
   at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.WaitForShutdownAsync(IHost host, CancellationToken token)
   at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.RunAsync(IHost host, CancellationToken token)
   at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.RunAsync(IHost host, CancellationToken token)
   at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.Run(IHost host)
   at ExampleApp.Program.Main(String[] args) in C:\xxx\blog-examples\SlowShutdown\Program.cs:line 16

NormalHostedService.StopAsync() 方法从未被调用。如果服务需要执行一些清理工作,那么您会遇到问题。例如,您可能需要从 Consul 优雅地注销服务,或者从 Kafka 主题取消订阅——现在这些都不会发生。

所以这里发生了什么?那个超时是从哪里来的?

原因:HostOptions.ShutDownTimeout

您可以在运行于应用程序关闭时的框架 Host 实现代码中找到问题。下面是一个简化版本:

internal class Host: IHost, IAsyncDisposable
{
    private readonly HostOptions _options;
    private IEnumerable<IHostedService> _hostedServices;

    public async Task StopAsync(CancellationToken cancellationToken = default)
    {
        // Create a cancellation token source that fires after ShutdownTimeout seconds
        using (var cts = new CancellationTokenSource(_options.ShutdownTimeout))
        using (var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken))
        {
            // Create a token, which is cancelled if the timer expires
            var token = linkedCts.Token;

            // Run StopAsync on each registered hosted service
            foreach (var hostedService in _hostedServices.Reverse())
            {
                // stop calling StopAsync if timer expires
                token.ThrowIfCancellationRequested();
                try
                {
                    await hostedService.StopAsync(token).ConfigureAwait(false);
                }
                catch (Exception ex)
                {
                    exceptions.Add(ex);
                }
            }
        }

        // .. other stopping code
    }
}

这里的要点是设置为在 HostOptions.ShutdownTimeout 之后触发的 CancellationTokenSource默认情况下,这会在 5 秒后触发。这意味着托管服务在 5 秒后会被放弃关闭——所有 IHostedService 的关闭必须在超时时间内完成。

public class HostOptions
{
    public TimeSpan ShutdownTimeout { get; set; } = TimeSpan.FromSeconds(5);
}

foreach 循环的第一轮迭代中,执行了 SlowHostedService.Stopasync() ,这需要 10 秒才能运行完成。在第二轮迭代中,超出了 5 秒的限时,因此 token.ThrowIfCancellationRequested(); 抛出了一个 OperationConcelledException 。这退出了控制流程, NormalHostedService.Stopasync() 从未被执行。

这个问题有一个简单的解决办法——增加关闭超时时间!

解决方案:增加关闭超时时间

HostOptions 默认情况下没有在任何地方明确配置,因此您需要在您的 ConfigureSerices 方法中手动配置它。例如,以下配置将超时时间增加到 15 秒:

public void ConfigureServices(IServiceCollection services)
{
    services.AddHostedService<NormalHostedService>();
    services.AddHostedService<SlowShutdownHostedService>();
    
    // Configure the shutdown to 15s
    services.Configure<HostOptions>(
        opts => opts.ShutdownTimeout = TimeSpan.FromSeconds(15));
}

另外,您也可以从配置中加载超时设置。例如,如果您在 appsettings.json 中添加以下内容:

{
    "HostOptions": {
        "ShutdownTimeout": "00:00:15"
    }
    // other config
}

然后,您可以将 HostOptions 配置节绑定到 HostOptions 对象:

public class Startup
{
    public IConfiguration Configuration { get; }
    public Startup(IConfiguration configuration)
    {
        Configuration = configuration;
    }

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddHostedService<NormalHostedService>();
        services.AddHostedService<SlowShutdownHostedService>();

        // bind the config to host options
        services.Configure<HostOptions>(Configuration.GetSection("HostOptions"));
    }
}

这会将序列化的 TimeSpan00:00:15 绑定到 HostOptions 值,并将超时设置为 15 秒。在此配置下,当我们停止应用程序时,所有服务都会正确关闭:

info: Microsoft.Hosting.Lifetime[0]
      Application is shutting down...
info: SlowShutdown.SlowShutdownHostedService[0]
      SlowShutdownHostedService stopping...
info: SlowShutdown.SlowShutdownHostedService[0]
      SlowShutdownHostedService stopped
info: SlowShutdown.NormalHostedService[0]
      NormalHostedService stopped

您的应用程序现在将等待最多 15 秒,以便所有托管服务完成关闭后再退出!

总结

在这篇文章中,我讨论了一个最近的问题,当我们的应用程序关闭时, StopAsync 方法没有在我们的 IHostedService 实现中运行。这是由于一些后台服务对关机信号的响应时间过长,超过了关机超时时间。我用一个服务需要 10 秒关闭的例子演示了这个问题,但实际上,只要所有服务的总关闭时间超过默认的 5 秒,就会发生这种情况。

问题的解决方案是将 HostOptions.ShutdownTimeout 配置值延长至超过 5 秒,使用标准的 ASP.NET Core IOptions<T> 配置系统。

本文转译自:Extending the shutdown timeout setting to ensure graceful IHostedService shutdown