C# 中的 Windows 服务(带定时器)- 快速入门






4.63/5 (13投票s)
快速实现支持暂停/继续的 C# Windows 服务,使用单个或多个 System.Net.Timer 驱动的工作进程和 Apache log4net 日志记录的入门指南。
引言
对于需要实现 C# Windows 服务且满足以下要求的用户,这是一份入门和示例实现。
- 在服务中整合一个或多个独立的工作进程,每个进程由一个底层的
System.Timers.Timer
实例驱动。 - 从服务控制管理器 (SCM) 接收启动、停止、重启、暂停和恢复/继续的调用(不包括电源事件)。
- 基本的 Apache log4net 日志记录实现,可以进一步扩展。
- 内置的“管线”处理服务状态转换、计时器事件、计时器释放以及服务组件类与其工作进程之间的线程安全通信。
背景
您应该意识到,在实现服务时使用 System.Timers.Timer
是否是个好主意,一直存在一些争论。我发现它是一种可靠的服务实现方法,但一些受尊敬的专业人士对此持有不同意见。例如,请参阅:
http://weblogs.asp.net/jongalloway//428303
关于日志记录和调试的简要说明
一旦您决定需要一个基于计时器的服务,那么就接受对日志记录的需求。如果您想构建服务,您将需要投入 5%(或更多)的编码精力用于日志记录。如果您是从桌面应用程序背景或其他将日志记录视为次要考虑因素的环境中过来的,这可能会感觉很麻烦,但如果您想认真构建用于生产环境的服务,我想对广泛日志记录的需求的理由在此无需过多解释。
Apache log4net 是一个优秀的开源日志记录库,我推荐您使用它。也有其他替代方案,但本入门指南以及随附的代码假设您对这种日志记录库的选择感到满意。
如果您没有调试 Windows 服务的经验,至少应该阅读以下两篇文章:
http://msdn.microsoft.com/en-us/library/vstudio/7a50syb3(v=vs.100).aspx
http://msdn.microsoft.com/en-us/library/vstudio/sd8zc8ha%28v=vs.100%29.aspx
与调试控制台应用程序相比,调试 Windows 服务项目可能有点棘手。一种建议的方法是,如果您有很多需要调试和测试的代码,请将其保留在 Windows 库项目中。使用 Visual Studio 的单元测试功能、控制台应用程序或其他合适的项目托管类型来测试和调试您的代码。在完成所有(或至少大部分)测试后,您可以将那些经过严格审查、高质量的代码移至 Windows 服务项目进行最终运行。在其他条件相同的情况下,您希望尽量减少对 Windows 服务进行调试和测试的量,但不能以牺牲代码质量为代价。
请记住,服务必须先安装才能进行调试。使用 installutil
成功安装可能对新手来说有点麻烦。如果您是新手,请在投入之前做一些研究。这会为您节省未来的挫败感。
入门
使用入门代码实现具体的(派生)类
封装在库 ServiceTimerLib 中的入门代码包含两个关键类。第一个是 TimerServiceBase
,它继承自 System.ServiceProcess.ServiceBase
;第二个是可释放的 TimerWorker
类。
您应该将这两个类视为 abstract
类,您可以继承它们来构建 Windows 服务的具体类。
注意:实际上,
TimerServiceBase
类并未实现为抽象类,因为这样做会“破坏”Visual Studio 的服务设计器组件。然而,从概念上讲,这两个类都是抽象的——意图是您应该通过继承来派生它们,以构建服务的具体类。
当在 Visual Studio 中创建了一个标准的 Windows 服务项目时,自动生成的服务组件类会继承自 System.ServiceProcess.ServiceBase
。当使用入门代码时,您需要将此更改为使组件继承自 TimerServiceBase
。继承链保持不变,因为正如前面提到的,TimerServiceBase
本身就继承自 ServiceBase
。
直接进入浅水区,让我们展示一个服务组件类的具体实现。
/// <summary> /// The service class inherits from TimerServiceBase, rather than inheriting from ServiceBase /// </summary> public partial class PrimeCalcService : TimerServiceBase { public PrimeCalcService() { InitializeComponent(); } /// <summary> /// In OnStart, we need do litte more than construct our two worker classes, and /// "register" them using the base class RegisterWorker() method. We also set up /// the log4net logging /// </summary> /// <param name="args"></param> protected override void OnStart(string[] args) { // Set up log4net logging. Note that this call by itself is enough to ensure that your // worker classes also get an ILog object, provided this call is made prior to the // RegisterWorker() calls. An additional caveat is that it is assumed you are satisfied // with a call to log4net's XmlConfigurator - if you are using a different logging methodology, // you'll need to hack some of the code to get exactly what you want DefaultLog(); LargePrimeWorker largeWorker = new LargePrimeWorker(); SmallPrimeWorker smallWorker = new SmallPrimeWorker(); RegisterWorker(largeWorker); RegisterWorker(smallWorker); } }
上面代码块中看到的内容是使用此入门指南实现功能性服务组件类所需的全部代码。当然,还有更多的事情在进行,但其中大部分已在 TimerServiceBase
中抽象掉了。
在 OnStart
方法中会发生几件事:
- 调用了继承的
DefaultLog
方法。该方法会做一些工作来设置 log4net 日志记录。 - 创建了一个
LargePrimeWorker
实例。LargePrimeWorker
继承自TimerWorker
,它是一个工作类,除其他外封装了一个System.Timers.Timer
。 - 创建了一个
SmallPrimeWorker
实例。与LargePrimeWorker
一样,它继承自TimerWorker
,是一个带有时钟的工作类。 - 调用了继承的
RegisterWorker
方法两次——每次为已实例化的一个工作进程。
在继续学习具体的工作类实现之前,让我们快速回顾一下上面使用的 RegisterWorker
方法的私有实现。
/// <summary> /// Register a worker /// </summary> /// <param name="worker"></param> private void _registerWorker(TimerWorker worker) { // Provide the worker with a handler to the function that // allows it to evaluate the state of this service worker.getServiceStateHandler = getServiceState; // If this service is using a logger, set a logger for the worker too if (_log != null) { worker.SetLog(LogManager.GetLogger(worker.GetType())); } // Add it to the collection if (_workers == null) _workers = new ArrayList(); _workers.Add(new WorkerCollectionItem(worker)); // The use of this constructor will cause an // associated signal (ManualResetEvent) to be created // See WorkerCollectionItem.cs
这里有两点值得注意:
首先,这行代码...
worker.getServiceStateHandler = getServiceState;
为每个工作进程提供了一个委托函数的句柄,该函数允许工作进程查询服务状态。这使得工作进程能够轮询服务状态的变化,例如当服务正在从运行状态变为暂停状态时。
其次,每个工作进程都被添加到 _workers
集合中,从而允许服务组件类随后管理每个工作进程的信号,并在服务终止时管理每个工作进程的释放。
我们可以稍后更详细地讨论这一点。现在,让我们继续看一个派生工作类的例子。
这是 LargePrimeWorker
的类定义和构造函数。
internal class LargePrimeWorker : TimerWorker { private Random r = new Random(); private System.IO.StreamWriter theFile; /// <summary> /// Constructor - ensure you use an available base constructor. In this case, we specify the /// constructor arguments: /// -- a delayOnStart value of 30000 (approximately 30 seconds) which means nothing will happen for /// approximately 30 seconds after the service is started /// -- a timerInterval value of 10000 (approximately 10 seconds) /// -- a workOnElapseCount value of 6 (work will be carried out every 6th elapse of the /// underlying timer, which equates approximately to 6*10 = 60 seconds) /// Obviously it would be easy to create a non-default constructor for your concrete class using the /// same arguments if, for example, you wanted to store these settings in your app.config (or wherever) /// </summary> internal LargePrimeWorker() : base (delayOnStart: 30000, timerInterval: 10000, workOnElapseCount: 6) { }
当继承 TimerWorker
来构建您的工作进程时,您必须使用 base
指令来访问这两个构造函数之一,因为基类中没有有用的无参数构造函数。在上面的例子中,使用了第二个构造函数。
protected TimerWorker(double timerInterval, uint workOnElapseCount) protected TimerWorker(double delayOnStart, double timerInterval, uint workOnElapseCount)
两个构造函数之间的唯一区别是,前者会立即开始工作,而后者会在工作开始前实现一个初始延迟。
在继续之前,让我解释一下底层的 Timer
如何根据您传递给基构造函数的参数进行操作。正如您所料,Timer
会以与指定的 timerInterval
值一致的规则间隔触发。然而,每次触发并不总是会导致工作。相反,每次触发时都会递增一个私有计数器。只有当计数器达到等于 workOnElapseCount
的值时,计数器才会重置为零并执行工作。尽管如此,在每次触发时,工作进程类都会向其服务组件类查询,以确定服务状态是否已更改。
为了更清楚地解释这一点,让我们看看上面所示的 LargePrimeWorker
示例中底层 Timer
的工作方式。
传递给构造函数的值是:
delayOnStart: 30000, timerInterval: 10000, workOnElapseCount: 6
在这种情况下,将发生以下情况:
- 当工作进程构建时,计时器将空闲约 30 秒(delayOnStart = 30000 毫秒)。
- 大约 10 秒(timerInterval = 10000 毫秒)后,计时器将首次触发。私有计数器将等于 1。触发时,工作进程将查询服务是否有任何状态更改。
- 大约 10 秒(timerInterval = 10000 毫秒)后,计时器将第二次触发。私有计数器将等于 2。触发时,工作进程将查询服务是否有任何状态更改。
- 大约 10 秒(timerInterval = 10000 毫秒)后,计时器将第三次触发。私有计数器将等于 3。触发时,工作进程将查询服务是否有任何状态更改。
- 大约 10 秒(timerInterval = 10000 毫秒)后,计时器将第四次触发。私有计数器将等于 4。触发时,工作进程将查询服务是否有任何状态更改。
- 大约 10 秒(timerInterval = 10000 毫秒)后,计时器将第五次触发。私有计数器将等于 5。触发时,工作进程将查询服务是否有任何状态更改。
- 大约 10 秒(timerInterval = 10000 毫秒)后,计时器将第六次触发。私有计数器将等于 6,这等于 workOnElapseCount 的值。触发时,工作进程将查询服务是否有任何状态更改,私有计数器将重置为 0,并执行工作。
- 以上所有操作将再次发生(不包括初始延迟)。
从这个例子中可以看出,工作大约每 60 秒执行一次,而服务状态大约每 10 秒查询一次。入门代码鼓励您构建频繁查询服务状态的计时器。这样做的好处是,前提是您执行的工作也在合理的时间内完成,您的工作进程将保持对服务状态更改的响应。这很重要,因为服务控制管理器 (SCM) 要求服务及时响应其请求。不要低估正确处理服务行为的这一方面的重要性。
注意:您可以将 workOnElapseCount
配置为 1
,在这种情况下,工作将在计时器的每次触发时执行。构建响应服务状态转换的工作进程的关键是确保您的工作进程执行的工作代码在合理的时间内完成;如果它通常运行超过 50-60 秒,我建议您尝试找到一种方法将其分解为更小的单元。如果您的服务需要在相对较短的时间内完成大量工作,您应该考虑构建能够更频繁地执行快速运行工作方法的工作进程,而不是能够执行执行时间较长代码的工作进程。您不能将 workOnElapseCount
设置为 0
——这样做会抛出异常。
让我们来看一些具体工作类实现示例中的更多代码。
/// <summary> /// StartWork will execute once and only once, at start up of the /// Service /// </summary> /// <param name="info"></param> protected override void StartWork(TimerWorkerInfo info) { OpenFile(); // Note that you get a reference to a suitable log4net logger by way of inheritance, // and without any additional code, provided that your Service makes a call to // DefaultLog() before registering its workers this.Log.InfoFormat("File '{0}' opened", (theFile.BaseStream as System.IO.FileStream).Name); } /// <summary> /// Use the Work override to carry out our work /// </summary> /// <param name="info"></param> protected override void Work(TimerWorkerInfo info) { int N; long prime; // In this example, we simulate some work by calculating a random Nth prime, // and writing the result out to a log file. In a real service implementation, // your work would go here - polling a directory, polling a database, or // whatever else it is your service worker should do at regular intervals Prime.GetNthRandomPrimeLarge(r, out N, out prime); theFile.WriteLine(string.Format("Calculated prime number with ordinal {0}", N.ToString())); theFile.WriteLine(string.Format("The prime is {0}", prime.ToString())); // The info object contains some basic information about the operation of // the underlying timer theFile.WriteLine(info.ToString()); theFile.Flush(); }
- 重写
StartWork
方法以执行任何初始化工作。该方法仅在底层计时器首次触发以执行工作时执行一次。最好限制在工作进程的构造函数中执行的代码量。尽可能将初始化代码放在StartWork
中。 - 重写
Work
方法以执行您的工作。 - 类型为
TimerWorkerInfo
的info
对象可以通过多种方法提供,以提供有关底层计时器的一些简单统计信息和信息。进一步检查源代码以了解包含的属性。
protected override void OnContinue(TimerWorkerInfo info) { base.OnContinue(info); OpenFile(); } protected override void OnPause(TimerWorkerInfo info) { base.OnPause(info); CloseFile(); } protected override void OnStop(TimerWorkerInfo info) { base.OnStop(info); CloseFile(); } protected override void OnShutdown(TimerWorkerInfo info) { base.OnShutdown(info); CloseFile(); }
上面每个覆盖方法的目的应该是不言自明的,但我会简短而明确,以免产生任何疑问。
- 当服务从暂停状态恢复时,用您的工作进程应执行的任何代码重写
OnContinue
。 - 当服务从运行状态过渡到暂停状态时,用您的工作进程应执行的任何代码重写
OnPause
。 - 当服务停止时,用您的工作进程应执行的任何代码重写
OnStop
。 - 当服务因系统关机而停止时,用您的工作进程应执行的任何代码重写
OnShutdown
。
注意:我承认我还没有发现实现与 OnStop
不同的 OnShutdown
代码的任何充分理由。无论哪种情况,您的服务都在停止,对吗?我相信其他专家能够解释为什么 Windows 服务会单独处理这些事件。我从未为自己研究过这一点。
至此,我们已经涵盖了使用入门代码创建具体类所需了解的主要方面。上述解释性注释,以及对文章附件中包含的示例代码的审查,应该足以帮助您开始实现您的服务。下面的章节提供了关于其工作原理的更多见解。
服务与工作进程的协调
在本节中,我将解释抽象的入门代码如何协调服务组件类与其工作进程之间的服务状态转换。
协调服务状态的转换,例如从运行到停止的转换,会给我们带来以下场景:
- 当服务类组件需要停止时,我们需要它的工作进程停止工作,并且在绝大多数情况下,工作进程必须有机会优雅地停止(通常,每个工作进程会持久化某种数据并释放其负责的任何资源)。
- 工作进程将在与服务类组件不同的线程上执行代码,因为它们由底层的
System.Timers.Timer
驱动。 - 因此,我们需要仔细处理好跨线程的通信。
- 由于入门代码抽象了服务类组件和每个工作进程,我们不能使用具体方法来满足这些要求。这样做会破坏将此逻辑抽象为可重用类的优势。
要了解这是如何处理的,首先看 TimerServiceBase
的这段代码:
/// <summary> /// Track the state of this service /// </summary> private ServiceState _serviceState = ServiceState.Running; /// <summary> /// A reference object to facilitate thread-locking the _serviceState /// enum var declared immediately above /// </summary> private object _serviceStateLock = new object(); /// <summary> /// Enum to describe the states of the Service /// </summary> internal enum ServiceState { Running = 0, Pausing = 1, Paused = 2, Stopping = 3, ShuttingDown = 4, Stopped = 5 } /// <summary> /// Delegate definition for the function call made by workers /// to get the state of the service /// </summary> /// <returns></returns> internal delegate ServiceState getServiceStateDelegate(); /// <summary> /// Get the state of this service. Signature matches /// with the delegate declared immediately above /// </summary> /// <returns></returns> internal ServiceState getServiceState() { lock (_serviceStateLock) { return _serviceState; } }
ServiceState
枚举用于描述各种服务状态,当前状态存储在同类型变量 _serviceState
中。由于我们需要跨多个线程访问和操作此变量,因此创建了一个名为 _serviceStateLock
的引用对象用于线程锁定,任何需要访问该变量的代码都将被包含在 lock
子句中。
注意:我们本可以使用 Framework 提供的较新的 Interlocked
方法,而不是使用 lock
子句。我个人更喜欢 lock
,因为它使代码更易读。
为了解决需要抽象工作进程查询服务状态的代码的问题,我们创建一个具有无参数签名和 ServiceState
返回类型的委托。
internal delegate ServiceState getServiceStateDelegate();
... 并实现一个具有匹配签名的函数...
internal ServiceState getServiceState()
... 然后,当使用 RegisterWorker
方法注册每个工作进程时,我们将一个处理程序传递给该方法,然后传递给工作进程。
worker.getServiceStateHandler = getServiceState;
在 TimerWorker
类中,在计时器的每次触发时,我们使用对 getServiceStateHandler
处理程序的调用来查询服务的当前状态,并采取任何必要的行动。
private void _QueryAndHandleServiceState(TimerWorkerInfo info, out bool doWork, out bool stop) { // Query the state of the service TimerServiceBase.ServiceState state = getServiceStateHandler(); // Handle the state appropriately...
拼图的最后一块是确保每个工作进程在服务需要停止时都有机会优雅地停止。通过在注册每个工作进程时为其关联一个 ManualResetEvent
来满足此要求。您在任何上面的代码片段中都看不到此代码,因为它稍微隐藏了一些,但现在它公开了:
worker.signalEvent = new ManualResetEvent(false);
您可以通过查看 TimerServiceBase
类的 OnStop
覆盖方法来更清楚地了解这些 ManualResetEvent
信号的用法。(就这个概念而言,OnPause
的实现方式非常相似)。
protected override void OnStop() { try { if (_log != null) { _log.Info(logmessages.ServiceOnStop); } // Reset the signals for state transitions _resetSignals(); // Change the state of the service to "Stopping" lock (_serviceStateLock) { _serviceState = ServiceState.Stopping; } // Wait for all of the workers to stop. This means using a WaitAll() // that runs across all of the signals in the Workers collection _waitSignals(); // Change the state of the service to "Stopped" lock (_serviceStateLock) { _serviceState = ServiceState.Stopped; } if (_log != null) { _log.Info(logmessages.ServiceStopped); } } finally { base.OnStop(); } }
上面的代码块并不完全详尽,因为有些代码隐藏在私有方法 _resetSignals
和 _waitSignals
中,但希望这足以让您理解这个概念。基本上,发生的情况如下:
- (可能)记录一条消息。
- 重置与每个工作进程关联的
ManualResetEvent
。 - 服务状态更改为停止。
- 服务等待每个工作进程检测到新的服务状态,完成其工作,停止,并
Set
它的ManualResetEvent
。 - 服务更改状态为已停止,(可能)记录另一条消息。
资源释放
理解入门代码如何在资源释放方面帮助您,以及您需要在哪里自行弥补,这一点很重要。
首先,请注意:
TimerWorker
显式实现了IDisposable
,因此您从中派生的任何工作进程类都是IDisposable
。TimerServiceBase
实现了IDisposable
,因为它继承自一个IDisposable
,因此,您从中派生的任何服务组件类TimerServiceBase
也是IDisposable
。
好消息是,TimerServiceBase
包含了确保调用每个工作进程的 Dispose
方法的代码。(前提是必须调用 RegisterWorker
方法来注册该工作进程,但如果您没有这样做,您的工作进程也不会运行)。因此,您无需担心使这些调用生效,底层 System.Timers.Timer
的释放当然也为您处理好了。
注意:在极少数情况下,如果重要,并且无论如何,TimerServiceBase
中的释放代码将按照与注册工作进程的相反顺序调用每个工作进程的 Dispose
。也就是说,传递给第一个 RegisterWorker
调用的工作进程将被最后释放,而传递给最后一个 RegisterWorker
调用的工作进程将被首先释放。
坏消息是(这应该不足为奇),通常的最佳实践释放规则仍然适用。例如,如果您在具体工作类中使用托管的 IDisposable
资源,您必须:
- 使用
using
指令使用该资源,以便它被自动释放;或 - 对于任何没有
using
的资源,请正确实现protected override
释放模式,在此处释放您的资源,并确保调用base.Dispose(disposing)
,如下面的代码块所示;或 - 否则,请确保该资源得到适当的释放。
private bool disposed = false; // Protected implementation of Dispose pattern. protected override void Dispose(bool disposing) { if (disposed) return; if (disposing) { // Free any other managed objects here. // if (theFile != null) theFile.Dispose(); } // Free any unmanaged objects here. // disposed = true; /// Because the base TimerWorker class is IDisposable, you MUST NOT forget to /// base.Dispose(disposing), to ensure the underlying timer is properly disposed of base.Dispose(disposing); }
使用 log4net 进行日志记录
关于如何使用该库的资源非常丰富,因此我故意使本节简短。
在您的服务组件类中,您可以调用 DefaultLog
方法,该方法使用标准方法为您的类获取一个 ILog
记录器。
_log = LogManager.GetLogger(this.GetType());
然后,您可以通过 Log
属性在派生类中访问它。
更有用的是,这对您的工作进程也适用,因为它们每个都获得了自己的类型特定的 _log
实例,前提是您在调用 RegisterWorker
之前,在服务类中调用了 DefaultLog
。
与服务组件类一样,在每个派生的工作类中,您可以使用 Log
属性来进行日志记录。(不过,在代码中检查 Log != null
可能是明智的,以避免 null 引用异常)。
示例 PrimeCalService 包含一个可用的基于 xml 的 log4net 配置,如果您不熟悉 log4net,可以将其作为起点进行参考。只需查看 app.config
文件即可。
我建议您花一些额外的时间来研究 log4net 文档,因为有无数种方法可以配置和使用这个库。
执行执行时间较长的任务
我之前提到过,我认为将任务分解为执行时间较短的单元是构建行为良好的服务的关键之一。
如果您需要在 Work
中运行可能需要数分钟或更长时间才能完成的代码,当 SCM 停止或暂停您的服务时,您将在使用入门代码时遇到问题。入门代码假定每个工作进程的计时器会定期触发,以确保工作进程对服务状态的变化有足够的响应;并且底层计时器在进入和退出 Work
之间根本不会触发。如果这还不清楚,请仔细阅读并理解 TimerWorker
代码,我相信您很快就会看到问题所在。
不过,您确实可以选择在 Work
执行期间检查服务状态。基类为此目的提供了一个布尔属性 ServiceStateRequiresStop
。如果您能够足够频繁地检查其值并且能够在结果为 true
(这意味着服务状态不再是运行)时迅速退出工作,这可能足以使您的服务保持响应。
最后的想法
- 如果您需要为生产环境构建基于计时器的服务,您可能需要仔细考虑入门代码是否包含足够的异常处理来满足您的需求。您可能需要在此基础上进行一些额外的工作——我提供的代码未经广泛测试,我将其提供给社区作为一种概念模型,而不是别的。简而言之,我在这里给您一个入门,而不是买一辆法拉利。
- 这个初版代码可能存在一些不足之处(我自己知道一些,但还没有机会解决)。如果您有想法和反馈,请随时告诉我。我欢迎任何程度的建设性批评,并将努力(在时间允许的情况下)进行改进并相应地更新文章。
- 如果您是服务新手,以下是一些可能为您省去麻烦的小贴士:(1)第一次使用 installutil 安装时,请确保您已使用 VS 设计器添加和配置了安装程序,并且您的项目已构建并包含该安装程序;并且您以提升的(以管理员身份运行)权限运行 installutil;(2)仔细考虑您的服务应在哪个账户下运行。如果您的服务仅访问本地资源,本地系统通常就足够了,但如果您的服务代码需要访问远程数据库等,您可能需要考虑其他选项;(3)使用 附加到进程进行调试时,始终以提升的权限(以管理员身份运行)启动 Visual Studio。
致谢
本文附带的示例服务中嵌入了一个修改过的素数计算例程。原始例程的来源在此:
http://stackoverflow.com/questions/13001578/i-need-a-slow-c-sharp-function
发布者没有明确注明来源。如果属于您,请告诉我,我很乐意在此正式承认您。
历史
这是本文的首次发布。