别把一次性任务写成常驻 Worker:从 .NET 托管服务的生命周期说起
不少 .NET 后台程序一开始都很像:从队列取一条消息、跑一次数据整理、执行一次同步,然后退出。于是很容易把它们统统塞进 BackgroundService,再写一个永不结束的循环。问题不在于这种写法能不能运行,而在于它是否准确表达了任务的生命周期。
Microsoft Learn 对几个相近概念做了明确区分:BackgroundService 是一个具体类型;Hosted Service 指 IHostedService 的实现;Worker Service 则是项目模板。把这些概念拆开后,设计会清晰许多:持续消费或定时轮询的程序可以是常驻托管服务;只需要执行一次的作业,则应让宿主在工作结束后退出。
先从模板看宿主到底做了什么
Worker 模板的核心很小:创建 HostApplicationBuilder,用 AddHostedService<Worker>() 注册服务,构建 IHost,最后调用 Run()。这意味着日志、配置和依赖注入都由通用宿主提供;Worker 并不是脱离宿主独立运行的线程。
ini
using App.WorkerService;
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Services.AddHostedService<Worker>();
IHost host = builder.Build();
host.Run();
模板中的 Worker 继承 BackgroundService,实现 ExecuteAsync(CancellationToken)。默认示例在取消请求到来之前循环、记录时间并等待一秒。这里的 stoppingToken 不是可有可无的参数:延迟、I/O 与下游调用都应尽量传递它,才能在宿主停止时及时收敛。
arduino
public sealed class Worker(ILogger<Worker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
logger.LogInformation("Worker running at: {time}", DateTimeOffset.Now);
await Task.Delay(1_000, stoppingToken);
}
}
}
常驻服务与一次性作业的分界
当程序的业务语义是"进程存活期间持续工作"时,循环是合理的。例如不断从队列读取工作项,或按固定间隔检查是否有待处理数据。这类代码需要把取消、异常边界、空闲等待和重试策略一并设计,而不是只写一个 while。
但一次性任务不同。假设容器启动后只需导入一批数据并结束,如果仍使用模板式循环,任务完成后宿主会继续存活。官方文档给出的做法是:在 ExecuteAsync 完成实际工作后,通过 IHostApplicationLifetime.StopApplication() 通知整个宿主停止。没有这一步,短任务的 Worker 会继续运行。
arduino
public sealed class Worker(
IHostApplicationLifetime lifetime,
ILogger<Worker> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
logger.LogInformation("Starting job at {Time}", DateTimeOffset.Now);
await RunImportAsync(stoppingToken);
lifetime.StopApplication();
}
private static Task RunImportAsync(CancellationToken stoppingToken)
=> Task.CompletedTask; // 替换为真实导入逻辑
}
上例只说明生命周期控制,RunImportAsync 未经运行验证。生产代码还应根据业务决定:失败时是否让异常向外冒泡、是否记录可重试状态、以及取消到来后是否需要完成当前事务。
另一种更贴切的写法:直接使用通用宿主
如果应用只有一次性作业,却仍想使用依赖注入、配置和日志,未必需要注册 BackgroundService。官方文档同时展示了直接构建通用宿主的方式:注册一个 JobRunner,从服务容器取出它并调用 RunAsync;成功返回 0,未处理异常时记录错误并返回 1。
ini
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton<JobRunner>();
using var host = builder.Build();
try
{
var runner = host.Services.GetRequiredService<JobRunner>();
await runner.RunAsync();
return 0;
}
catch (Exception ex)
{
var logger = host.Services.GetRequiredService<ILogger<Program>>();
logger.LogError(ex, "Unhandled exception occurred during job execution.");
return 1;
}
这不是"Worker 更高级"的替代品,而是把程序的真实语义直接写进入口:运行一个作业,然后结束。对于由容器任务、CI 作业或计划调度拉起的进程,这种结构通常更容易观察退出码,也更容易和调度器的重试策略配合。
覆盖生命周期方法时要保留基类行为
有时确实需要在启动或停止阶段加入逻辑,例如建立连接、停止接收新消息或刷新状态。IHostedService 定义了 StartAsync 和 StopAsync,它们分别对应宿主启动与停止。若重写 BackgroundService 的同名方法,官方文档特别提醒要调用并等待基类方法;否则服务本身的启动或停止流程可能不完整。
把这条规则落到代码评审上很简单:重写生命周期方法时,先明确你的附加逻辑放在基类调用之前还是之后,再确保 await base.StartAsync(...) 或 await base.StopAsync(...) 没有被遗漏。
关于服务器 GC:别把配置当成默认优化
Worker 模板默认不启用服务器 GC。官方说明给出的原因是,是否需要它取决于多个因素,长运行服务应评估性能影响,而不是因为"后台服务"就一律打开。需要启用时可以在项目文件中加入:
xml
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
这项设置应结合实际负载、内存限制和延迟要求验证;它不是替代对象生命周期管理或性能分析的开关。
一个实用的选择清单
- 任务要持续消费、轮询或定期执行:使用
BackgroundService,把取消令牌贯穿等待和 I/O。 - 任务完成后进程应退出:在 Worker 中显式调用
StopApplication(),或直接采用通用宿主加JobRunner。 - 要自定义启动、停止语义:理解
IHostedService的两个生命周期方法;重写基类方法时等待基类实现。 - 要调整 GC:先测量,再决定是否配置
ServerGarbageCollection。
关键不是选择"Worker"还是"普通控制台程序",而是让宿主生命周期、作业语义和部署方式一致。持续运行的服务要能优雅停止;一次性任务则要能明确结束。