托管服务(Hosted Service)在 ASP.NET Core 里算是一个经常被忽略、但现实中又特别能救场的官方组件。很多人写后台任务第一反应是开一个Timer丢在Startup里,或者干脆起一个单独的控制台程序,结果要么任务跟着应用生命周期走得不干净,要么部署的时候要多管一套进程。托管服务把这堆事统一收编进了 Web 宿主的生命周期里,让后台任务和 Web 应用吃同一份配置、同一套依赖注入容器,还能享受优雅停机。这篇文章我会重点讲清楚它底层跑的逻辑、不同接口之间的差异、真实项目里怎么注册怎么写才能不踩坑,以及我在实际使用中踩过的几个比较深的坑。
适合看这篇的读者,主要是已经在用 ASP.NET Core 做 Web API 或 MVC 项目、但还没系统搞明白后台任务该怎么优雅落地的同学。如果你只是想在应用启动时跑一段数据预热逻辑,或者想写一个定时扫描订单状态的轮询任务,甚至想搞一个长连接消费者,那托管服务几乎是唯一不需要额外引入中间件就能干净解决问题的方案。文中的代码都是基于 .NET 6/8 的默认模板写法,老版本稍微调整一下也能对应上。
1. 先理清托管服务到底在解决什么问题
1.1 Web 应用里的后台任务为什么不能随便写
我见过很多项目里用最粗暴的方式做后台任务:在Startup.Configure里 new 一个Timer,回调里写业务逻辑。这种方式最大的问题有两个:第一,IHost在停止的时候,没有人会通知这个Timer停下来,进程可能已经在销毁依赖的时候Timer还在触发回调,轻则报 ObjectDisposedException,重则数据写到一半连接池已经关闭;第二,这种任务完全游离在依赖注入之外,用不了DbContext、IOptions、ILogger这些基础设施,最后只能靠服务定位器去硬拿,代码越写越脏。
托管服务就是把“后台任务的生命周期”和“应用宿主生命周期”绑定在一起的正规解法。你只需要在ConfigureServices里调用AddHostedService<T>(),框架会保证:应用启动时,先启动你注册的托管服务;应用关闭时,先给托管服务发取消信号,并等待它完成清理再释放整个容器。
1.2 三个典型的业务场景
我拆几个最常见的使用场景,方便你对号入座:
- 定时轮询型任务,比如每隔几分钟扫描一次数据库里超时未支付的订单,然后执行关单操作。这种场景用
BackgroundService加PeriodicTimer或者Task.Delay就能稳定实现。 - 消息队列消费者,比如监听某个队列,有消息就丢给处理器去处理。托管服务在应用关闭时会收到
CancellationToken通知,能让你优雅地停止拉取消息、把正在处理的这批消息跑完再退出。 - 应用启动预热,比如启动时加载配置、初始化缓存、预热数据库连接池。这类任务甚至不需要是循环的,
StartAsync里执行完一次性逻辑就行,只要不阻塞返回,Web 应用就会照常接收请求。
还有一类容易被忽略的场景是“环境隔离”。比如只有测试环境需要启动一个假数据生成器,生产环境不需要。你可以把托管服务注册到特定的环境判断条件里,或者用IHostEnvironment在服务内部做开关,这比在外面起一个独立进程要灵活得多。
2. 核心机制拆解:IHostedService 和 BackgroundService 到底怎么跑
2.1 IHostedService 的最小契约
IHostedService是托管服务的地基层,它只定义了两个方法:
public interface IHostedService { Task StartAsync(CancellationToken cancellationToken); Task StopAsync(CancellationToken cancellationToken); }框架的启动流程是:应用启动时,遍历容器里注册的所有IHostedService,按注册顺序依次调用它们的StartAsync。这里有一个容易误导新手的点:StartAsync不是“启动后台循环”就完事了,它返回的Task会被宿主等待。如果你在StartAsync里写了一个while(true)的循环且不返回Task,那你的 Web 应用会被卡在启动阶段,永远开始不了接收 HTTP 请求。
所以正确的姿势是:StartAsync只负责“创建后台执行的环境”并立刻返回;真正的循环逻辑应该放到一个不阻塞返回的独立任务里跑。这也是为什么微软提供了BackgroundService这个基类。
2.2 BackgroundService 帮你做了一半的事
BackgroundService实现了IHostedService,核心逻辑大致长这样:
public abstract class BackgroundService : IHostedService { private Task? _executeTask; public virtual Task StartAsync(CancellationToken cancellationToken) { _executeTask = ExecuteAsync(_stoppingCts.Token); return _executeTask.IsCompleted ? _executeTask : Task.CompletedTask; } public virtual async Task StopAsync(CancellationToken cancellationToken) { if (_executeTask == null) return; try { _stoppingCts.Cancel(); } finally { await Task.WhenAny(_executeTask, Task.Delay(Timeout.Infinite, cancellationToken)); } _stoppingCts.Dispose(); } }你只需要继承并实现ExecuteAsync(CancellationToken stoppingToken),在里面写你的循环逻辑。框架在调用你的ExecuteAsync时传入的那个CancellationToken,会在StopAsync被调用时自动转为取消状态。这就解决了前面说的“应用关闭时通知后台任务”的问题。
我在前面特意省略了字段初始化的细节,是为了强调一个关键点:StopAsync中真正做的是“取消令牌 + 等待任务结束”。如果你的循环体完全忽略了这个令牌,比如你写了一个while (true)而且Thread.Sleep也不用,那应用关闭时就会一直卡在等待任务结束这一步,直到宿主的强制超时触发。
2.3 生命周期的钩子:IHostApplicationLifetime
除了让任务感知取消,托管服务经常还需要知道“应用什么时候真正开始服务请求”和“什么时候开始关闭”。这个信息藏在IHostApplicationLifetime里,它提供了三个事件:
public interface IHostApplicationLifetime { CancellationToken ApplicationStarted { get; } CancellationToken ApplicationStopping { get; } CancellationToken ApplicationStopped { get; } }来看一个典型场景:缓存预热服务。你希望 Web 应用真正开始接收请求之前,缓存就已经预热完毕。但托管服务的StartAsync是并行启动的,你的预热任务还没跑完,HTTP 请求可能已经打进来了。解决办法是监听ApplicationStarted:
public class CacheWarmupService : BackgroundService { private readonly IHostApplicationLifetime _appLifetime; public CacheWarmupService(IHostApplicationLifetime appLifetime) { _appLifetime = appLifetime; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await _appLifetime.ApplicationStarted.WaitAsync(stoppingToken); // 此时才做真正的预热,因为所有托管服务都已启动 await WarmupAsync(stoppingToken); } }这样做的好处是,你不会阻塞应用的启动过程,同时能保证实际上开始接受请求后,预热也在紧锣密鼓地跑。注意我用了WaitAsync(stoppingToken),别用Wait(),否则停止时可能因为阻塞而无法及时响应取消。
3. 实操:从定义托管服务到多服务协调
3.1 用 BackgroundService 写一个订单超时扫描任务
我直接给一个可以在项目里复用的完整例子,任务内容是每 30 秒扫描一次“超时未支付订单”,扫描到就关单。先定义服务类:
public class OrderTimeoutScanService : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger<OrderTimeoutScanService> _logger; private readonly TimeSpan _scanInterval = TimeSpan.FromSeconds(30); public OrderTimeoutScanService( IServiceScopeFactory scopeFactory, ILogger<OrderTimeoutScanService> logger) { _scopeFactory = scopeFactory; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("订单超时扫描服务已启动"); while (!stoppingToken.IsCancellationRequested) { try { await ScanAndCloseTimeoutOrdersAsync(stoppingToken); } catch (OperationCanceledException) { break; } catch (Exception ex) { _logger.LogError(ex, "订单扫描任务执行异常"); } await Task.Delay(_scanInterval, stoppingToken); } _logger.LogInformation("订单超时扫描服务已停止"); } private async Task ScanAndCloseTimeoutOrdersAsync(CancellationToken ct) { // 这里是关键:不要在构造函数里注入 DbContext // BackgroundService 是单例,而 DbContext 是 Scoped using var scope = _scopeFactory.CreateScope(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var now = DateTime.UtcNow; var timeoutTime = now.AddMinutes(-30); var expiredOrders = await db.Orders .Where(o => o.Status == OrderStatus.PendingPayment && o.CreatedAt < timeoutTime) .ToListAsync(ct); foreach (var order in expiredOrders) { order.Status = OrderStatus.Closed; _logger.LogInformation("订单 {OrderId} 超时未支付,已自动关闭", order.Id); } await db.SaveChangesAsync(ct); } }这里有几个容易踩的细节,我说得更直白一点:
- 注入
IServiceScopeFactory而不是直接注入AppDbContext。因为BackgroundService本身会被注册为单例,你如果在构造函数里注入了作用域服务,相当于把Scoped提升到了单例生命周期,而且实际运行时大概率会抛“scope 已销毁”之类的错误。 - 用
Task.Delay(_scanInterval, stoppingToken)而不是Thread.Sleep。前者能在取消时立刻抛OperationCanceledException,后者只会继续傻等。 - 把整个扫描主体用 try/catch 包住。不包的话,只要出现一次数据库超时这类异常,
ExecuteAsync就直接退出了,这个后台服务等于“静默死亡”,进程却还活着,非常坑。
注册方式是在Program.cs里一行代码:
builder.Services.AddHostedService<OrderTimeoutScanService>();3.2 从配置文件读取扫描间隔
很多项目会把这种时间间隔直接怼在代码里,维护起来很痛苦。建议用强类型配置,比如在appsettings.json里:
{ "OrderScanSettings": { "ScanIntervalSeconds": 30, "TimeoutMinutes": 30 } }然后定义一个配置类:
public class OrderScanSettings { public int ScanIntervalSeconds { get; set; } public int TimeoutMinutes { get; set; } }注册和读取:
builder.Services.Configure<OrderScanSettings>( builder.Configuration.GetSection("OrderScanSettings"));服务内部通过IOptions<OrderScanSettings>读取。注意这里别为了图省事直接用IOptions<T>.Value在循环里一直读,配置变更时IOptionsMonitor<T>才能拿到最新值。如果你不需要热更新,IOptions<T>就行;万一将来要动态调整扫描频率,建议换IOptionsMonitor<T>并订阅OnChange事件。
3.3 多个托管服务的启动顺序与优雅停机
有时候你会注册多个托管服务,比如一个负责扫订单,一个负责定时清理日志,一个负责消费消息队列。默认情况下,宿主按注册顺序依次调用StartAsync,所以注册顺序基本就是启动顺序。如果你的服务之间有依赖关系,比如日志清理服务必须在订单扫描服务已经初始化完一些状态后才能开始跑,那其实就是控制注册顺序就能达到目的:
builder.Services.AddHostedService<DatabaseInitService>(); builder.Services.AddHostedService<OrderTimeoutScanService>(); builder.Services.AddHostedService<LogCleanupService>();停机顺序默认是反过来的,即先启动的后停止。这只是框架默认策略。如果你需要精细控制,也可以在IHostOptions层面调整,但多数场景不需要过度设计。
优雅停机有一个很关键的配置项,很多人不知道:HostOptions.ShutdownTimeout。它决定了宿主在StopAsync阶段最多等待多久。默认值是 5 秒,如果后台服务清理耗时超过 5 秒,宿主会强制结束,日志里经常出现“A timeout was reached”之类的告警。配置方法:
builder.Services.Configure<HostOptions>(options => { options.ShutdownTimeout = TimeSpan.FromSeconds(20); });我在实际项目中通常会把关闭超时调到 20~30 秒,因为消费者服务需要时间把当前正在处理的消息处理完,5 秒实在不够。但也不要调成几分钟,否则发布更新时整个实例会长时间处于“正在停止”状态。
4. 常见问题与排查技巧实录
4.1 应用停止时卡住,一直退不出去
这个现象我碰到过好多次。表现是执行dotnet run按 Ctrl+C 之后,命令行迟迟不退出,或者容器环境里发送 SIGTERM 后任务一直挂着。
排查思路分三步走:先看是不是ExecuteAsync里的循环没有响应CancellationToken;再看有没有长时间运行的数据库查询没传ct参数;最后看StopAsync里是不是自己加了奇怪的阻塞逻辑。我见过一个案例,某开发者在StopAsync里手动Task.Delay(5000).Wait(),理由是“清理需要时间”,结果反而把宿主的 5 秒超时给干穿了,宿主等不到超时就直接异常。
排查顺序建议:先把HostOptions.ShutdownTimeout临时调小,比如 2 秒,看日志里是否打印“等待超时强制结束”之类的关键日志;然后定位到具体是哪个托管服务卡住的,再单独调试它的StopAsync分支。另外有个技巧,在ExecuteAsync的循环里针对取消日志单独打一条,比如在catch (OperationCanceledException)里输出“捕获到停止信号”,这样能快速判断是否走到取消分支。
4.2 后台任务一抛异常,服务就悄悄死了
BackgroundService的默认行为是:ExecuteAsync如果抛了未处理异常,且这个异常不是OperationCanceledException,宿主会认为整个应用发生了致命错误,进程会直接挂掉。这个行为在不同版本里略有差异,但总体上可以理解为“后台服务异常 = 应用级故障”。
这其实是一个安全设计:后台任务挂了如果不退出进程,应用表面看起来活着,实际上核心能力已经缺失。但很多人不想因为一个偶然的数据库超时就让整个服务重启,所以更稳妥的做法是在ExecuteAsync内部加一层捕获,记录日志后继续下一轮循环。我上面示例代码里已经演示了这个写法。
需要特别说明的是:不要吞异常吞得太干净。至少要把Exception.ToString()完整记录到日志里,如果用了日志系统,还需要记录异常类型和堆栈。不然线上出了问题根本无从排查。
4.3 Scoped 服务注不进去,运行时报错
这个问题几乎每个刚接触托管服务的人都会遇到。原因我再强调一次:BackgroundService是单例,作用域服务的生命周期比它短。如果你在构造函数里直接注入AppDbContext或某个 Scoped 业务服务,运行时容器会抛类似Cannot resolve scoped service ... from root provider的异常。
有一种看起来能跑但其实是隐患的做法是:把服务注册成AddScoped,然后AddHostedService里通过IServiceScopeFactory每次动态创建。但要注意,如果你用ActivatorUtilities.CreateInstance配合自定义 scope 去解析托管服务,实际上把托管服务变成了“每个 scope 一个实例”,托管服务的单例语义就变了,不是常规做法。
正确姿势就一个:托管服务类本身保持无状态或只存配置和工厂,所有真正要用的作用域服务在每次循环里通过IServiceScopeFactory创建 scope 获取。
4.4 定时任务时间漂移和重复执行
用Task.Delay做周期任务时,每个周期是“执行完业务再延迟 N 秒”,而不是固定周期。如果业务跑得很慢,实际周期会大于你配置的 N 秒。这在多数场景下没问题,但如果你的业务要求严格的固定间隔,比如每 5 秒采样一次监控指标,就不能用Task.Delay了,应该用PeriodicTimer:
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5)); while (await timer.WaitForNextTickAsync(stoppingToken)) { // 业务逻辑 }PeriodicTimer是 .NET 6 引入的,它保证相邻两次 tick 的间隔基于一个固定的时间基准,业务执行时间不会导致下一次 tick 顺延。不过要注意,如果业务执行时间超过周期本身,依然会有问题,那说明你的任务设计周期太短或者单个周期太慢,需要并行化或者拆分。
重复执行的问题,通常出现在你部署多个实例的时候。比如你挂了两个副本的 Web 应用,两个副本都会跑同一个托管服务,结果订单扫描被同时执行了两次。解决思路一般有几种:用分布式锁保证只有一个实例真正执行;或者只把托管服务部署在主实例上;再有就是任务本身设计成幂等(扫描时用状态条件过滤,即使扫两遍结果也一样)。简单场景下,幂等设计最省事,复杂场景建议引入分布式锁,不要指望框架帮你解决多实例的互斥。
5. 一些项目里会用到的注册进阶写法
AddHostedService是基础,但你可以把它玩出更多花样。比如按环境条件注册:
if (builder.Environment.IsDevelopment()) { builder.Services.AddHostedService<FakeDataGeneratorService>(); }这样生产环境默认不启动假数据服务。又比如某个服务依赖的配置不存在时,可以在注册时直接跳过:
var scanSection = builder.Configuration.GetSection("OrderScanSettings"); if (scanSection.Exists()) { builder.Services.AddHostedService<OrderTimeoutScanService>(); }这两个写法都很实用,能避免“开发环境能用、生产环境报错”的尴尬情况。
如果你的托管服务需要暴露一些运行时状态,比如最近一次执行的时间、最近一次执行的结果,可以考虑用IHostedService接口之外的公共属性或方法,配合HealthChecks做健康检查。常规做法是定义一个状态类注入到服务里,服务每轮更新状态,健康检查接口读取状态并判断是否需要报警。这个玩法相当于给后台任务加了“体检指标”,线上运营时非常友好。
6. 关于测试托管服务的一点个人经验
托管服务不太好测试,因为它本质上是长生命周期组件,测试框架默认不会去启动整个 Web 宿主。我个人的做法是:把核心业务逻辑抽到独立的普通类里,托管服务只负责调度和生命周期,测试时直接测试业务逻辑类,不测托管服务本身。比如订单扫描服务,我会抽出OrderTimeoutHandler类,接受AppDbContext和日志对象,里面对应“扫描+关单”的业务逻辑;托管服务只负责定时调用它。这样单元测试就完全不依赖后台任务调度了。
如果你真的想测试托管服务的启停逻辑,可以用BackgroundService的子类替换ExecuteAsync为一个受控的TaskCompletionSource,再手动调用StartAsync和StopAsync来断言状态变化。这种方式能覆盖生命周期,但代码写起来比较啰嗦,我建议只在关键场景用。
另一个经验是:托管服务里输出日志时,记得用ILogger<T>,并且把服务名带进日志消息里。多个托管服务并行跑的时候,日志如果只输出“扫描中”“任务执行完毕”,你根本分不清是哪一个服务打出来的。我习惯在每条日志开头加上服务代号,比如[OrderScan] 当前扫描到 100 条记录,排查问题时效率高很多。
还有一个小技巧:托管服务启动时,在日志里明确输出“已开始”和“已停止”,并且配合IHostApplicationLifetime的ApplicationStopping事件再打一条日志,这样出现启动顺序问题或者异常退出时,日志时间线会非常清晰。
结尾
托管服务这个看似简单的组件,扯上生命周期、依赖注入、取消令牌之后就多了很多细节。我在实际项目中踩过最深的一个坑,是早期写的后台任务里所有数据库查询都没有传CancellationToken,结果每次发布更新时,实例都要等那个卡住的长查询把 5 秒超时耗完,用户那边表现为间歇性请求失败。后来把所有异步方法都补上了ct,并在循环主逻辑里做了异常隔离,发布更新才变得流畅起来。
所以我最后再强调一次:写托管服务时,尽量把循环体、作用域创建、取消令牌、异常处理这几个点当成固定套路来写。你省掉的每一句ct传递,最后都可能变成线上一次深夜排查事故的导火索。