2. IHostedService与BackgroundService基础

在 .NET 通用主机(Generic Host)模型里,后台任务并不是“随手起一个线程”那么简单。宿主需要知道有哪些长期运行的工作、它们何时启动、以什么顺序停止、停止时给多长时间收尾,以及某个任务崩溃后是否会影响整个进程。IHostedService 与 BackgroundService 就是回答这些问题的两个核心抽象:前者是契约,后者是面向“长时间循环任务”的便捷基类。

一、接口定义:IHostedService 只约定两件事

IHostedService 的定义非常克制,只有两个方法:

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

StartAsync 在宿主启动阶段被调用,用于初始化资源、注册监听、启动后台循环;StopAsync 在宿主关闭阶段被调用,用于停止循环、释放资源。注意两个方法都返回 Task,这意味着宿主会等待它们完成——如果 StartAsync 长时间不返回,整个应用启动就会被阻塞。因此约定是:StartAsync 中只做“启动动作”,不要把整个后台循环写在里面等待;真正的循环应放到独立任务中执行。

BackgroundService 是 IHostedService 的抽象实现,它把“启动一个长期任务、停止时取消该任务”这套模式封装好了:

public abstract class BackgroundService : IHostedService, IDisposable
{
    protected abstract Task ExecuteAsync(CancellationToken stoppingToken);
    public virtual Task StartAsync(CancellationToken cancellationToken);
    public virtual Task StopAsync(CancellationToken cancellationToken);
}

继承 BackgroundService 时,我们只需要实现 ExecuteAsync,在其中编写循环逻辑,并监听 stoppingToken。StartAsync 会调用 ExecuteAsync 并把返回的 Task 保存起来,StopAsync 则触发取消并等待该 Task 结束。

二、生命周期与启动、停止流程

宿主启动时,会按注册顺序依次调用所有 IHostedService 的 StartAsync;宿主停止时,则按相反顺序调用 StopAsync。这个“正序启动、逆序停止”的规则很重要:它保证了依赖关系正确的服务先启动、后停止,避免先启动的服务在依赖它的服务还在运行时就被关闭。

下面这张图展示了宿主与两个后台服务之间的启动/停止时序,以及取消信号如何从宿主传递到 ExecuteAsync 循环:

宿主 Host ServiceA(先注册) ServiceB(后注册) 取消令牌 启动阶段(正序) StartAsync() StartAsync() ExecuteAsync 启动循环 ExecuteAsync 启动循环 运行阶段 两个后台循环并行运行,各自监听 stoppingToken 停止阶段(逆序) StopAsync() 先停后注册的 ServiceB StopAsync() 再停 ServiceA Cancel() Cancel() 取消令牌触发后,ExecuteAsync 中的循环应尽快退出并完成清理

从图中可以看到,取消令牌是连接宿主与后台循环的关键纽带。ExecuteAsync 中必须把 stoppingToken 传入所有可等待操作(如 Task.Delay、异步 IO),否则 StopAsync 会一直等待任务结束,最终可能触发宿主关闭超时。

三、异常处理:不要让异常悄悄杀死进程

BackgroundService 有一个容易被忽视的行为:如果 ExecuteAsync 抛出未处理异常,该异常会被包装进返回的 Task 中。在 .NET 6 及以后,默认情况下这个异常会导致整个宿主停止(HostOptions.BackgroundServiceExceptionBehavior 默认为 StopHost)。也就是说,一个后台任务的意外崩溃可能拖垮整个应用。

因此,正确的做法是在 ExecuteAsync 内部用 try/catch 包裹循环体,把异常记录到日志,并根据业务决定是继续重试还是退出。下面是一个带异常隔离和退避重试的示例:

public class OrderSyncService : BackgroundService
{
    private readonly ILogger<OrderSyncService> _logger;
    private readonly IOrderApi _api;

    public OrderSyncService(ILogger<OrderSyncService> logger, IOrderApi api)
    {
        _logger = logger;
        _api = api;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation("订单同步服务启动,时间 {Time}", DateTimeOffset.Now);

        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                var count = await _api.SyncAsync(stoppingToken);
                _logger.LogInformation("本轮同步完成,处理 {Count} 条订单", count);
                await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
            }
            catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
            {
                // 正常停止,不视为错误
                break;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "订单同步失败,5 秒后重试");
                await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken);
            }
        }

        _logger.LogInformation("订单同步服务已停止");
    }
}

这里有两个关键点:第一,用 catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) 区分“正常取消”和“真实异常”,避免把停止信号误报为错误;第二,异常分支中仍然使用 stoppingToken 进行延迟,保证停止请求能及时生效。

四、日志记录:让后台任务可观测

后台任务没有 HTTP 请求上下文,出问题时很难定位。因此日志要覆盖三个关键节点:启动、每轮执行结果、停止。建议使用结构化日志,把关键参数作为字段输出,例如同步条数、耗时、重试次数。对于高频循环,避免每轮都打 Information 级别日志,可以改为 Debug,或按固定间隔输出一次统计信息。

在注册服务时,可以通过 services.AddHostedService<OrderSyncService>() 完成注册。如果需要控制停止超时,可配置 HostOptions.ShutdownTimeout,默认 30 秒;若后台任务清理较慢,应适当调大该值,或在 StopAsync 中主动缩短清理路径。

五、实践要点小结

  • StartAsync 只做启动动作,循环逻辑放在 ExecuteAsync 中,避免阻塞宿主启动。
  • 启动正序、停止逆序,注册顺序即依赖顺序。
  • ExecuteAsync 必须捕获异常并记录日志,防止单个任务崩溃导致宿主停止。
  • 所有可等待操作都要传入 stoppingToken,确保停止请求能及时生效。
  • 日志覆盖启动、每轮结果、停止三个节点,高频循环降低日志级别。