简介:这是一份面向C#开发者的自定义定时任务系统源码资源,针对需要按预定间隔执行操作、且要求多任务并行的业务场景,提供了一套可运行、可扩展的调度器实现。资源包共41个文件,以12个cs源码文件为核心,配合3个csproj工程文件、1个sln解决方案,以及6个dll、5个pdb、2个exe等编译与运行产物,另有txt说明、xml配置、resx资源等辅助文件,压缩包约22KB,结构紧凑便于直接导入Visual Studio调试。源码围绕TimingTaskScheduler与TimingTask展开,涵盖任务注册、执行、多任务管理、取消与状态跟踪等关键环节,并涉及线程安全、异常处理、任务优先级与日志记录等工程化考量,还包含Windows服务安装与配置相关模块。目前已有3943人学习下载,适合希望深入理解C#定时调度机制、需要快速搭建多任务定时框架的中级开发者参考借鉴。
1. 从一次线上事故说起:为什么我又回头翻出了这份 C# 定时任务源码
去年帮一个做数据采集的朋友排查问题,他们系统里散落着七八个System.Timers.Timer,每个都直接new在业务代码里,结果某天凌晨一个采集任务卡死,连带把整个进程的线程池拖垮,第二天早上才发现数据断了六个小时。这种场景太常见了——单文件里塞几个定时器谁都会写,但一旦任务数量上去、执行周期不同、还要保证互不干扰,裸写 Timer 就开始各种玄学翻车。这份 C# 实现的自定义定时任务源码,核心就是解决「多任务并行调度」这件事:它把每个任务封装成独立调度单元,支持各自设定间隔、各自启停,互不阻塞。适合手里有多个周期性作业、又不想引入 Quartz.NET 这种重框架的 .NET 开发者,尤其是中小型服务端和桌面工具场景。下面我按自己拆包复现的顺序,把这份资源从结构到跑通再到踩坑讲一遍。
2. 拆开源码看调度骨架:任务单元、调度器与线程模型
拿到一份定时任务源码,我第一件事不是急着运行,而是先搞清楚它的调度骨架长什么样。因为定时任务这东西,表面看都是「隔一段时间跑一次」,但底层用的是什么计时机制、任务之间怎么隔离、异常怎么处理,直接决定了它能不能扛住真实场景。这份 C# 自定义定时任务的结构不算复杂,但几个关键设计点值得先讲清楚,否则后面配参数就是瞎调。
2.1 任务单元是怎么封装的
常见做法是把每个定时任务抽象成一个类,内部持有执行间隔、回调委托、启停状态这几个核心字段。这份源码走的就是这条路——每个任务是一个独立对象,而不是把逻辑散在 Timer 的 Elapsed 事件里。这样做的好处很直接:任务之间互不感知,一个任务改间隔不影响另一个,启停也是各管各的。
我用伪代码还原一下它的核心结构,方便你对照自己手里的版本:
// 任务单元:每个实例代表一个独立的定时作业 public class ScheduledTask { // 执行间隔,单位毫秒,决定这个任务多久触发一次 public int Interval { get; set; } // 任务名称,用于日志区分和排查,多任务场景下必须能对上号 public string Name { get; set; } // 实际要执行的业务逻辑,由调用方注入 public Action Work { get; set; } // 运行状态标记,控制调度循环是否继续 public bool IsRunning { get; set; } }这里几个参数的含义要拎清楚。Interval是间隔而非固定时刻,也就是说它属于「上次执行完再等 N 毫秒」还是「每隔 N 毫秒触发一次」,这两种语义在多任务场景下差别很大——前者不会堆积,后者在任务执行时间超过间隔时会排队。Name看着不起眼,但多任务并行时,日志里没有名字你根本分不清是哪个任务出的问题,这是我踩过的坑。Work用委托注入,意味着调度器和业务逻辑解耦,你换业务不用动调度代码。
2.2 调度器如何驱动多个任务
单任务用 Timer 就够了,多任务的关键在于调度器怎么统一管理这些任务单元。这份源码的思路是:调度器持有一个任务集合,为每个任务分配独立的执行上下文,避免它们互相抢占。
// 调度器:统一管理多个任务的生命周期 public class TaskScheduler { // 任务集合,支持动态增删 private List<ScheduledTask> _tasks = new List<ScheduledTask>(); // 注册一个任务,注册后并不立即启动 public void Register(ScheduledTask task) { _tasks.Add(task); } // 启动所有已注册任务 public void StartAll() { foreach (var task in _tasks) { task.IsRunning = true; // 每个任务独立起调度循环,互不阻塞 RunTaskLoop(task); } } // 单个任务的调度循环 private void RunTaskLoop(ScheduledTask task) { // 具体实现通常用 Task.Run 或独立线程承载 // 循环内判断 IsRunning,执行 Work,再按 Interval 等待 } }逻辑上分三层:注册、启动、循环执行。注册阶段只把任务放进集合,不触发任何计时;启动阶段遍历集合,给每个任务拉起独立的调度循环;循环内部靠IsRunning标记控制退出。这种设计的价值在于,任务数量增加时,每个任务有自己的循环,不会因为某个任务执行慢而卡住其他任务。
参数层面要注意Interval的单位和精度。C# 里Thread.Sleep的精度受系统时钟影响,实际间隔会有十几毫秒的偏差,如果你的任务对时间敏感,得换用更高精度的等待方式。另外RunTaskLoop里如果用Task.Run承载,要注意它跑在线程池上,任务数量一多会争抢线程池资源,这时候要么限制并发数,要么给关键任务单独开线程。
2.3 多任务并行的线程模型选择
多任务定时器最容易翻车的地方就是线程模型。我见过不少人图省事,所有任务共用一个 Timer,Elapsed 事件里挨个调用——结果第一个任务执行时间一长,后面的任务全部延迟,这就是典型的「一个拖垮一窝」。
这份源码给每个任务独立调度循环,本质上是把隔离性做在了任务级别。但独立循环也有代价:任务多了线程或 Task 数量就上去了。常见做法是给每个任务用Task.Run加await Task.Delay的组合,这样等待期间不占线程,适合 I/O 密集型任务;如果是 CPU 密集型任务,则要考虑用独立线程并限制总数。
// 用 Task.Delay 实现非阻塞等待,等待期间不占用线程 private async Task RunTaskLoopAsync(ScheduledTask task) { while (task.IsRunning) { try { task.Work?.Invoke(); // 执行任务逻辑 } catch (Exception ex) { // 单个任务异常必须吞掉,否则会终止整个循环 Console.WriteLine($"[{task.Name}] 执行异常: {ex.Message}"); } await Task.Delay(task.Interval); // 非阻塞等待,释放线程 } }这段代码里有两个关键点。第一,try-catch必须包住Work调用——如果任务逻辑抛异常不处理,循环直接退出,这个任务就再也不跑了,而且悄无声息,这是最阴的坑。第二,Task.Delay替代Thread.Sleep,等待期间线程归还线程池,多任务场景下资源利用率高得多。参数上,Interval设太小(比如小于 100 毫秒)会导致任务频繁触发,CPU 占用飙升;设太大又要考虑任务实际执行时间,避免出现「还没跑完下一轮又来了」的堆积。
3. 把源码跑起来:从建工程到多任务并行的完整步骤
骨架看明白之后,就该动手把它跑起来了。这一章我按实际复现顺序走一遍,从建工程、引入源码、配置任务到验证多任务并行,每一步都给出可抄的代码和参数说明。你照着走一遍,基本就能判断这份资源合不合你的场景。
3.1 建工程与引入源码文件
先建一个控制台工程做验证,这是最省事的起步方式,跑通之后再往服务或桌面项目里搬。
# 建一个 .NET 控制台工程,名字随意 dotnet new console -n TimerTaskDemo cd TimerTaskDemo # 把源码里的任务单元和调度器文件拷进项目目录 # 通常是 ScheduledTask.cs 和 TaskScheduler.cs 两个文件工程建好后,把源码里的核心类文件放进项目根目录,确保命名空间对得上。如果源码用的是老版本 .NET Framework 语法,迁到 .NET 6/7/8 时可能要改几处:比如Thread相关 API 的调用方式、Task的用法差异。我一般先编译一遍,让编译器把不兼容的地方全报出来,再逐个改,比肉眼找快得多。
提示:如果你的项目目标框架和源码不一致,先别急着改代码,优先调整项目的 TargetFramework,很多时候改框架比改代码省事。
3.2 注册并启动多个任务
工程能编译之后,写一个入口把多个任务注册进去,这是验证多任务并行的核心步骤。
// Program.cs 入口 var scheduler = new TaskScheduler(); // 任务一:每 2 秒执行一次,模拟数据采集 scheduler.Register(new ScheduledTask { Name = "采集任务", Interval = 2000, Work = () => Console.WriteLine($"[采集] {DateTime.Now:HH:mm:ss} 拉取数据") }); // 任务二:每 5 秒执行一次,模拟状态上报 scheduler.Register(new ScheduledTask { Name = "上报任务", Interval = 5000, Work = () => Console.WriteLine($"[上报] {DateTime.Now:HH:mm:ss} 上报状态") }); // 任务三:每 1 秒执行一次,模拟心跳检测 scheduler.Register(new ScheduledTask { Name = "心跳任务", Interval = 1000, Work = () => Console.WriteLine($"[心跳] {DateTime.Now:HH:mm:ss} 检测连接") }); scheduler.StartAll(); // 启动全部任务 Console.ReadLine(); // 阻塞主线程,防止程序退出这段代码注册了三个间隔不同的任务,分别是 2 秒、5 秒、1 秒。运行后你会看到三个任务的输出交错出现,各自按自己的节奏走,互不干扰——这就是多任务并行的直观验证。参数上,Interval的单位是毫秒,2000 就是 2 秒。Name一定要填,日志里靠它区分任务。Work里放你的实际业务逻辑,验证阶段先用Console.WriteLine占位。
如果运行后发现某个任务没输出,先检查IsRunning是否被正确置为 true,再看调度循环有没有真的跑起来。常见问题是StartAll里用了同步阻塞的写法,导致第一个任务的循环把主线程占死,后面的任务根本没机会启动。
3.3 验证任务隔离与异常处理
多任务调度最该验证的不是「能不能跑」,而是「一个任务出问题会不会影响其他任务」。这一步我建议故意制造异常来测。
// 故意让采集任务抛异常,观察其他任务是否受影响 scheduler.Register(new ScheduledTask { Name = "异常任务", Interval = 3000, Work = () => { throw new InvalidOperationException("模拟任务内部异常"); } });跑起来之后,如果异常任务每次触发都抛异常,但心跳任务和上报任务照常输出,说明异常隔离做到位了。反过来,如果整个程序崩了或者其他任务停了,那就是try-catch没包对位置。这个测试很关键,因为真实环境里任务抛异常是常态——网络超时、文件被占用、数据格式不对,任何一个都可能让任务逻辑炸掉。
参数层面还要注意一点:如果任务执行时间可能超过Interval,要决定是「跳过本轮」还是「排队等待」。这份源码默认是执行完再等Interval,也就是不会堆积,但如果你的业务要求严格按固定频率触发,就得改成基于时间戳的调度,而不是基于间隔的等待。
3.4 动态增删任务的实现方式
真实场景里任务不是一成不变的,经常需要运行时加一个或停一个。这份源码的任务集合是List,支持动态增删,但要注意线程安全——如果调度循环正在遍历集合,你同时增删,会抛集合修改异常。
// 动态添加任务,注意加锁保护集合 lock (_tasks) { _tasks.Add(new ScheduledTask { Name = "临时任务", Interval = 4000, Work = () => Console.WriteLine("临时任务执行") }); }常见做法是给集合操作加锁,或者换成ConcurrentBag这类线程安全集合。参数上,动态添加的任务要记得单独启动它的调度循环,光加进集合不启动是不会跑的。删除任务时,除了从集合移除,还要把它的IsRunning置为 false,让循环自然退出,否则会留下一个跑着的孤儿任务。
4. 避坑与排查:多任务定时器最容易翻车的五个地方
这份源码本身不复杂,但定时任务这个东西,坑往往不在代码写没写对,而在运行时行为和你预期不一致。下面五条是我自己在多任务场景里反复踩过的,每条按现象、原因、解决来说。
4.1 任务执行时间超过间隔导致堆积
现象:某个任务日志越打越密,间隔看起来越来越短,CPU 占用持续升高。
原因:任务实际执行时间超过了Interval,而调度循环是「执行完再等 Interval」,如果执行本身就很慢,等待时间被压缩,实际触发频率反而变高。更糟的是如果用了固定频率触发,任务会排队堆积。
解决:先测出任务的平均执行时间,把Interval设成执行时间的两倍以上留余量。如果业务要求严格频率,改用基于时间戳的调度,每次触发前检查距上次执行是否已超过间隔,超了才跑,没超就跳过。
4.2 异常未捕获导致任务静默停止
现象:程序没崩,但某个任务跑着跑着就不输出了,也没有任何报错。
原因:任务逻辑抛了异常,而调度循环里没有try-catch,异常直接把循环打断,任务从此不再触发。这种问题最阴,因为表面看程序还活着。
解决:确保Work调用被try-catch包住,异常至少记日志。更好的做法是记录异常后继续循环,而不是退出。我一般会在 catch 里加上任务名和异常堆栈,方便定位是哪个任务出的问题。
4.3 线程池争抢导致任务延迟
现象:任务少的时候正常,任务一多就出现明显延迟,所有任务都变慢。
原因:每个任务用Task.Run承载,任务数量超过线程池最小线程数后,新任务要排队等线程,等待时间不可控。
解决:I/O 密集型任务用async/await加Task.Delay,等待期间不占线程;CPU 密集型任务限制并发总数,或者给关键任务单独开线程。参数上可以调线程池最小线程数,但治标不治本,根本还是控制任务数量。
4.4 动态增删任务时的集合修改异常
现象:运行中动态添加或删除任务,程序抛InvalidOperationException,提示集合已修改。
原因:调度循环正在遍历任务集合,同时另一个线程在增删集合,List不允许遍历时修改。
解决:给集合操作加锁,或者用线程安全集合。删除任务时先置IsRunning为 false,等循环退出后再从集合移除,避免遍历到一半集合变了。
4.5 间隔精度偏差累积
现象:任务运行时间越长,触发时刻和预期偏差越大。
原因:Thread.Sleep和Task.Delay的精度受系统时钟影响,每次等待都有十几毫秒偏差,长时间累积就明显了。
解决:对时间敏感的任务,记录上次执行的时间戳,每次循环根据时间戳计算还需要等多久,而不是固定等Interval。这样偏差不会累积,每次都会向目标时刻对齐。
5. 进阶用法:把间隔调度改成时间点调度
跑通基础版本之后,很多人会碰到一个新需求:不是「每隔 N 秒跑一次」,而是「每天凌晨两点跑一次」或者「每周一上午九点跑一次」。间隔调度做不了这个,得换成基于时间点的调度。这份源码的骨架可以直接改,核心是把「等待固定间隔」换成「计算到下一个目标时刻的差值」。
// 计算距离下一个目标时刻还有多少毫秒 private int GetDelayToNextRun(TimeSpan targetTime) { var now = DateTime.Now; // 构造今天的目标时刻 var next = now.Date.Add(targetTime); // 如果今天的目标时刻已过,顺延到明天 if (next <= now) { next = next.AddDays(1); } return (int)(next - now).TotalMilliseconds; }逻辑很直白:先算出今天的目标时刻,如果已经过了就加一天,然后返回当前到目标时刻的毫秒差。把这个差值作为Task.Delay的参数,就能实现「每天固定时刻触发」。参数上,targetTime是TimeSpan类型,比如new TimeSpan(2, 0, 0)就是凌晨两点。
如果要支持「每周一上午九点」,就在计算时加上星期判断,找到下一个周一再算差值。这套改法的好处是不依赖任何外部库,坏处是时区、夏令时这些边界要自己处理——如果你的场景跨时区,建议还是上成熟框架,别自己造。
验证时间点调度是否准确,我的习惯是先把目标时刻设成当前时间往后一两分钟,跑一次看是否准时触发,再改成真实时刻。另外要注意,如果程序在目标时刻之前重启了,调度会重新计算,不会丢任务,但如果重启时正好跨过目标时刻,那一轮就错过了,这个边界要心里有数。
从那以后我每次接定时任务的需求,都强制先问清楚三件事:任务执行时间多长、异常了怎么办、要不要固定时刻。这三问答完,选间隔调度还是时间点调度、要不要上框架,基本就定了。希望这份拆解帮到你。
本文还有配套的精品资源,点击获取