
用C#写Windows服务程序这件事听起来像是一个“上古时代”的开发任务但实际上只要你做过任何需要长时间后台运行、脱离用户会话独立工作的功能——比如数据同步、文件监控、定时报表推送——Windows服务就是绕不开的选项。C#在这方面成熟度极高配套的框架和调试手段也很完善今天这篇文章我就把从零手写一个服务程序的完整过程讲透包括项目结构、生命周期、调试方式和部署细节以及那些文档里不会明说的坑。先说清楚这篇文章不打算只给你贴一段能跑的代码。代码本身很容易写真正难的是理解服务程序和普通EXE的区别以及在开发环境里怎么调、在生产环境里怎么管。我下面会从运行模型开始讲再逐步落到实际项目操作全程都是我验证过的做法。1. 服务程序为什么“看起来简单跑起来玄学”先理解生命周期1.1 服务程序不是双击启动的程序它由服务控制管理器调度很多人第一次写Windows服务会用写控制台程序的思路Main函数里跑一个循环任务处理完进程退出。这个思路从根上就是错的。Windows服务程序不是由用户直接启动的而是由操作系统里的服务控制管理器SCMService Control Manager统一调度。SCM负责启动、停止、暂停服务并监视服务进程的健康状态。这就解释了一个常见现象你在开发环境里双击运行服务EXE弹出一个窗口然后程序很快就退出了看起来“什么都没发生”。因为服务程序在被SCM启动时才会走服务相关的初始化路径直接双击时没有SCM传入的启动上下文很多服务框架代码不会正常执行程序自然直接退出。服务程序还有一个特点它通常运行在独立的会话Session 0中和用户登录后看到的桌面不在同一个会话里。这意味着服务程序里不能随便调用需要桌面交互的功能——比如弹出一个MessageBox等用户点确定。如果你真写了这个弹窗会出现在一个用户根本看不见的会话里然后整个服务卡死在那里直到SCM判定超时。所以理解服务程序的第一步是把它想成“一个由SCM托管的外卖配送点”订单启动/停止指令由平台统一派发你只需要在收到指令时做出正确响应不能自己擅自开始营业或者关门歇业。1.2 三个必须看懂的回调OnStart、OnStop、OnShutdownC#里写服务程序最经典的做法是创建一个继承自ServiceBase的类然后重写它的几个方法。绝大多数初学者只关心OnStart和OnStop但实际生产环境里OnShutdown同样重要。OnStart()是服务的入口SCM在启动服务时会调用它。有一点特别容易误解OnStart()里面不适合放耗时的初始化任务。SCM有个超时机制默认是30秒某些扩展配置可以调整如果OnStart方法在超时时间内没有返回SCM会判定服务启动失败。所以OnStart里的正确动作是快速完成基础状态设置把耗时任务丢到后台线程去然后立刻返回。不是说不能在OnStart里做任何IO而是要做最轻量的验证重活交给后台。OnStop()是停止服务时的回调。这里要注意的相反方向OnStop不能太快返回。SCM同样有超时机制如果OnStop还没处理完就返回服务进程可能直接被杀掉导致状态没保存、资源没释放。正确做法是OnStop里发信号让后台线程退出然后等待线程真正结束再返回。这个过程要设计好不能把等待时间做得无限长。OnShutdown()是在系统关机时调用的。很多人不重写这个方法导致的问题是用户关机时服务收到的是关机通知而不是停止通知如果服务在OnStop里才做清理工作OnShutdown不处理的话那些清理就不会执行。至少在OnShutdown里调用和OnStop相同的清理逻辑只是执行时间更短。这三个回调构成了服务的骨架。ServiceBase还支持暂停和继续OnPause/OnContinue但现实中用得少而且需要在服务属性里允许“暂停和继续”复杂度也不低。做日常后台任务先不用管这两个。2. 项目搭建与代码骨架从空解决方案到能跑的服务2.1 用Worker服务模板还是老式ServiceBase项目如果你用的是.NET 6或更高版本新建项目时看到一个“Worker Service”模板。这个模板默认生成一个BackgroundService配合Generic Host框架日志、配置、依赖注入全都内置。它本身不是Windows服务还需要一个额外的包来把它托成Windows服务。如果你用的是.NET Framework那就是经典ServiceBase路线项目模板里直接选“Windows服务”。我的建议是分情况选型全新项目、能用.NET 6/8等新版本环境优先走Worker Service加托管的路线。因为GC、日志、DI都是现成的和ASP.NET Core的技术栈一致后续维护成本低。老项目维护、或者被迫在.NET Framework环境里开发那就老老实实用ServiceBase别强行升级。Worker Service虽然方便但它本质上还是“控制台应用”。要在Windows服务环境跑起来需要安装一个包NuGet里搜索Windows服务托管相关包然后在Program.cs里调用扩展方法把宿主注册成服务。加上这个包装之后宿主内部会在ServiceBase回调里帮你启动和停止后台任务我们只需要关心BackgroundService里的ExecuteAsync循环怎么写。ServiceBase路线的优势是直观代码量少不依赖新框架。缺点是日志、配置都要自己搭依赖注入也不方便。如果只是做一个很简单的轮询服务ServiceBase完全够用一旦涉及多个后台任务、数据库、配置中心Worker Service能省很多事。这条建议放在这里是因为我见过不少项目一开始图省事选了ServiceBase后面业务变复杂硬着头皮在服务类里手写了一堆静态类来管理配置维护起来相当痛苦。2.2 核心代码的骨架把业务逻辑和Windows服务解耦不管你选哪种路线有一条原则是通用的Windows服务类里面不要写具体业务逻辑。服务类的职责是接收SCM的指令、管理后台任务的启停具体做什么应该放在一个单独的业务类或后台任务类里。这样写服务类能保持很薄调试和替换都容易。我拿ServiceBase路线给一个最小骨架例子方便理解public class MyService : ServiceBase { private CancellationTokenSource _cts; private Task _mainTask; private readonly IMyBusinessWorker _worker; // 业务worker由外部构造注入 public MyService() { ServiceName MyDemoService; CanStop true; CanShutdown true; } protected override void OnStart(string[] args) { _cts new CancellationTokenSource(); _mainTask Task.Run(() _worker.RunAsync(_cts.Token), _cts.Token); } protected override void OnStop() { _cts?.Cancel(); try { _mainTask?.Wait(TimeSpan.FromSeconds(10)); } catch (AggregateException) { // 任务中抛出的取消异常在此汇聚忽略即可 } _cts?.Dispose(); } protected override void OnShutdown() { OnStop(); } }注意几个点。第一OnStart里我用Task.Run去启动业务任务这样OnStart立刻返回SCM不会超时。第二OnStop里先发取消信号再等待任务结束给业务代码一个优雅退出的机会。第三OnShutdown里直接复用OnStop的清理逻辑这是很多服务的缺口。如果走Worker Service路线对应代码大致是public class Worker : BackgroundService { private readonly ILoggerWorker _logger; private readonly IMyBusinessWorker _worker; protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation(Service started at: {time}, DateTimeOffset.Now); await _worker.RunAsync(stoppingToken); } public override async Task StopAsync(CancellationToken cancellationToken) { _logger.LogInformation(Service stopping at: {time}, DateTimeOffset.Now); await base.StopAsync(cancellationToken); } }BackgroundService的好处是框架已经帮你处理了Start和Stop的时序ExecuteAsync返回时会通知宿主状态管理更安全。业务Worker里再写循环结构很清晰。2.3 业务循环里的关键设计任务队列与异常兜底业务Worker内部不要写一个死循环然后什么都不管。我的习惯是把它拆成三层轮询调度层、任务执行层、异常兜底层。轮询调度层负责按策略决定“要不要干活”比如每秒检查一次是否有到期任务任务执行层负责真正干活异常兜底层确保任何一个任务抛异常都不会拖垮整个服务。一个典型的轮询结构长这样public async Task RunAsync(CancellationToken token) { while (!token.IsCancellationRequested) { try { await ProcessOneRoundAsync(token); } catch (OperationCanceledException) when (token.IsCancellationRequested) { break; // 正常取消 } catch (Exception ex) { _logger.LogError(ex, Round processing failed); // 不退出等待下一轮 } try { await Task.Delay(TimeSpan.FromSeconds(5), token); } catch (OperationCanceledException) { break; } } }这里有一个非常容易被忽略的细节Task.Delay必须要传token。如果服务正在停止你的轮询代码还在Task.Delay等待不传token的话触发Cancel后循环不会立刻退出而会等到Delay完成。遇到Delay时间很长比如30分钟跑一次的任务服务的停止就会被拖住SCM可能强制杀进程。传了token取消时Delay立刻抛OperationCanceledException我们接住后退出循环整个过程是顺畅的。3. 开发期调试的两条路一条叫“伪装成控制台”一条叫“附加进程”服务程序的调试是新手最容易卡住的地方。你写好代码直接F5跑发现它闪一下就没了根本没法断点。原因在第一节讲了服务需要SCM来启动。这里我给出两个实战方法都自己用过。3.1 伪装成控制台应用同一套代码两种运行方式最简单的调试方案是让程序自己判断当前是“被SCM启动”还是“被命令行启动”如果是后者就走控制台逻辑直接运行业务Worker。Windows服务项目里获取这个判断可以用Environment.UserInteractive属性。这个属性在服务会话里为false在普通用户会话里为true。Main函数可以写成这样static void Main(string[] args) { if (Environment.UserInteractive) { // 控制台调试模式 var worker new MyBusinessWorker(); using var cts new CancellationTokenSource(); Console.CancelKeyPress (_, e) { e.Cancel true; cts.Cancel(); }; Console.WriteLine(Running as console app...); worker.RunAsync(cts.Token).GetAwaiter().GetResult(); } else { // 服务模式 ServiceBase.Run(new MyService()); } }这个模式的妙处在于你在IDE里直接按F5走的就是控制台分支日志直接打在输出窗口断点任意下。真正的业务逻辑没有被改动部署到服务器上自动走服务分支。我强烈建议每个Windows服务项目都加上这个入口调试效率能高好几倍。有一点要说明Environment.UserInteractive在较新的.NET版本里行为有一些调整但核心判断逻辑仍然有效。如果你用Worker Service模板因为默认是控制台输出直接就能以控制台方式跑调试这个技巧主要用于ServiceBase模式的老项目。3.2 附加进程调试服务已经装好了怎么连上有时候必须调试一个已经在服务状态下运行的程序比如只在服务模式下才出现的启动问题。这时候要用“附加到进程”的方式。先说步骤再给一个省事的tip。步骤大概是先把服务跑起来确保它处于Running状态然后在开发工具里打开项目的调试菜单选择“附加到进程”在进程列表里找到同名进程附加进去再在代码里打断点。但这里有个问题服务启动阶段可能在OnStart执行时就已经把服务工作做完了你附加进去时断点早就错过了。解决方法是在OnStart里主动调用Debugger.Break()或者Debugger.Launch()让程序在指定位置停下来等调试器附着。需要注意的是Debugger.Launch()本身会弹一个“选择调试器”的对话框但服务程序运行在Session 0这个对话框用户看不见所以实际使用中更推荐Debugger.Break()配合开发工具在启动时等待附加的模式。还有个笨办法在入口加几十秒延迟等你把调试器附上。这个够用但比较原始生产环境千万别留。附加进程调试是了解服务程序的必经之路我建议每个开发者在服务项目里至少走一遍完整流程这样你对SCM和服务进程之间的关系会有非常直观的感受。4. 安装、卸载与更新命令行工具比“服务管理器”可靠Windows服务的安装和卸载有两种主流的命令行手段InstallUtil和sc.exe。网上很多教程还在教你打开“服务管理器”手动操作但手动操作对部署脚本化、自动化来说完全不够。我用下来安装/卸载/更新服务一定要命令行化谁手动点谁吃亏。4.1 sc.exe 常用命令速查sc.exe是Windows自带的工具在管理员权限的命令行中执行。常用命令我整理成了一张表操作命令示例说明创建服务sc create MyDemoService binPath D:\svc\MyService.exe start auto注册服务binPath指向exe路径startauto表示自动启动启动服务sc start MyDemoService启动服务停止服务sc stop MyDemoService停止服务查询服务状态sc query MyDemoService查看当前状态删除服务sc delete MyDemoService删除服务注册配置失败恢复sc failure MyDemoService reset 86400 actions restart/5000/restart/10000服务崩溃后自动重启有几个语法上的坑值得专门提醒第一sc create的每个参数后面等号和值之间必须有一个空格但参数名和等号之间不能有空格比如binPath D:\svc\MyService.exe这个写法是合法的binPath ...或者binPath ...中间没有空格就会报错。第二路径带空格时要加引号这是典型的Windows命令行风格不难记但容易错。第三删除服务前不用先停止sc delete可以处理已停止的服务但如果是运行中删除通常会先停止再删除。如果你用InstallUtil命令是InstallUtil.exe MyService.exe和InstallUtil.exe /u MyService.exe。InstallUtil会读取服务类里的ServiceName属性来注册。注意这里有一个踩坑经验InstallUtil安装结束后它会弹一个提示说“服务安装成功但没能自动启动”这是正常现象不要误以为安装失败服务需要手动sc start启动。另外用InstallUtil改代码后重新安装时必须先卸载旧服务再安装不能直接覆盖。4.2 服务账户与日志路径的权限问题服务默认运行账户的选择直接决定你的程序能不能读写某些文件。我在实际项目里被这个坑过不止一次——服务在开发机上是好的部署到服务器上就疯狂报“拒绝访问”。用sc create创建服务时可以通过obj参数指定账户比如obj LocalSystem或obj .\SomeUser。常见选择有三个LocalSystem权限很高可以访问系统大部分资源文件系统权限基本不需要额外配置。但权限太高从安全角度看不符合最小权限原则。NetworkService比LocalSystem低一些能够访问网络资源但对本机文件系统写权限受限。LocalService权限最低适合只做轻量任务的场景。很多后台任务需要写日志如果你把服务账户设置为LocalService默认是没有权限写Program Files目录下的日志文件的。这里要么把日志路径配置到一个专门目录比如C:\ProgramData\你的服务名\并给这个目录授予服务账户的读写权限要么干脆把服务和数据目录放在同一个盘符下避免系统目录的权限限制。还有一点是关于64位和32位的问题如果你的服务程序是32位编译的部署到64位服务器上InstallUtil可能会因为文件系统重定向而找错路径导致注册失败。这种情况下建议直接按64位编译或者用sc create明确指定完整路径。这个问题在新版.NET下选择AnyCPU基本能规避但老项目经常会踩。5. 服务挂掉之后不能裸奔恢复策略和日志兜底5.1 自动重启配置别等服务主管半夜打电话服务进程可能因为未捕获异常、资源耗尽等原因挂掉。虽然代码里做了异常兜底但总有兜不住的场景比如内存溢出、系统强制杀进程。这时候如果没有配置恢复策略服务就一直处于Stopped状态业务停滞直到有人发现。生产环境一定要配sc failure。我常用的配置是把“第一次失败后自动重启第二次失败后自动重启后续持续失败则停止”做成一组策略。示例如下sc failure MyDemoService reset 86400 actions restart/5000/restart/10000/restart/30000这条命令的含义是5秒后重启如果再次失败10秒后重启如果第三次失败30秒后重启reset86400表示如果服务正常运行超过一天失败计数重置。这样最坏情况下服务不会一直无限重启也给运维留了介入空间。还有一个和恢复策略配合的参数是服务依赖。如果你的服务依赖数据库、网络等组件建议在sc create时通过depend指定依赖服务这样SCM会等待依赖服务就绪后再启动你的服务减少启动期因依赖未就绪导致的失败。不配置依赖的话你自己在代码里做“等待连接重试”也可以只是启动日志里会多一堆连接失败记录。5.2 服务日志的兜底思路日志系统本身不能成为崩溃源日志是排查服务问题的第一手材料。服务程序没有控制台你写再多的Console.WriteLine也没人看必须接一个文件日志或者事件日志。文件日志是主流。文件日志的坑主要在三点第一日志文件所在目录必须有权限这条前面提过第二日志文件不能无限增长要有按日期切分或按大小切分机制第三写日志的动作本身不能阻塞业务线程最好走异步写入或队列缓存。我自己在服务项目里的做法是用一个内存队列接收日志消息后台有一个专用线程批量写文件。这样主业务线程不会因为磁盘慢而卡住日志写不写成功也不影响业务。另外在进程级别再兜一层给AppDomain.CurrentDomain.UnhandledException和TaskScheduler.UnobservedTaskException挂上处理器把未捕获异常也打进日志。这两个事件不是用来“恢复”程序的而是用来留证据的——服务挂了你至少知道死在哪一行。如果你的服务跑的是定时任务日志里一定要带任务编号和时间戳。定时任务出问题和偶发任务是排查难点日志如果不够结构化事后回溯会非常痛苦。我一般要求每一条日志至少包含执行轮次、任务ID、如果有批处理ID、耗时。这个习惯在出线上故障的时候能节省大量时间。6. 从“能跑”到“好维护”几个实战里反复遇到的坎6.1 服务安装成功但启动失败先看事件查看器场景很常见sc create成功sc start却提示“服务启动失败”。这个时候别瞎猜去事件查看器里看“Windows日志-系统”里面会有服务控制管理器记录的失败原因通常还会带上错误代码。很多错误代码一眼就能看出问题服务无法启动的常见原因我归纳过路径错误binPath写错了、依赖服务没启动、DLL缺失、OnStart里抛了未捕获异常、账户没有权限。其中OnStart抛异常这个场景代码里写得再小心也可能遇到因为可能在读取配置文件时就崩了。我的建议是OnStart的入口加一个全局try/catch如果异常确实发生写日志并重新抛出这样事件查看器里才能记录到更明确的错误信息。还有一点如果服务启动失败但事件查看器里什么日志都没有先把服务账户从LocalService换到LocalSystem再试一次。我排查过几次最后发现是账户访问某资源被拒但服务没有把异常投射到事件日志里换账户能快速定位是不是权限问题。6.2 OnStop之后进程不退出后台线程没有正确停止服务可以正常启动和运行但点击“停止服务”后服务管理器里状态却一直停留在“正在停止”再过一会儿变成“已停止”但进程还在任务管理器里。这个问题的根源是OnStop已经返回了但进程里的前台线程还没有退出进程无法结束。我见过几种导致这个问题的写法后台任务里有什么等待比如Task.Delay没传token、用了Thread.SleepThread.Sleep是一个不可取消的阻塞停止信号来了还在睡、外部库自己起了常驻线程没关闭。处理办法没有捷径只能把启动过的后台任务都纳入生命周期管理。用CancellationToken模式快进快出是一个基础方案。如果某一个第三方库始终无法优雅退出也可以考虑在OnStop里给进程一个强制结束的机会——但这是最后手段能不用就不用。6.3 定时任务的调度细节DateTime.Now和时区很多服务内部有定时调度逻辑。这里有个容易引发“服务偶尔出错”的问题你直接用DateTime.Now做日期判断到了夏令时调整地区或者系统时区误配时调度逻辑会偏移一小时。我自己的建议是所有调度计算基于DateTimeOffset或者使用系统UTC时间只在日志展示和业务展示时转换为本地时间。这个改动不大但对运行在跨国服务器、或者云上ECS实例上的服务来说能避免很多隐性bug。另一个定时相关的坑是“跳过”服务在计划执行时刻宕机了恢复之后任务没有补偿执行。如果业务允许建议在Worker里记录上一次成功执行的时间下次启动时做一次“补跑”判断。这个逻辑看起来简单但等到真实业务因为漏跑一次而出问题的时候再补就很被动。6.4 更新部署进程占用导致文件替换失败服务部署更新时经常出现“文件被另一个进程使用无法替换”。解决方法是标准流程先停止服务再删除或覆盖文件最后启动服务。这个流程要写成一个deploy脚本不要手工操作。我见过不止一次有人为了省事直接覆盖DLL覆盖完才发现服务还占用着旧文件最后导致副本混乱。如果服务停止后文件仍然被占用大概率是某个后台线程没有完全退出这时候排查点还是回到6.2。你可以用系统自带的资源监视器查看哪个进程占用了该文件通常就是服务进程本身。这种情况下快速恢复业务的做法是强制结束进程但这只能作为临时手段根治还要靠改进OnStop清理逻辑。服务程序部署还有一个经验版本号务必每次发布都递增。服务不像网页应用刷新一下就能看到新版本服务进程跑在系统后台版本信息只能靠程序集版本号和启动日志确认。我在每个服务启动日志里会打一行程序集版本号排查线上问题时非常有用。7. 写在最后服务程序的本质是管理好“生命周期”回头再看用C#写Windows服务程序这件事技术难点从来不是语言或框架本身而是你对服务生命周期有多深的理解。SCM管理着服务的启停ServiceBase的回调是我们的契约业务Worker是我们的灵魂日志和恢复策略是我们的保障。把这四层关系想清楚剩下的都是水到渠成。最后分享一个小技巧如果你在建服务项目时总是把“能不能在IDE里直接跑”当作第一需求那么这个项目后期迭代会舒服很多。给Program加一个控制台模式入口在日志里把服务名、版本号、启动参数都打全把sc failure配置提交到部署脚本里这些看起来微不足道的动作会在服务运行半年之后体现出巨大的价值。Windows服务是后台程序越是后台越要靠前期设计来保证可观测性和可维护性。