c# BackgroundService 和 IHostedService 的优雅关闭(Graceful Shutdown)

来源:这里教程网 时间:2026-02-21 17:40:37 作者:

BackgroundService.StopAsync 里的 cancellation token 是关键

它不是你传进来的那个

cancellationToken
,而是框架在调用
StopAsync
时注入的「关机超时令牌」。这个令牌会在主机关闭超时后被触发(默认 5 秒),和你在
StartAsync
中启动的长期任务所监听的
CancellationToken
不是同一个。

常见错误是直接在

StopAsync
里 await 长期任务而不做超时控制,导致关机卡住或被强制终止:

public override async Task StopAsync(CancellationToken cancellationToken)
{
    // ❌ 错误:await 一个没受 cancellationToken 约束的任务,可能永远不返回
    await _processingTask; // 如果 _processingTask 内部没响应取消,这里就挂住
    // ✅ 正确:用传入的 cancellationToken 做超时等待,并确保内部任务可取消
    await _processingTask.WaitAsync(cancellationToken); // .WaitAsync 是 Task 的扩展方法(需引用 Microsoft.Extensions.Tasks)
}

注意:

WaitAsync
Task
的扩展方法,来自
Microsoft.Extensions.Tasks
包,.NET 6+ 已内置;若用旧版需手动安装。

IHostedService 实现必须自己处理取消逻辑

BackgroundService
IHostedService
的封装,自动帮你管理生命周期和取消信号;但如果你直接实现
IHostedService
,就得手动把
StartAsync
StopAsync
CancellationToken
透传给所有异步工作流。

典型疏漏点:

StartAsync
启动
Task.Run
Task.Factory.StartNew
,却没把
cancellationToken
传进去
while (true)
轮询,但没在循环开头检查
cancellationToken.IsCancellationRequested
调用第三方异步方法(如
HttpClient.SendAsync
)时,忘了把
cancellationToken
传过去

示例中漏掉取消传播的写法:

public Task StartAsync(CancellationToken cancellationToken)
{
    _workerTask = Task.Run(() =>
    {
        while (true)
        {
            DoWork();
            Thread.Sleep(1000); // ❌ Sleep 不响应 cancellation
        }
    });
    return Task.CompletedTask;
}

应改为:

public Task StartAsync(CancellationToken cancellationToken)
{
    _workerTask = ExecuteLoopAsync(cancellationToken);
    return Task.CompletedTask;
}
private async Task ExecuteLoopAsync(CancellationToken cancellationToken)
{
    while (!cancellationToken.IsCancellationRequested)
    {
        await DoWorkAsync(cancellationToken); // ✅ 所有异步操作都接收并传递 cancellationToken
        await Task.Delay(1000, cancellationToken); // ✅ Delay 支持取消
    }
}

注册时别用 AddSingleton

虽然编译通过,但这是危险操作:

IHostedService
实例由主机在启动/停止时统一调度,如果注册为
Singleton
,且该服务又依赖了 Scoped 或 Transient 服务(比如
DbContext
ILogger<t></t>
),就会引发对象生命周期冲突——最常见的是
ObjectDisposedException
或日志丢失。

正确注册方式只有这一种:

services.AddHostedService<MyBackgroundService>();

它等价于:

services.AddSingleton<IHostedService, MyBackgroundService>(); // ❌ 表面一样,但 AddHostedService 内部做了额外校验和包装

但更安全的做法是显式使用

AddHostedService
,它会自动处理作用域上下文,在
StartAsync
中正确解析 Scoped 服务(通过
IServiceScopeFactory
创建新 scope)。

ShutdownTimeout 影响 StopAsync 的执行窗口

主机默认只给

StopAsync
5 秒时间完成清理。如果业务需要更久(比如要刷完缓存、发完最后一批消息),必须显式配置:

var host = Host.CreateDefaultBuilder(args)
    .ConfigureServices(services =>
    {
        services.AddHostedService<MyBackgroundService>();
    })
    .ConfigureWebHostDefaults(webBuilder =>
    {
        webBuilder.UseStartup<Startup>();
    })
    .UseShutdownTimeout(TimeSpan.FromSeconds(30)); // ✅ 设为 30 秒

这个设置影响所有

IHostedService
StopAsync
总体超时,不是单个服务的专属时间。所以多个后台服务共用这 30 秒,谁耗得久,谁就容易被截断。

另外,Kestrel 在收到 SIGTERM 后也会启动自己的 shutdown 流程,和

IHostedService
并行;如果 HTTP 请求还在处理中,它们不会被立即中断,但新请求会被拒绝。这意味着你的
StopAsync
里不该再发起新的 HTTP 调用(除非明确带超时且容忍失败)。

真正容易被忽略的是:

StopAsync
抛出异常会导致整个主机关机失败(表现为进程不退出、日志无后续),而这个异常往往被吞掉或只出现在 debug 输出里。务必在
StopAsync
外层加
try/catch
并记录日志。

相关推荐