ASP.NET Core后台任务与定时任务:IHostedService和BackgroundService实战指南 1. 凌晨任务消失的教训为什么后台任务不归 Host 管迟早出事做 ASP.NET Core 定时任务很多人第一步就直奔 Hangfire 或 Quartz 去了结果还没用到那一步就被 IHostedService 和 BackgroundService 这两个名字绕晕。其实在引入第三方调度框架之前宿主自带的托管服务机制已经能覆盖绝大多数简单的周期任务场景而且代码量非常少。这篇文章我就结合一次真实排障经历把两个概念从头到尾理清楚再给出可以直接抄走的定时任务写法。先说一个我实际遇到过的场景。有个报表系统需要在每天凌晨两点汇总前一天的数据并生成报表。第一版实现非常直接程序启动时手动 new 了一个 System.Threading.Timer回调里跑统计逻辑。本机调试一切正常上线第一个星期也很稳。结果后来连续三天凌晨报表都没有产出。登录服务器发现进程明明还活着但 Windows 事件日志里躺着一条应用程序池回收记录。问题的根子就在这里当 ASP.NET Core 应用托管在 IIS 或者作为 Windows 服务运行时工作进程完全可能被回收、重启。而你手动启动的那个 Timer 并不在宿主管辖范围内进程一没Timer 跟着没进程恢复后代码也没有任何地方负责把它重新创建回来。哪怕你用的是自建的 Task 或后台线程也一样。这个教训让我把一条原则刻进了自己的开发习惯里——在 ASP.NET Core 里做后台任务生命周期必须交给 Host 统一管理。所谓交给 Host 管理落到代码层面就是实现 IHostedService或者更省事一点继承 BackgroundService。宿主会在应用启动时调用你的启动方法在应用停止或进程收到退出信号时调用你的停止方法。这样无论应用是正常发布重启还是被 IIS 回收后台任务都能跟着宿主的生命周期走该启动启动该退出退出。接下来我先从最底层的接口讲起。2. 先看接口本尊IHostedService 的 StartAsync 与 StopAsync 到底在协调什么2.1 只有两个方法的接口背后却是完整的生命周期协议IHostedService 的定义极简很多第一次接触的人看完会觉得就这public interface IHostedService { Task StartAsync(CancellationToken cancellationToken); Task StopAsync(CancellationToken cancellationToken); }但简单接口不意味着简单行为。宿主在应用启动后会按照依赖注入容器里的注册顺序依次调用每个托管服务的 StartAsync在应用停止时则按照相反顺序调用 StopAsync。注意这个倒序逻辑——后启动的先停止这跟很多资源清理框架的设计是一致的避免嵌套依赖的服务在停止时出现依赖项已经被释放的问题。StartAsync 的 CancellationToken 表示的是应用启动过程是否被取消正常情况下不会触发但你在做长耗时初始化时依然应该传递这个 token。StopAsync 的 CancellationToken 则更实用它表示宿主愿意等多久一旦越过这个时间宿主会强制结束整个进程。也就是说StopAsync 不是给你无限时间慢慢停的它同样存在超时压力。2.2 新手最容易踩的坑StartAsync 里不能写死循环或长阻塞我见过不少刚接触托管服务的开发者把后台任务直接塞进 StartAsync 里写成这样public async Task StartAsync(CancellationToken cancellationToken) { while (true) { await DoWorkAsync(); await Task.Delay(TimeSpan.FromSeconds(30)); } }这段代码从语义上就是错的。宿主的启动流程会等待所有 IHostedService 的 StartAsync 返回如果你让 StartAsync 一直循环下去宿主就永远无法完成启动。在控制台宿主里表现是启动过程卡住在 Web 应用里表现是应用一直处于正在启动状态端口都不能正常响应请求。正确理解是StartAsync 返回的 Task 表示这个后台服务的启动动作已完成而不是后台工作已结束。正确的姿势是把长时间运行的循环体放到一个单独的 Task 里启动然后立刻返回启动完成。public class ManualWorker : IHostedService { private Task? _executingTask; private readonly CancellationTokenSource _stoppingCts new(); public Task StartAsync(CancellationToken cancellationToken) { _executingTask RunAsync(_stoppingCts.Token); return Task.CompletedTask; } private async Task RunAsync(CancellationToken token) { while (!token.IsCancellationRequested) { await DoWorkAsync(token); await Task.Delay(TimeSpan.FromSeconds(30), token); } } public async Task StopAsync(CancellationToken cancellationToken) { _stoppingCts.Cancel(); if (_executingTask ! null) { await _executingTask; } } }一旦自己写你马上会发现两个麻烦第一你要手动维护一个 CancellationTokenSource第二必须在 StopAsync 里自己保存任务并等待它结束。否则应用停止时你启动出去的那个 Task 根本没人等进程一结束任务就被强行掐断。这套样板代码本身并不难但它暴露了一个痛点——每个服务都要重复写还容易遗漏。2.3 如果你需要更细的生命周期钩子还有 IHostedLifecycleService顺带提一个扩展点从 .NET Core 3.0 开始框架提供了 IHostedLifecycleService 接口它在 IHostedService 的基础上增加了 StartingAsync、StartedAsync、StoppingAsync、StoppedAsync 四个回调。StartingAsync 在所有 StartAsync 之前调用StartedAsync 在所有 StartAsync 完成之后调用停止阶段同理。实际项目里用得不多但当你需要在一个服务真正启动之前做资源准备或者等所有后台服务启动完再对外暴露健康检查时这个接口能救命。对定时任务来说一般用不到知道有这层即可。3. BackgroundService 源码拆解它并不神奇只是把上面那套样板写完了3.1 一个抽象类替你把混乱的任务管理接住了BackgroundService 是抽象类实现了 IHostedService本质上是模板方法模式。它的设计目标很明确帮你解决上一章那两个麻烦——CancellationTokenSource 的维护和停止等待。你只需要填空把业务逻辑写在 ExecuteAsync 里。它的工作逻辑用大白话讲是这样StartAsync 被调用后BackgroundService 做的事情是把你的 ExecuteAsync 方法作为一个后台任务启动然后立刻返回。注意它没有真的在 StartAsync 里等你执行完而是把返回的那个 Task 保存到了内部字段。StopAsync 被调用后它把内部那个 CancellationTokenSource 取消这时你 ExecuteAsync 里传入的 stoppingToken 就会变成已取消状态。紧接着它等待之前保存的那个后台任务结束。如果 ExecuteAsync 足够配合在收到取消信号后及时退出这个等待就会很快完成。换句话说BackgroundService 帮你做的事只有一个核心启动即返回停止先通知再等待。你需要做的就是让 ExecuteAsync 响应 stoppingToken。public abstract class BackgroundService : IHostedService, IDisposable { // 伪代码思想与官方实现一致 private Task? _executeTask; private readonly CancellationTokenSource _stoppingCts new(); public virtual Task StartAsync(CancellationToken cancellationToken) { _executeTask ExecuteAsync(_stoppingCts.Token); return Task.CompletedTask; } public virtual async Task StopAsync(CancellationToken cancellationToken) { _stoppingCts.Cancel(); if (_executeTask ! null) { await _executeTask; } } protected abstract Task ExecuteAsync(CancellationToken stoppingToken); }这也是为什么网上所有基于 BackgroundService 的定时任务示例都用类似while (!stoppingToken.IsCancellationRequested)的写法。stoppingToken 就像是一枚下班铃宿主想让你停了就会摇铃你的循环体每转一圈都要检查一下铃有没有响响了就收拾东西走人。如果任务正在某个长时间操作里不响应取消信号StopAsync 就只能一直等等宿主设定的关机超时到了进程被强制结束。3.2 两者从来不是竞争关系而是接口与默认实现的关系很多人纠结该用 IHostedService 还是 BackgroundService这是个误会。BackgroundService 本身就是 IHostedService 的一个默认实现它没有增加任何宿主层面的新能力只是改变了你写代码的方式。我把关键差异整理成一张表方便你对比对比维度IHostedServiceBackgroundService本质接口完全自定义抽象类实现了 IHostedService启动方式自己在 StartAsync 里编排自动启动 ExecuteAsync 并立刻返回取消机制自己维护 CancellationTokenSource内置 stoppingToken停止行为自己保存并等待后台任务自动取消并等待 ExecuteAsync 结束灵活度高可任意控制启动停止顺序相对固定但覆盖绝大多数场景代码量多样板代码重复少只关注业务逻辑适用场景需要精细控制生命周期或实现 IHostedLifecycleService周期任务、消息消费、长驻后台服务一句话总结如果你发现自己在一个 IHostedService 里手动写了 CancellationTokenSource、又手动保存 Task 再在 StopAsync 里等待那你其实就是在手写一个 BackgroundService。既然如此直接在 ExecuteAsync 里写业务逻辑多省事。4. 定时任务落地方案三种常用写法和各自的使用边界4.1 方案一while 循环 Task.Delay最简单也最稳这是我最推荐的入门写法逻辑直白调试也方便public class ReportWorker : BackgroundService { private readonly ILoggerReportWorker _logger; private readonly TimeSpan _interval TimeSpan.FromMinutes(30); public ReportWorker(ILoggerReportWorker logger) { _logger logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation(报表任务开始运行); while (!stoppingToken.IsCancellationRequested) { try { await RunOnceAsync(stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } catch (Exception ex) { _logger.LogError(ex, 报表任务执行出错); } try { await Task.Delay(_interval, stoppingToken); } catch (OperationCanceledException) { break; } } _logger.LogInformation(报表任务已停止); } }这种写法有一个典型的语义差异它是上一轮结束之后才开始等下一轮。如果任务一次执行需要 20 分钟配置的间隔是 30 分钟那实际周期就是 50 分钟。如果你需要的是无论执行多久每隔 30 分钟必须触发一次这种写法就不合适需要看方案二。另外务必注意 Task.Delay 要传入 stoppingToken。如果不传当宿主停止时Delay 并不会立即醒来而会继续睡满整个间隔。假如你的间隔是几个小时停机时应用就会卡在那里迟迟不退最后被宿主超时强杀。4.2 方案二PeriodicTimer追求绝对周期的选择.NET 6 开始引入 PeriodicTimer它的语义和 Task.Delay 不太一样计时从创建那一刻开始计算每个周期都会触发一次 WaitForNextTickAsync 返回适合需要稳定每次间隔 N 时间就执行的场景。public class HeartbeatWorker : BackgroundService { private readonly ILoggerHeartbeatWorker _logger; public HeartbeatWorker(ILoggerHeartbeatWorker logger) { _logger logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { using var timer new PeriodicTimer(TimeSpan.FromSeconds(30)); while (await timer.WaitForNextTickAsync(stoppingToken)) { try { _logger.LogInformation(发送心跳); await Task.Delay(100, stoppingToken); } catch (Exception ex) { _logger.LogError(ex, 心跳发送失败); } } } }使用 PeriodicTimer 时要注意它的追赶行为如果你在某个周期里执行了很久超过了定时周期那么下一次 WaitForNextTickAsync 可能立即返回造成连续执行两轮甚至更多轮的现象。也就是说它保证了触发的频率尽可能贴近配置但没有承诺执行不会重叠。执行任务比较重的场景必须配合并发控制后面会讲才能真正安全。4.3 方案三System.Threading.Timer老式定时器的适配System.Threading.Timer 是最传统的定时器现在也有人把它包进 IHostedService 里用。它的特点是回调方法跑在 ThreadPool 上签名是 void天然是用完即走的回调模型public class LegacyTimerWorker : IHostedService, IDisposable { private Timer? _timer; private readonly ILoggerLegacyTimerWorker _logger; public LegacyTimerWorker(ILoggerLegacyTimerWorker logger) { _logger logger; } public Task StartAsync(CancellationToken cancellationToken) { _timer new Timer(DoWork, null, TimeSpan.Zero, TimeSpan.FromMinutes(10)); return Task.CompletedTask; } private void DoWork(object? state) { try { _logger.LogInformation(执行周期任务); } catch (Exception ex) { _logger.LogError(ex, 任务执行失败); } } public Task StopAsync(CancellationToken cancellationToken) { _timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan); return Task.CompletedTask; } public void Dispose() { _timer?.Dispose(); } }这种写法最明显的短板是回调里的并行无法简单控制如果你的回调逻辑耗时超过了 timer 的 period上一次回调还没跑完下一次回调又开始了产生重入。StopAsync 里虽然把定时器停掉了但并没有等待正在执行的回调结束也就是停止不干净。如果你真的需要等待当前回调跑完得自己维护任务集合这又回到了上一章说的手工管理问题。因此我的建议是除非你在维护老代码否则新项目直接用 BackgroundService不要往 Timer 这条路走。4.4 注册只需要一行代码不管哪种写法注册到宿主都很简单builder.Services.AddHostedServiceReportWorker(); builder.Services.AddHostedServiceHeartbeatWorker();注册顺序决定启动顺序停止顺序则相反。如果有多个后台任务且它们之间存在先后的依赖关系注册时就要注意顺序。如果依赖关系复杂到需要明确控制可能要考虑拆成单独的依赖注入注册或者用 IHostedLifecycleService 来做前后置钩子。5. 生产环境必填的坑作用域、并发重叠和优雅停机5.1 直接在构造函数注入 DbContext是定时任务最常见的隐藏炸弹托管服务本身是单例的。因此如果你在构造函数里注入了一个 Scoped 生命周期服务比如 EF Core 的 DbContext、某个业务 Service那个服务实际上会被固定成单例级别的长期存活。很多项目里定时任务第一次执行正常跑几天后越来越慢最后报出数据库连接超时或者上下文状态异常就是这个原因。DbContext 不是线程安全的它内部有状态、有一级缓存设计上就是短生命周期对象。把它放到一个长期存活的单例里等于让它一直拿住一个数据库连接最后的崩溃几乎是必然的。正确的做法是注入 IServiceScopeFactory在每次执行任务时创建独立作用域public class NotificationWorker : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILoggerNotificationWorker _logger; public NotificationWorker( IServiceScopeFactory scopeFactory, ILoggerNotificationWorker logger) { _scopeFactory scopeFactory; _logger logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { using (var scope _scopeFactory.CreateScope()) { var db scope.ServiceProvider.GetRequiredServiceAppDbContext(); var service scope.ServiceProvider.GetRequiredServiceINotificationService(); await service.SendScheduledNotificationsAsync(db, stoppingToken); } await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken); } } }这样每个循环都拿到一个新的作用域DbContext 用完即释放既不会被长期占用也不会被上一次任务的状态污染。5.2 任务执行时间超过周期宁可跳过不要排队叠起来无论是 Timer 还是 PeriodicTimer都存在重入的可能。后台任务一重叠轻则资源占用翻倍重则出现脏数据、重复发消息。我习惯在任务入口加一个 SemaphoreSlim 做非阻塞的防重入控制private readonly SemaphoreSlim _gate new(1, 1); private async Task SafeRunAsync(CancellationToken cancellationToken) { if (!await _gate.WaitAsync(0, cancellationToken)) { _logger.LogWarning(上一轮任务尚未结束本轮跳过); return; } try { await RunOnceAsync(cancellationToken); } finally { _gate.Release(); } }这里 WaitAsync 的第一个参数 0 表示不等待拿不到锁立刻返回 false。也就是上一轮还没结束这一轮就直接放弃而不是排队等着。为什么选择跳过而不是排队因为对于周期任务一旦任务开始堆积后面每一轮都可能在排队最终形成雪崩。更合理的策略是记录一条日志让下一轮周期再去处理。如果业务真的要求每轮都要执行完那属于队列场景应该考虑专业调度框架而不是单纯用周期触发。5.3 异常必须捕获记录但取消信号要放行ExecuteAsync 里如果发生异常宿主并不会因此自动重启任务也不会把异常展示到页面上。它最终会变成一个未观察异常最多在日志里留下一行不痛不痒的记录任务从此静默死亡。所以在 ExecuteAsync 的最外层一定要包 try/catch把异常吃掉并记录。但有一个特例OperationCanceledException 是收到取消信号后的正常退出不是错误。不要在 catch 里把它误报成异常更不要吞掉后继续循环。上一章代码里的写法就体现了这个区分catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)只有在确实取消了时才走退出分支。如果任务执行中有多个子步骤建议给每个子步骤都做必要的捕获避免一个步骤的失败让整个循环退出。当然前提是那些失败本来就可以容忍。需要重试的逻辑可以自己封装一个简单的重试循环或者引入 Polly 这样的重试库。5.4 优雅停机不是靠运气是要让 ExecuteAsync 真正响应停止信号很多人以为把任务写在 BackgroundService 里就自动优雅停机了其实不一定。如果 ExecuteAsync 内部有一个长时间运行的操作完全不理会 stoppingTokenStopAsync 就只能干等。一个典型的反面例子是任务里调用了第三方接口对方响应很慢单次请求超时设了 10 分钟。停机时宿主已经摇了铃但你的代码还卡在 HTTP 请求里无法及时退出。这时的正确做法是所有 IO 操作都传递同一个 CancellationToken让 HTTP 请求、数据库操作、消息队列读取都能立刻取消。你传入的 stoppingToken 就是那个用于协作的信号源。还可以在 Host 层面配置停机等待时间上限builder.Services.ConfigureHostOptions(options { options.ShutdownTimeout TimeSpan.FromSeconds(60); });如果 60 秒内所有托管服务没有停完宿主就会强制退出。这个配置给了你一个兜底但真正的优雅是要靠代码里的取消响应来保证的配置只是在资源耗尽前给一个最终裁判。5.5 多实例部署时注意任务重复执行后台任务在一台服务器上跑得好好的改成多副本部署后定时任务可能在每个节点都执行一次。如果你的业务对重复执行不可容忍比如发送邮件、扣减库存、生成对账单原生 BackgroundService 并不会帮你做任何分布式互斥。对于这类场景常规做法是引入分布式锁。简单点用数据库行锁复杂点上 Redis 分布式锁。核心逻辑都是在任务执行前尝试获取锁拿到锁的节点才执行其他节点直接跳过本轮if (!await lockProvider.TryAcquireAsync(report-lock, cancellationToken)) { _logger.LogInformation(其他节点正在执行报表任务本节点跳过); return; } try { await RunOnceAsync(cancellationToken); } finally { await lockProvider.ReleaseAsync(report-lock); }原生方案本身不提供分布式能力这一点在架构设计时就要想清楚。如果对多节点唯一执行、失败重试、任务持久化都有强需求那么下面要聊的第三方调度框架更合适。6. 原生方案和第三方框架的边界什么时候换 Hangfire 或 Quartz6.1 先给原生方案一个清晰的适用范围我见过不少项目任务就两三个逻辑也不复杂却引入了完整的调度框架配置、存储、后台管理页面折腾了一圈最后的用途只是每天发一封邮件。这种高射炮打蚊子的复杂度完全没必要。原生 BackgroundService 真正擅长的是任务数量少、周期固定、不需要持久化、失败可以容忍、单进程内执行、团队规模小。只要这些条件满足原生方案就是最高性价比的选择——没有额外依赖没有数据库表没有学习成本排错也容易。6.2 出现这三个信号就应该换框架了第一个信号需要复杂的触发表达式。比如每个工作日的上午 9 点到 11 点之间每 15 分钟执行一次、每月最后一个周五执行。用原生方案手写这种日历逻辑会非常痛苦而 Quartz 的 Cron 表达式天然就是为了解决这个问题。第二个信号需要任务持久化和历史记录。原生方案的任务只活在内存里进程重启任务就重新计时你也不知道上一次执行到底成没成功、耗时多少、失败在哪一步。Hangfire 会把任务定义和执行状态写入数据库自带 Dashboard 可以看执行历史、失败原因、重试次数。第三个信号需要跨节点协调或并行调度。前面说的分布式锁只是最粗糙的互斥手段Quartz 本身支持集群模式多个节点可以协调调度保证同一个 job 在一个时间点只有一个节点执行还支持负载均衡。Hangfire 基于共享存储也能达到类似效果。整理成选型表会更直观维度原生 BackgroundServiceQuartz.NETHangfire触发方式手动 Delay/PeriodicTimerCron 表达式强大周期性/延迟/Cron需扩展任务持久化无支持支持管理界面无无官方界面自带 Dashboard集群协调需自己实现锁原生支持集群基于共享存储协调失败重试自己写可配置自带重试机制学习成本低中高中适用场景简单固定周期任务复杂调度、集群任务后台任务多、需要可视化做个生活化类比原生方案就像家里的机械闹钟定好时间就响简单可靠但它不会因为你没听到而重新响一次也不记录历史。Quartz 和 Hangfire 更像专业的日程管理系统能按规则生成日程、提醒你、记录你处理了没有。机械闹钟能解决的问题你没必要上日程管理系统但事情多了机械闹钟的极限就摆在那里。最后分享一个我自己的实际体会无论用哪种方案定时任务都应该是可观测的。原生方案里任务每轮执行至少留一条结构化日志记录开始时间、结束时间、执行结果、下一次执行时间。很多后台任务的故障初期都只是日志里一条不起眼的异常而定时任务又不像接口那样有人调用、报错会立刻被发现。你自己不盯出了问题通常都是几天后用户来投诉才知道。先保证能看见再谈用什么工具去调度这是所有后台任务方案里我排在第一优先级的一条经验。