65.9K
CodeProject 正在变化。 阅读更多。
Home

.NET 定时器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.89/5 (130投票s)

2004年3月24日

CPOL

15分钟阅读

viewsIcon

1143437

downloadIcon

38533

一个轻松支持绝对计划的定时器,例如每天凌晨4点或每周五下午5点运行。

引言

在我参与的大多数项目中,通常都需要在各种绝对时间触发事件。例如,每周五下午5点,或者每小时的15分。 .NET 原生定时器只支持相对计时接口。您可以指定从当前时间开始需要等待多久。对于简单的事件,这很容易计算,但是当您开始处理更复杂的计划时,代码可能会变得冗长。这是编写一组简单的调度原语以简化构建更复杂计划的尝试。

处理面向人类的计划只是此定时器的目标之一。自动恢复,事件日志记录和解决并发问题也是目标。

背景

调度批处理操作是一项常见但常常被忽视的编程任务。许多应用程序需要按固定时间发送电子邮件批次或生成报告。 .NET 自带的原生定时器设计用于像硬件定时器一样工作,从启动时开始以固定速率触发。这对于许多应用程序来说是可行的,但当您需要每天在固定时间调度事件,或者以交替间隔调度事件,更不用说尝试管理仅在工作日发生的事件时,可能会很不方便。在编写了自定义逻辑来处理这些操作后,我认为需要一个更通用的解决方案。

创建定时器和调度事件只是需要解决的问题的一部分。每个自动进程都需要有人维护,在它停止时重启它,在事件被跳过时重新运行它,并在它不起作用时进行调试。这些过程的优点是它们消除了人工确保这些事情得到处理。缺点是当它们失败时,它们可能会在很长一段时间内无人察觉。因此,一个好的定时器不仅需要能够处理您能想到的最疯狂的计划,它还需要具有极强的抗故障能力,并提供一种在出现问题时通知其操作员的机制。

错误处理有两种形式,首先是事件处理程序抛出异常不应能够关闭进程。其次,定时器需要一种方法来从系统停机时间(如停电和类似故障)中恢复。所有这些操作都应与事件本身分开管理。

一些 .NET 原生定时器具有不易清晰记录的属性和复杂性。例如,System.Threading 定时器使用线程池中的线程来运行事件。这意味着如果一个事件处理程序运行时间过长,那么在第一个事件运行时,其他处理程序可以在单独的线程上启动。如果您没有显式确保您的进程是线程安全的,那么您可能会遇到一些非常难以追踪的错误。我们的定时器应该允许从定时器轻松控制这一点,而不是强迫事件处理程序处理这个问题,或者创建一个新对象来处理同步问题。

我最初编写这个定时器时,以 System.Threading 定时器为模型。它的限制是只有一个与之关联的事件。从那时起,我收到了许多添加支持的请求,以便从同一个定时器调度多个事件。起初我不太喜欢这个想法,因为它使整个过程更加复杂,我更喜欢简单的操作。但是,在编写了这个定时器的几个消费者之后,我意识到它对代码的易用性大大提高。您只需要启动和停止一个对象,因此自从它使客户端更简单以来,我已经接受了这个想法。

我最喜欢的 .NET 框架功能之一是每个委托上的 BeginInvoke/EndInvoke 操作。我认为提供一个类似于委托在带有计划的委托上的方法将非常酷,该方法只是在一个特定计划上运行一个函数。或者,一般来说,类似于 delegate.EventInvoke(EventSource, function parameters here);。不幸的是,委托操作与各种语言编译器集成得太紧密,无法处理这类事情。所以,我想用这个定时器做的一件事就是尽可能地模仿这个操作。

所以现在我们有了需求

  • 处理各种面向人类的计划以及基本的定时器操作,如 Windows 定时器。
  • 操作方式类似于原生的 System.Threading Windows 定时器。
  • 事件应尽可能准确地发出信号,理想情况下在计划时间毫秒内。
  • 能够在无头服务进程中运行,就像 System.Threading Windows 定时器一样。
  • 在错误条件下更健壮。事件中的未处理异常不应触发 AppDomain 未处理异常处理程序。
  • 提供一种报告任何错误的方法。
  • 为定时器关闭和事件被错过的情况提供可选的自动恢复方法。
  • 提供更明确的选项来防止并发事件操作。
  • 每个定时器将处理多个事件,每个事件都有自己独立的计划。
  • 定时器不限于事件,而是提供一种执行任意函数的方法,而无需创建包装函数。

首次尝试

本节详细介绍了我见过的一些定时器方法以及我早期的尝试,以及当前设计的动机。我见过许多在 NT 任务计划程序下运行的进程。在许多情况下,这要么是一个脚本,要么是一个作为任务运行的常规可执行文件。这的优点是与操作系统集成,易于设置和配置,并带有内置的 UI。管理更困难,错误处理和恢复仅与其执行的进程一样好。此外,每个独立的计划都与特定进程相关联。在许多情况下,存在通过这种方式计划的 VB 和 DCOM 应用程序,这些应用程序需要用户登录系统才能正确运行,尽管这更多是由于缺乏配置知识所致。

对于我们这些能够访问 SQL Server 的人来说,SQL Server Agent 和 DTS 为调度操作提供了一个出色的平台。它既有 UI 也有调度操作的编程接口。它可以运行进程或执行 SQL 语句,并且包括日志记录、错误恢复工作流和通知管理。如果您可以访问此功能,那么有许多强有力的理由使用这些工具。唯一的真正缺点是您的进程将与 SQL Server 竞争资源。SQL Server 在有整个系统专用于它时运行得最好,因此它应该仅限于在同一系统上运行效率更高的操作,或那些不消耗大量资源的操作。

第三种常见的调度方法是创建 Windows 服务。它提供了最大的灵活性,但也内置了最少的功能。框架为您提供的只有安装程序,以及启动和停止您的服务的 ক্ষমতা。所有错误处理、报告、配置和其他细节都留给您。作为异常被抛出时立即关闭的服务,或者在有人尝试停止服务时挂起的服务太常见了。另一个常见问题是批处理操作不可重新启动,并且在故障后,需要采取手动操作才能使系统恢复到正确状态,然后才能重新启动服务。

除了服务基础设施提供的最少支持外,唯一可用的定时器是 .NET pulse 类型定时器。我通常看到它们被用来以固定的速率创建一系列事件,例如每 5 分钟一次。然后,在每个事件上进行一个 switch 语句或查找,将批处理执行时间与当前时间进行比较,如果它在执行窗口内,则运行批处理过程。这种方法的一些问题是它只保证批处理将在所需时间内执行。因此,如果您的 pulse 速率是每 15 分钟一次,并且您调度了在 12:00 执行的内容,那么它将在 12:00 到 12:15 之间的某个时间运行,具体取决于服务何时启动。您可以通过加快 pulse 频率来弥补。但是,这会增加事件被错过的风险,例如,如果一个更高优先级的线程在整个 pulse 期间都在运行。

为了处理这些可能性,我们需要一个知道何时错过节拍的定时器。因此,即使它延迟触发,它也不会丢弃任何计划的事件。定时器可以通过维护事件历史来做到这一点。它记录上次触发的时间,并查找在此时间和现在之间发生的所有事件。它在等待下一个事件需要触发之前,会触发所有事件。这不仅可以防止在定时器运行时错过事件,而且可以在状态被持久化到某个地方时帮助定时器从中断中恢复。

此定时器应尽可能少地假设连接到它的每个处理程序。这意味着每个事件都应包装在异常处理程序中。提供一个错误事件供客户端挂钩并根据需要处理。

防止并发事件操作:如果您使用 System.Timers.Timer 类,您的每个事件都将在线程池中的一个线程上发生。很多时候,我看到应用程序使用此定时器,并且在开发中一切正常,但在生产环境中,报告和处理的数据都一团糟。这是因为此定时器的默认设置允许事件在不同线程上并发发生。结果是,批处理过程在生产环境中花费的时间更长,您最终会同时运行多个批处理过程。定时器允许您提供一个 SynchronizingObject 来防止事件并发发生。这可以防止它们同时执行,但不能让我们控制如何处理这些重复事件。这取决于我们正在处理的事件。正确的解决方案可能是让它们并发运行,完全跳过重叠的事件,或者将重叠的事件排队,以便在当前事件完成后立即运行。

当我最初编写这个定时器时,我提供了一个简单的事件驱动接口来设置一个定时器上的单个事件,因为 .NET 定时器就是这样设置的。经过几次请求后,我添加了方法来在同一个定时器上安排多个独立的事件,具有不同的计划。这允许在单个定时器上调用 start 和 stop 方法来控制所有事件。

使用定时器

计划

IScheduledItem

每个计划都需要提供两个类似的操作才能被调度。首先,返回它们在特定时间之后将触发的下一个时间。定时器使用此值来确定在下一个事件之前等待多长时间。其次,找到在特定时间间隔内触发的所有事件。这用于在定时器触发时调用所有适当的事件。这在 IScheduledItem 接口中表示。

  public  interface  IScheduledItem
  {
    void  AddEventsInInterval(DateTime  Begin,  
         DateTime  End,  ArrayList  List);
    DateTime  NextRunTime(DateTime  time);
  }

SimpleInterval

SimpleInterval 类模拟了一个简单的 pulse 定时器。它的构造函数接受两个参数:一个绝对开始时间和一个 TimeSpan,表示事件之间的间隔。它比 ScheduledTime 对象更通用,因为任何间隔都可以被调度。

ScheduledTime

ScheduledTime 类模拟了一个在几种固定速率之一(如每月、每日、每周或每小时)触发的定时器。它使得以更面向人类的速率安排事情变得更加容易,例如每周四早上6点。

SingleEvent

SingleEvent 类模拟了一个定时器,该定时器一次性在固定时间触发,然后处于非活动状态。

EventQueue

EventQueue 接受多个计划并提供它们的并集。因此,如果您需要每天早上 5 点和晚上 7 点执行一个事件,您可以为这两个事件创建计划并将它们都添加到 EventQueue 对象中。

BlockWrapper

BlockWrapper 是一个用于非常特定操作的调度器。它限制另一个计划仅在重复的时间范围内触发。这主要用于管理将在工作日、周末或仅在营业时间内运行的内容。

单个事件

我试图让接口尽可能接近原生的 .NET System.Timers.Timer 对象。但是,原生的事件参数是 sealed 且无法公开创建,所以我不得不创建单独的委托和事件参数定义。下面是一个使用该定时器每天下午 5 点运行一次的简单示例。

ScheduleTimer  TickTimer  =  new  ScheduleTimer();
TickTimer.Events.Add(new  Schedule.ScheduledTime("Daily",  "5:00  PM"));
TickTimer.Elapsed  +=  new  ScheduledEventHandler(TickTimer_Elapsed);
TickTimer.Start();

多个事件

定时器上的 AddJob 方法用于向定时器添加多个事件或作业。第一个重载最易于使用,它接受三个参数:计划、委托,以及一个可选的参数数组,用于传递给方法。为了给您提供更多灵活性,您不必指定方法的所有参数。如果存在未指定的 DateTimeobject 参数,则会传入触发事件的对象以及此事件应运行的时间。这保留了 .NET EventArgs 调用约定,同时让您可以自由地向事件传递其他参数。

如果您需要更多控制,那么您可以创建自己的 TimerJob,指定您需要的用于事件的 MethodCall 的确切类型。

常规的 AddJob 方法会同步作业,因此一次只执行一个作业。如果您的作业可以并发运行,那么您可以使用 AddAsyncJob 方法添加它们。

错误处理

定时器提供了一个 Error 事件处理程序。如果您没有为此事件添加处理程序,您将不会收到有关事件处理程序抛出的任何异常的通知。

恢复

恢复或状态持久化是自动运行因服务中断而错过的作业的能力。默认情况下禁用此功能,因为它需要以应用程序特定的方式存储上次执行时间。要添加应用程序特定的存储,您只需要实现以下接口

  public  interface  IEventStorage
  {
    void  RecordLastTime(DateTime  Time);
    DateTime  ReadLastTime();
  }

我提供了三个实现。默认是 LocalEventStorage,它将上次事件时间存储在内存中,因此只要定时器保留在内存中,它就会确保每个事件都触发。如果您不希望任何恢复,则可以像这样分配 NullEventStorage

    timer.EventStorage = new NullEventStorage();

我还提供了一个简单的基于 XML 文件的事​​件存储类,可用于服务等内容,但如果您真的关心恢复,则应实现自己的。

IMethodCall

.NET 委托可用于为对象提供回调,因为它们允许您存储一个对象并调用该对象上的特定方法,就像它是简单的静态方法一样。这允许仅依赖于特定方法签名的通用操作。缺点是,如果您的方法不匹配签名,您需要编写一个包装方法,或者如果您没有对象的源代码,则编写一个包装类。C# 2.0 使用匿名委托解决了这个问题,但在此期间,我编写了一些类来简化通过部分传递参数给方法来构建委托。假设我们想调度一个接受报表 ID 作为参数的方法。

public Delegate void GenerateReport(int reportID);

public class Report
{
    public static void Generate(int reportID) {}
}

public class EventWrapper
{
    EventWrapper(int reportID, GenerateReport report)
    {
        mReportID = reportID;
        mReport = report;
    }
    public void EventHandler(object src, EventArgs e)
    {
        mReport(mReportID);
    }
    int mReportID;
    GenerateReport mReport;
}

使用 MethodCall 对象,我们可以编写如下代码

IMethodCall call = new DelegateMethodCall(new GenerateReport(Report.Generate), 10);
obj.Event = new EventType(call.EventHandler);

此外,使用一些参数设置器对象,我们可以根据名称而不是顺序和类型将参数绑定到方法。

代码示例

  • 每秒一次,在整秒触发。
    TickTimer.Events.Add(new  Schedule.ScheduledTime("BySecond",  "0"));
  • 每分钟一次,在整分后15秒触发。
    TickTimer.Events.Add(new  Schedule.ScheduledTime("ByMinute",  "15,0"));
  • 每周一早上 6:00 触发。
    TickTimer.Events.Add(new  Schedule.ScheduledTime("Weekly",  "1,6:00AM"));
  • 在 2008 年 6 月 27 日早上 6:00 触发一次。
    TickTimer.Events.Add(new  Schedule.SingleEvent(new DateTime("6/27/2008 6:00")));
  • 自 2003 年 1 月 1 日午夜开始,每 12 分钟触发一次。
    TickTimer.Events.Add(new Schedule.SimpleInterval(new 
               DateTime("1/1/2003"), TimeSpan.FromMinutes(12)));
  • 从早上 6:00 到下午 5:00,每 15 分钟触发一次。
        TickTimer.Events.Add(
            new Schedule.BlockWrapper(
                new Schedule.SimpleInterval(new DateTime("1/1/2003"), 
                                                   TimeSpan.FromMinutes(15)),
                "Daily",
                "6:00 AM",
                "5:00 PM"
            )
        );
Clock 示例应用程序

对于示例项目,我编写了一个简单的闹钟。为了让它更有趣一些,我让它透明且始终可见。将此功能添加到 .NET Forms 应用程序非常容易。我只需要设置窗体的透明度和隐藏正常的 Windows 边框。重写鼠标按下和抬起处理程序以使整个窗口可拖动,以及添加一个上下文菜单来关闭和设置计划,同样容易。

由于我主要编写 ASP.NET 应用程序,我惊讶于在窗体应用程序中存储动态状态信息没有一个简单的方法。从 app.config 文件中读取数据非常简单,但没有一个简单的 API 可以更新该数据。为该应用程序编写一个简单的类来将数据存储在 XML 文件中同样容易。

历史

  • 2004年3月24日 - 发布。
  • 2004年6月14日 - 错误修复和更新的定时器对象模型。
  • 2005年8月20日 - 更新文章文本,改进定时器中的异常处理,更新测试以独立于区域设置,修复了每月定时器解析错误。
  • 2005年8月29日 - 更新以修复停止问题,添加了无恢复模式,并添加了单个事件计划项。
  • 2005年9月12日 - 更新以修复定时器对象上丢失的 Dispose 方法。
© . All rights reserved.