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 的一个默认实现,它没有增加任何宿主层面的新能力,只是改变了你写代码的方式。
我把关键差异整理成一张表,方便你对比:
| 对比维度 | IHostedService | BackgroundService |
|---|---|---|
| 本质 | 接口,完全自定义 | 抽象类,实现了 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 ILogger<ReportWorker> _logger; private readonly TimeSpan _interval = TimeSpan.FromMinutes(30); public ReportWorker(ILogger<ReportWorker> 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 ILogger<HeartbeatWorker> _logger; public HeartbeatWorker(ILogger<HeartbeatWorker> 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 ILogger<LegacyTimerWorker> _logger; public LegacyTimerWorker(ILogger<LegacyTimerWorker> 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.AddHostedService<ReportWorker>(); builder.Services.AddHostedService<HeartbeatWorker>();注册顺序决定启动顺序,停止顺序则相反。如果有多个后台任务,且它们之间存在先后的依赖关系,注册时就要注意顺序。如果依赖关系复杂到需要明确控制,可能要考虑拆成单独的依赖注入注册,或者用 IHostedLifecycleService 来做前后置钩子。
5. 生产环境必填的坑:作用域、并发重叠和优雅停机
5.1 直接在构造函数注入 DbContext,是定时任务最常见的隐藏炸弹
托管服务本身是单例的。因此,如果你在构造函数里注入了一个 Scoped 生命周期服务(比如 EF Core 的 DbContext、某个业务 Service),那个服务实际上会被固定成"单例级别的长期存活"。
很多项目里定时任务第一次执行正常,跑几天后越来越慢,最后报出数据库连接超时或者上下文状态异常,就是这个原因。DbContext 不是线程安全的,它内部有状态、有一级缓存,设计上就是短生命周期对象。把它放到一个长期存活的单例里,等于让它一直拿住一个数据库连接,最后的崩溃几乎是必然的。
正确的做法是注入 IServiceScopeFactory,在每次执行任务时创建独立作用域:
public class NotificationWorker : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger<NotificationWorker> _logger; public NotificationWorker( IServiceScopeFactory scopeFactory, ILogger<NotificationWorker> logger) { _scopeFactory = scopeFactory; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { using (var scope = _scopeFactory.CreateScope()) { var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var service = scope.ServiceProvider.GetRequiredService<INotificationService>(); 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 内部有一个长时间运行的操作完全不理会 stoppingToken,StopAsync 就只能干等。
一个典型的反面例子是任务里调用了第三方接口,对方响应很慢,单次请求超时设了 10 分钟。停机时,宿主已经摇了铃,但你的代码还卡在 HTTP 请求里,无法及时退出。这时的正确做法是:所有 IO 操作都传递同一个 CancellationToken,让 HTTP 请求、数据库操作、消息队列读取都能立刻取消。你传入的 stoppingToken 就是那个用于协作的信号源。
还可以在 Host 层面配置停机等待时间上限:
builder.Services.Configure<HostOptions>(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 或 Quartz
6.1 先给原生方案一个清晰的适用范围
我见过不少项目,任务就两三个,逻辑也不复杂,却引入了完整的调度框架,配置、存储、后台管理页面折腾了一圈,最后的用途只是每天发一封邮件。这种"高射炮打蚊子"的复杂度完全没必要。
原生 BackgroundService 真正擅长的是:任务数量少、周期固定、不需要持久化、失败可以容忍、单进程内执行、团队规模小。只要这些条件满足,原生方案就是最高性价比的选择——没有额外依赖,没有数据库表,没有学习成本,排错也容易。
6.2 出现这三个信号,就应该换框架了
第一个信号:需要复杂的触发表达式。比如"每个工作日的上午 9 点到 11 点之间每 15 分钟执行一次"、"每月最后一个周五执行"。用原生方案手写这种日历逻辑会非常痛苦,而 Quartz 的 Cron 表达式天然就是为了解决这个问题。
第二个信号:需要任务持久化和历史记录。原生方案的任务只活在内存里,进程重启任务就重新计时,你也不知道上一次执行到底成没成功、耗时多少、失败在哪一步。Hangfire 会把任务定义和执行状态写入数据库,自带 Dashboard 可以看执行历史、失败原因、重试次数。
第三个信号:需要跨节点协调或并行调度。前面说的分布式锁只是最粗糙的互斥手段,Quartz 本身支持集群模式,多个节点可以协调调度,保证同一个 job 在一个时间点只有一个节点执行,还支持负载均衡。Hangfire 基于共享存储也能达到类似效果。
整理成选型表,会更直观:
| 维度 | 原生 BackgroundService | Quartz.NET | Hangfire |
|---|---|---|---|
| 触发方式 | 手动 Delay/PeriodicTimer | Cron 表达式,强大 | 周期性/延迟/Cron(需扩展) |
| 任务持久化 | 无 | 支持 | 支持 |
| 管理界面 | 无 | 无官方界面 | 自带 Dashboard |
| 集群协调 | 需自己实现锁 | 原生支持集群 | 基于共享存储协调 |
| 失败重试 | 自己写 | 可配置 | 自带重试机制 |
| 学习成本 | 低 | 中高 | 中 |
| 适用场景 | 简单固定周期任务 | 复杂调度、集群任务 | 后台任务多、需要可视化 |
做个生活化类比:原生方案就像家里的机械闹钟,定好时间就响,简单可靠,但它不会因为你没听到而重新响一次,也不记录历史。Quartz 和 Hangfire 更像专业的日程管理系统,能按规则生成日程、提醒你、记录你处理了没有。机械闹钟能解决的问题,你没必要上日程管理系统;但事情多了,机械闹钟的极限就摆在那里。
最后分享一个我自己的实际体会:无论用哪种方案,定时任务都应该是"可观测的"。原生方案里,任务每轮执行至少留一条结构化日志,记录开始时间、结束时间、执行结果、下一次执行时间。很多后台任务的故障,初期都只是日志里一条不起眼的异常,而定时任务又不像接口那样有人调用、报错会立刻被发现。你自己不盯,出了问题通常都是几天后用户来投诉才知道。先保证能看见,再谈用什么工具去调度,这是所有后台任务方案里,我排在第一优先级的一条经验。