用C#写Windows服务程序,这件事听起来像是一个“上古时代”的开发任务,但实际上只要你做过任何需要长时间后台运行、脱离用户会话独立工作的功能——比如数据同步、文件监控、定时报表推送——Windows服务就是绕不开的选项。C#在这方面成熟度极高,配套的框架和调试手段也很完善,今天这篇文章我就把从零手写一个服务程序的完整过程讲透,包括项目结构、生命周期、调试方式和部署细节,以及那些文档里不会明说的坑。先说清楚,这篇文章不打算只给你贴一段能跑的代码。代码本身很容易写,真正难的是理解服务程序和普通EXE的区别,以及在开发环境里怎么调、在生产环境里怎么管。我下面会从运行模型开始讲,再逐步落到实际项目操作,全程都是我验证过的做法。1. 服务程序为什么“看起来简单,跑起来玄学”:先理解生命周期1.1 服务程序不是双击启动的程序,它由服务控制管理器调度很多人第一次写Windows服务,会用写控制台程序的思路:Main函数里跑一个循环,任务处理完进程退出。这个思路从根上就是错的。Windows服务程序不是由用户直接启动的,而是由操作系统里的服务控制管理器(SCM,Service 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 ILogger<Worker> _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路径,start=auto表示自动启动启动服务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秒后重启;reset=86400表示如果服务正常运行超过一天,失败计数重置。这样最坏情况下服务不会一直无限重启,也给运维留了介入空间。还有一个和恢复策略配合的参数是服务依赖。如果你的服务依赖数据库、网络等组件,建议在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.Sleep(Thread.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服务是后台程序,越是后台,越要靠前期设计来保证可观测性和可维护性。
用C#写Windows服务程序,这件事听起来像是一个“上古时代”的开发任务,但实际上只要你做过任何需要长时间后台运行、脱离用户会话独立工作的功能——比如数据同步、文件监控、定时报表推送——Windows服务就是绕不开的选项。C#在这方面成熟度极高,配套的框架和调试手段也很完善,今天这篇文章我就把从零手写一个服务程序的完整过程讲透,包括项目结构、生命周期、调试方式和部署细节,以及那些文档里不会明说的坑。先说清楚,这篇文章不打算只给你贴一段能跑的代码。代码本身很容易写,真正难的是理解服务程序和普通EXE的区别,以及在开发环境里怎么调、在生产环境里怎么管。我下面会从运行模型开始讲,再逐步落到实际项目操作,全程都是我验证过的做法。1. 服务程序为什么“看起来简单,跑起来玄学”:先理解生命周期1.1 服务程序不是双击启动的程序,它由服务控制管理器调度很多人第一次写Windows服务,会用写控制台程序的思路:Main函数里跑一个循环,任务处理完进程退出。这个思路从根上就是错的。Windows服务程序不是由用户直接启动的,而是由操作系统里的服务控制管理器(SCM,Service 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 ILogger<Worker> _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路径,start=auto表示自动启动启动服务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秒后重启;reset=86400表示如果服务正常运行超过一天,失败计数重置。这样最坏情况下服务不会一直无限重启,也给运维留了介入空间。还有一个和恢复策略配合的参数是服务依赖。如果你的服务依赖数据库、网络等组件,建议在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.Sleep(Thread.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服务是后台程序,越是后台,越要靠前期设计来保证可观测性和可维护性。