说一下.NET 纯原生实现 Cron 定时任务执行,未依赖第三方组件 (Timer 优化版)

在上个月写过一篇 .NET 纯原生实现 Cron 定时任务执行,未依赖第三方组件 的文章,当时 CronSchedule 的实现是使用了,每个服务都独立进入到一个 while 循环中,进行定期扫描是否到了执行时间来实现的,但是那个逻辑有些问题,经过各位朋友的测试,发现当多个任务的时候存在一定概率不按照计划执行的情况。

感谢各位朋友的积极探讨,多交流一起进步。之前那个 while 循环的逻辑每循环一次 Task.Delay 1000 毫秒,无限循环,多个任务的时候还会同时有多个循环任务,确实不够好。

所以决定重构 CronSchedule 的实现,采用全局使用一个 Timer 的形式,每隔 1秒钟扫描一次任务队列看看是否有需要执行的任务,整体的实现思路还是之前的,如果没有看过之前那篇文章的建议先看一下,本片主要针对调整部分进行说明  .NET 纯原生实现 Cron 定时任务执行,未依赖第三方组件 ,主要调整了 CronSchedule.cs

using Common;
using System.Reflection;

namespace TaskService.Libraries
{
    public class CronSchedule
    {
        private static List<ScheduleInfo> scheduleList = new();
        private static Timer mainTimer;

        public static void Builder(object context)
        {
            var taskList = context.GetType().GetMethods().Where(t => t.GetCustomAttributes(typeof(CronScheduleAttribute), false).Length > 0).ToList();

            foreach (var action in taskList)
            {
                string cron = action.CustomAttributes.Where(t => t.AttributeType == typeof(CronScheduleAttribute)).FirstOrDefault()!.NamedArguments.Where(t => t.MemberName == "Cron" && t.TypedValue.Value != null).Select(t => t.TypedValue.Value!.ToString()).FirstOrDefault()!;

                scheduleList.Add(new ScheduleInfo
                {
                    CronExpression = cron,
                    Action = action,
                    Context = context
                });
            }

            if (mainTimer == default)
            {
                mainTimer = new(Run, null, 0, 1000);
            }
        }


        private static void Run(object? state)
        {
            var nowTime = DateTime.Parse(DateTimeOffset.UtcNow.ToString("yyyy-MM-dd HH:mm:ss"));

            foreach (var item in scheduleList)
            {
                if (item.LastTime != null)
                {
                    var nextTime = DateTime.Parse(CronHelper.GetNextOccurrence(item.CronExpression, item.LastTime.Value).ToString("yyyy-MM-dd HH:mm:ss"));

                    if (nextTime == nowTime)
                    {
                        item.LastTime = DateTimeOffset.Now;

                        _ = Task.Run(() =>
                        {
                            item.Action.Invoke(item.Context, null);
                        });
                    }
                }
                else
                {
                    item.LastTime = DateTimeOffset.Now.AddSeconds(5);
                }
            }
        }


        private class ScheduleInfo
        {
            public string CronExpression { get; set; }

            public MethodInfo Action { get; set; }

            public object Context { get; set; }

            public DateTimeOffset? LastTime { get; set; }
        }
    }

    [AttributeUsage(AttributeTargets.Method)]
    public class CronScheduleAttribute : Attribute
    {
        public string Cron { get; set; }
    }

}

这里的逻辑改为了注入任务时将 mainTimer 实例化启动,每一秒钟执行1次 Run方法,Run 方法内部用于 循环检测 scheduleList 中的任务,如果时间符合,则启动一个 Task 去执行对应的 Action,这样全局不管注册多少个服务,也只有一个 Timer 在循环运行,相对之前的 CronSchedule 实现相对更好一点。

使用的时候方法基本没怎么改,只是调整了CronSchedule.Builder 的调用 代码如下:

using DistributedLock;
using Repository.Database;
using TaskService.Libraries;

namespace TaskService.Tasks
{
    public class DemoTask : BackgroundService
    {

        private readonly IServiceProvider serviceProvider;
        private readonly ILogger logger;



        public DemoTask(IServiceProvider serviceProvider, ILogger<DemoTask> logger)
        {
            this.serviceProvider = serviceProvider;
            this.logger = logger;
        }


        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            CronSchedule.Builder(this);

            await Task.Delay(-1, stoppingToken);
        }



        [CronSchedule(Cron = "0/1 * * * * ?")]
        public void ClearLog()
        {
            try
            {
                using var scope = serviceProvider.CreateScope();
                var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();

                //省略业务代码
                Console.WriteLine("ClearLog:" + DateTime.Now);
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "DemoTask.ClearLog");
            }
        }



        [CronSchedule(Cron = "0/5 * * * * ?")]
        public void ClearCache()
        {
            try
            {
                using var scope = serviceProvider.CreateScope();
                var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
                var distLock = scope.ServiceProvider.GetRequiredService<IDistributedLock>();

                //省略业务代码
                Console.WriteLine("ClearCache:" + DateTime.Now);
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "DemoTask.ClearCache");
            }
        }

    }
}

然后启动我们的项目就可以看到如下的运行效果:

最上面连着两个 16:25:53 并不是重复调用了,只是因为这个任务配置的是 1秒钟执行1次,第一次启动任务的时候执行的较为耗时,导致第一次执行和第二次执行进入到方法中的时间差太短了,这个只在第一次产生,对后续的执行计划没有影响。

至此 .NET 纯原生实现 Cron 定时任务执行,未依赖第三方组件 (Timer 优化版) 就讲解完了,有任何不明白的,可以在文章下面评论或者私信我,欢迎大家积极的讨论交流,有兴趣的朋友可以关注我目前在维护的一个 .NET 基础框架项目,项目地址如下

https://github.com/berkerdong/NetEngine.git

https://gitee.com/berkerdong/NetEngine.git




看了大家的讨论之后我又对 CronSchedule.cs 做了如下三点调整:

  1. 添加了一个 HashTable 用于记录每个任务已经执行过的时间点,每次执行之前尝试给 HashTable 插入一个执行记录,如果插入成功没有触发异常,则认为没有执行过可以触发任务,这里主要是利用了 HashTable Key 不可重复的逻辑
  2. 同时把 Key 插入到了一个 List 中,每次 mainTimer 执行的时候先检索 5秒钟以前的 key 把他们从 HashTable 中移除
  3. mainTimer 的执行间隔我调整为了900ms,根据微软的说法,在现行的 Windows 系统中 Timer 的分辨率为 15ms,所以 900ms 间隔扫描一次是否有需要执行的任务,同时利用了上面的逻辑避免了任务的重复调度。
using Common;
using System.Collections;
using System.Reflection;

namespace TaskService.Libraries
{
    public class CronSchedule
    {
        private static readonly List<ScheduleInfo> scheduleList = new();
        private static Timer mainTimer;

        private static readonly Hashtable historyList = new();
        private static readonly List<string> historyKeyList = new();


        public static void Builder(object context)
        {
            var taskList = context.GetType().GetMethods().Where(t => t.GetCustomAttributes(typeof(CronScheduleAttribute), false).Length > 0).ToList();

            foreach (var action in taskList)
            {
                string cron = action.CustomAttributes.Where(t => t.AttributeType == typeof(CronScheduleAttribute)).FirstOrDefault()!.NamedArguments.Where(t => t.MemberName == "Cron" && t.TypedValue.Value != null).Select(t => t.TypedValue.Value!.ToString()).FirstOrDefault()!;

                scheduleList.Add(new ScheduleInfo
                {
                    CronExpression = cron,
                    Action = action,
                    Context = context
                });
            }

            if (mainTimer == default)
            {
                mainTimer = new(Run, null, 0, 900);
            }
        }


        private static void Run(object? state)
        {
            var nowTime = DateTime.Parse(DateTimeOffset.UtcNow.ToString("yyyy-MM-dd HH:mm:ss"));

            foreach (var key in historyKeyList)
            {
                var keyTime = DateTime.Parse(key[..19]);

                if (keyTime! <= nowTime.AddSeconds(-5))
                {
                    historyList.Remove(key);
                }
            }

            foreach (var item in scheduleList)
            {
                if (item.LastTime != null)
                {
                    var nextTime = DateTime.Parse(CronHelper.GetNextOccurrence(item.CronExpression, item.LastTime.Value).ToString("yyyy-MM-dd HH:mm:ss"));

                    if (nextTime == nowTime)
                    {
                        try
                        {
                            string key = nextTime.ToString("yyyy-MM-dd HH:mm:ss") + " " + item.Action.DeclaringType?.FullName + "." + item.Action.Name;
                            historyList.Add(key, null);
                            historyKeyList.Add(key);

                            item.LastTime = DateTimeOffset.Now;

                            _ = Task.Run(() =>
                            {
                                item.Action.Invoke(item.Context, null);
                            });
                        }
                        catch
                        {
                        }
                    }
                }
                else
                {
                    item.LastTime = DateTimeOffset.Now.AddSeconds(5);
                }
            }
        }


        private class ScheduleInfo
        {
            public string CronExpression { get; set; }

            public MethodInfo Action { get; set; }

            public object Context { get; set; }

            public DateTimeOffset? LastTime { get; set; }
        }
    }

}

本模块主要是用于日常普通项目中的一些任务定时调度需求。

正文完