System.Threading.Timer最适合作为后台长任务的轻量轮询方案,因其单线程回调避免线程池耗尽,而System.Timers.Timer适合秒/分钟级简单任务但需防并发;Quartz.NET适用于复杂调度需求但配置复杂。

用 System.Timers.Timer 做简单轮询,但别指望它精准
它适合每秒/每分钟级的轻量任务,比如心跳上报、缓存刷新。底层靠线程池回调,不保证准时——如果 Elapsed 事件处理太慢,下一次触发会被延迟甚至丢弃。
- 必须手动调用
Start(),构造完默认是停着的 -
AutoReset设为false才能只执行一次;设为true(默认)才循环,但要注意避免重复触发:多个Elapsed可能并发执行,得自己加锁或用Interlocked - 别在回调里直接
Stop()+Start()重置间隔,会引发竞态;改用Change()方法(System.Threading.Timer才有,System.Timers.Timer没这接口) - 应用退出前记得调用
Dispose(),否则可能阻止进程结束
用 Quartz.NET 做生产级调度,但别一上来就配集群
它解决的是“每月最后一个周五上午9点发邮件”这类复杂表达式,也支持持久化、故障恢复和分布式协调。但本地开发时,RAMJobStore 就够用,硬上 AdoJobStore 反而多出数据库连接、表初始化、连接泄漏等坑。
- 触发器表达式写错常见于少写空格,比如
"0 0 9 ? * FRI"合法,"0 0 9?*FRI"直接抛ParseException - Job 类必须有无参构造函数,且不能是内部类(反射实例化失败)
- 如果用
async方法,别直接await在Execute()里——Quartz 默认同步调用,要封装成Task.Run(() => { ... })或改用IJobFactory注入异步上下文 - 日志里出现
"Trigger 'xxx' is set to fire now, but scheduler is in standby mode"?说明调用了Standby()没Start()
为什么 System.Threading.Timer 比 System.Timers.Timer 更适合后台长任务
前者回调只用一个线程(可指定 ThreadPool 或 Null),后者每次 Elapsed 都可能从线程池拿新线程,容易打满池子。尤其当任务偶尔卡住,前者最多阻塞下一次,后者可能堆积一堆等待线程。
- 创建时传
TimeSpan.FromMilliseconds(1000),不是整数1000(后者是 ticks) - 回调函数签名是
void Callback(object state),state 是你传进去的任意对象,别依赖闭包捕获外部变量——可能被 GC 提前回收 - 取消用
Dispose(),不是Change(Timeout.Infinite, Timeout.Infinite)(后者只是停掉,资源还在) - 如果任务执行时间 > 间隔,下一次回调会立刻触发(不会跳过),所以逻辑里得判断是否“还在跑”,常用
Interlocked.CompareExchange(ref _isRunning, 1, 0) == 0
部署到 Windows 服务或 Linux systemd 时,Timer 容易静默失效
控制台程序关了 Timer 就停;Windows 服务没正确处理 OnStop(),或 Linux 上没设 Restart=always,都会让定时任务中途断掉。
- Windows 服务中,
OnStart()里启动 Timer 后,必须确保服务状态设为ServiceControllerStatus.Running,否则 SCM 认为你启动失败 - Linux systemd 里,
Type=simple要求主进程一直活着;如果用dotnet run启动,得加ExecStart=/usr/bin/dotnet /path/to/app.dll并配RestartSec=10 - 所有 Timer 都该加全局异常捕获:
AppDomain.CurrentDomain.UnhandledException或TaskScheduler.UnobservedTaskException,否则未处理异常会让 Timer 彻底停摆
实际用哪套,取决于你有没有“错过一次就得补跑”“跨机器必须只执行一次”“要动态增删任务”这些需求。没这些,System.Threading.Timer 加个配置文件读取间隔,就是最稳的解法。 Quartz 的配置项和生命周期钩子太多,一个 IScheduler.Start() 忘了调,任务就永远睡着了。


















