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 循环:
从图中可以看到,取消令牌是连接宿主与后台循环的关键纽带。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,确保停止请求能及时生效。
- 日志覆盖启动、每轮结果、停止三个节点,高频循环降低日志级别。