简介这是一份面向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 ListScheduledTask _tasks new ListScheduledTask(); // 注册一个任务注册后并不立即启动 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 多任务并行的线程模型选择多任务定时器最容易翻车的地方就是线程模型。我见过不少人图省事所有任务共用一个 TimerElapsed 事件里挨个调用——结果第一个任务执行时间一长后面的任务全部延迟这就是典型的「一个拖垮一窝」。这份源码给每个任务独立调度循环本质上是把隔离性做在了任务级别。但独立循环也有代价任务多了线程或 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)就是凌晨两点。如果要支持「每周一上午九点」就在计算时加上星期判断找到下一个周一再算差值。这套改法的好处是不依赖任何外部库坏处是时区、夏令时这些边界要自己处理——如果你的场景跨时区建议还是上成熟框架别自己造。验证时间点调度是否准确我的习惯是先把目标时刻设成当前时间往后一两分钟跑一次看是否准时触发再改成真实时刻。另外要注意如果程序在目标时刻之前重启了调度会重新计算不会丢任务但如果重启时正好跨过目标时刻那一轮就错过了这个边界要心里有数。从那以后我每次接定时任务的需求都强制先问清楚三件事任务执行时间多长、异常了怎么办、要不要固定时刻。这三问答完选间隔调度还是时间点调度、要不要上框架基本就定了。希望这份拆解帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?