ASP.NET Core BackgroundService 深度解析:设备后台任务到底应该怎么设计

ASP.NET Core BackgroundService 深度解析:设备后台任务到底应该怎么设计?

前言

前两篇分别介绍了:

  1. 为什么在 Linux ARM64 嵌入式设备上使用 ASP.NET Core
  2. 一个 HTTP Request 在 ASP.NET Core 内部到底经历了什么

这一篇开始进入真正的设备端工程设计。

在普通 Web 项目中,大部分业务通常由 HTTP Request 驱动:

text 复制代码
HTTP Request
    ↓
Controller / Minimal API
    ↓
Service
    ↓
Database
    ↓
HTTP Response

但工业设备完全不同。

即使一个浏览器都没有连接,设备仍然必须不断执行:

text 复制代码
读取硬件状态
检查网络连接
采集设备数据
刷新运行状态
执行状态机
检查故障
执行恢复
写入日志
监控系统资源

也就是说,我们需要一种:

与 HTTP Request 生命周期完全无关、跟随应用程序生命周期持续运行的后台服务。

ASP.NET Core 为此提供了:

text 复制代码
IHostedService
        ↓
BackgroundService

这一篇我们不仅介绍怎么写 BackgroundService,还会深入讨论:

text 复制代码
IHostedService
BackgroundService
ExecuteAsync
StartAsync
StopAsync
CancellationToken
async/await
ThreadPool
PeriodicTimer
异常传播
Service 生命周期
Snapshot
状态机
设备轮询
systemd

最后结合典型工业设备的数据采集和状态监控场景,设计一套比较合理的后台服务架构。


一、为什么设备状态查询不能全部放在 HTTP API 里

先看一个非常直观的实现。

假设我们提供接口:

text 复制代码
GET /api/device/status

为了获得设备状态,直接访问底层硬件:

csharp 复制代码
app.MapGet(
    "/api/device/status",
    async (
        DeviceClient device,
        CancellationToken cancellationToken) =>
    {
        var result =
            await device.ReadStatusAsync(
                cancellationToken);

        return Results.Ok(result);
    });

看起来很简单:

text 复制代码
Browser
   ↓
HTTP API
   ↓
DeviceClient
   ↓
Hardware
   ↓
Response

但这种设计隐藏着大量问题。


二、第一个问题:HTTP Request 被硬件速度控制

一个正常的内存查询可能只需要:

text 复制代码
几十微秒

数据库查询可能需要:

text 复制代码
几毫秒 ~ 几十毫秒

但硬件操作完全不一样。

一次设备查询可能需要:

text 复制代码
100 ms
500 ms
2 s
5 s

甚至:

text 复制代码
Timeout

于是 HTTP Request 生命周期变成:

text 复制代码
HTTP Request
    ↓
等待设备
    ↓
等待 I/O
    ↓
解析响应
    ↓
返回 HTTP

如果设备响应需要 5 秒:

text 复制代码
HTTP Request = 5 秒

这显然不是一个理想的 Web API。


三、第二个问题:HTTP 并发和硬件访问冲突

假设浏览器同时请求:

text 复制代码
/api/device/status

/api/device/temperature

/api/device/network

/api/device/diagnostics

这些请求可能同时访问:

text 复制代码
Serial Port
Socket
I2C
SPI
CAN
其他硬件接口

但很多设备通信链路本质上是串行的:

text 复制代码
Request
   ↓
Response
   ↓
Request
   ↓
Response

而不是:

text 复制代码
Request A ─────┐
Request B ─────┼──> Hardware
Request C ─────┘

否则非常容易出现:

text 复制代码
响应错位
资源竞争
Timeout
Parser 混乱
设备通信异常

最后不得不增加:

text 复制代码
Lock
Semaphore
Command Queue
Timeout
Cancellation
Concurrency Control

最终 HTTP API 被迫承担硬件通信调度职责。

这显然不是一个好的职责边界。


四、更合理的设计:设备主动运行,Web 被动读取

设备后台应该自己维护状态。

例如:

text 复制代码
                Hardware
                    ▲
                    │
                    │
             DeviceService
                    │
                    │ Update
                    ▼
            Runtime Snapshot
                    ▲
                    │ Read
                    │
Browser → REST API ─┘

后台服务持续执行:

text 复制代码
读取传感器
读取网络状态
读取系统状态
读取设备运行状态
检查故障

然后更新:

text 复制代码
DeviceStatusSnapshot

Web API 只是:

csharp 复制代码
app.MapGet(
    "/api/device/status",
    (DeviceStatusStore store) =>
    {
        return Results.Ok(store.Current);
    });

这时候 HTTP 请求路径变成:

text 复制代码
HTTP Request
    ↓
读取内存状态
    ↓
JSON Serialize
    ↓
HTTP Response

完全不需要直接等待硬件。

这就是 BackgroundService 在设备端非常重要的原因。


五、IHostedService 是什么

ASP.NET Core 后台服务最底层的抽象其实是:

csharp 复制代码
IHostedService

接口非常简单:

csharp 复制代码
public interface IHostedService
{
    Task StartAsync(
        CancellationToken cancellationToken);

    Task StopAsync(
        CancellationToken cancellationToken);
}

也就是说 Host 只要求后台服务提供:

text 复制代码
启动
停止

两个生命周期操作。

整个过程:

text 复制代码
Application Start
      │
      ▼
StartAsync()
      │
      ▼
Application Running
      │
      ▼
Shutdown
      │
      ▼
StopAsync()

这意味着 Hosted Service 的生命周期由:

text 复制代码
Generic Host

管理。


六、BackgroundService 又是什么

如果直接实现:

csharp 复制代码
IHostedService

我们需要自己维护后台 Task

例如:

csharp 复制代码
public sealed class Worker : IHostedService
{
    private Task? _task;
    private CancellationTokenSource? _cts;

    public Task StartAsync(
        CancellationToken cancellationToken)
    {
        _cts =
            CancellationTokenSource
                .CreateLinkedTokenSource(
                    cancellationToken);

        _task = RunAsync(_cts.Token);

        return Task.CompletedTask;
    }

    public async Task StopAsync(
        CancellationToken cancellationToken)
    {
        if (_cts is null)
            return;

        _cts.Cancel();

        if (_task is not null)
        {
            await _task;
        }
    }

    private async Task RunAsync(
        CancellationToken cancellationToken)
    {
        // Long-running work
    }
}

.NET 已经帮我们封装了这套逻辑。

于是就有:

csharp 复制代码
BackgroundService

它本身实现了:

text 复制代码
IHostedService

我们通常只需要实现:

csharp 复制代码
ExecuteAsync(...)

例如:

csharp 复制代码
public sealed class DeviceMonitorService
    : BackgroundService
{
    protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await CheckDeviceAsync(
                stoppingToken);

            await Task.Delay(
                TimeSpan.FromSeconds(5),
                stoppingToken);
        }
    }
}

七、BackgroundService 的生命周期

可以理解为:

text 复制代码
Host
 │
 │ Start
 ▼
BackgroundService.StartAsync()
 │
 ▼
ExecuteAsync()
 │
 │
 │ 长时间运行
 │
 ▼
Host Shutdown
 │
 ▼
Cancellation requested
 │
 ▼
ExecuteAsync() exit
 │
 ▼
StopAsync()

这里最重要的是:

ExecuteAsync() 返回的 Task,代表这个后台服务的整个生命周期。

所以它通常不会立即完成。

典型代码:

csharp 复制代码
protected override async Task ExecuteAsync(
    CancellationToken stoppingToken)
{
    while (!stoppingToken.IsCancellationRequested)
    {
        await DoWorkAsync(stoppingToken);
    }
}

只要应用还在运行:

text 复制代码
ExecuteAsync Task

就应该仍然存在。


八、不要写成 while(true)

一个很常见的错误:

csharp 复制代码
while (true)
{
    await PollAsync();

    await Task.Delay(1000);
}

问题在于:

text 复制代码
应用准备退出
      ↓
Host 请求停止
      ↓
BackgroundService
      ↓
while(true)
      ↓
继续运行

正确方式应该始终观察:

csharp 复制代码
CancellationToken

例如:

csharp 复制代码
while (!stoppingToken.IsCancellationRequested)
{
    ...
}

更重要的是:

csharp 复制代码
await PollAsync(stoppingToken);

下面每一级都应该尽量继续传递:

text 复制代码
ExecuteAsync
    ↓
PollAsync
    ↓
DeviceClient
    ↓
Stream / Socket
    ↓
I/O

例如:

csharp 复制代码
await _device.ReadAsync(
    stoppingToken);

九、CancellationToken 本质上不是"强制杀线程"

这是理解 BackgroundService 很重要的一点。

执行:

csharp 复制代码
cancellationTokenSource.Cancel();

并不会:

text 复制代码
强制杀死线程

它只是把:

text 复制代码
Cancellation Requested = true

传播出去。

代码必须主动观察。

例如:

csharp 复制代码
while (
    !stoppingToken.IsCancellationRequested)
{
    ...
}

或者:

csharp 复制代码
await Task.Delay(
    5000,
    stoppingToken);

或者底层:

csharp 复制代码
await stream.ReadAsync(
    buffer,
    stoppingToken);

所以取消实际上是一种:

Cooperative Cancellation------协作式取消。


十、为什么所有异步操作都应该传播 CancellationToken

假设最外层:

csharp 复制代码
ExecuteAsync(stoppingToken)

但是:

csharp 复制代码
await device.ReadStatusAsync();

里面又写:

csharp 复制代码
await stream.ReadAsync(buffer);

全部没有 Token。

那么即使:

text 复制代码
systemctl stop

Host 已经要求关闭,

后台 Service 仍可能卡在:

text 复制代码
ReadAsync

直到:

text 复制代码
Timeout

真正合理的链条:

text 复制代码
Host
 ↓
stoppingToken
 ↓
ExecuteAsync
 ↓
PollAsync
 ↓
ReadStatusAsync
 ↓
ReadAsync

这是一条完整的:

text 复制代码
Cancellation Chain

十一、最简单的设备轮询

最常见的代码是:

csharp 复制代码
protected override async Task ExecuteAsync(
    CancellationToken stoppingToken)
{
    while (!stoppingToken.IsCancellationRequested)
    {
        await PollAsync(stoppingToken);

        await Task.Delay(
            TimeSpan.FromSeconds(5),
            stoppingToken);
    }
}

假设:

text 复制代码
Poll = 2 秒
Delay = 5 秒

实际周期:

text 复制代码
2 秒工作
+
5 秒 Delay
=
7 秒

所以这种方式其实不是严格的:

text 复制代码
每 5 秒运行一次

而是:

每次工作完成之后,再等待 5 秒。


十二、Task.Delay 和 PeriodicTimer 的区别

如果只是:

一次工作完成后休息一段时间

那么:

csharp 复制代码
Task.Delay

完全没有问题。

如果语义更接近:

周期性调度

可以考虑:

csharp 复制代码
PeriodicTimer

例如:

csharp 复制代码
protected override async Task ExecuteAsync(
    CancellationToken stoppingToken)
{
    using var timer =
        new PeriodicTimer(
            TimeSpan.FromSeconds(5));

    while (
        await timer.WaitForNextTickAsync(
            stoppingToken))
    {
        await PollAsync(stoppingToken);
    }
}

这种写法非常适合:

text 复制代码
状态采集
健康检查
系统监控
周期性数据刷新

十三、PeriodicTimer 可以避免传统 Timer 的重入问题

考虑普通 Timer:

text 复制代码
0s
 │
 ▼
Poll A

5s
 │
 ▼
Poll B

假设:

text 复制代码
Poll A

异常慢,需要:

text 复制代码
8 秒

如果每 5 秒直接触发 callback:

text 复制代码
0s     Poll A ────────────── 8s

5s          Poll B ──────────────

两个 Poll 就可能重叠。

对于很多硬件资源来说,这非常危险。

而使用:

csharp 复制代码
await timer.WaitForNextTickAsync()

然后:

csharp 复制代码
await PollAsync()

可以形成:

text 复制代码
Tick
 ↓
Poll
 ↓
Poll 完成
 ↓
等待下一次 Tick

避免自己创建多个并发采集任务。


十四、不要为了"后台运行"使用 Task.Run

另一个常见误区:

csharp 复制代码
protected override Task ExecuteAsync(
    CancellationToken stoppingToken)
{
    return Task.Run(
        async () =>
        {
            while (...)
            {
                ...
            }
        });
}

通常没有必要。

因为:

text 复制代码
BackgroundService

本身就是用来承载长期后台任务的。

对于真正的异步 I/O:

csharp 复制代码
await stream.ReadAsync(...)
await socket.ReceiveAsync(...)
await Task.Delay(...)

通常不应该额外套一层:

csharp 复制代码
Task.Run

十五、async 不等于"占一个后台线程"

例如:

csharp 复制代码
await Task.Delay(5000);

并不是:

text 复制代码
某个线程 Sleep 5 秒

异步等待期间:

text 复制代码
线程可以返回 ThreadPool

等操作完成以后再调度 continuation。

因此:

text 复制代码
一个 BackgroundService

也并不意味着:

text 复制代码
永久占用一个专门的 OS Thread

尤其设备程序里,如果大量操作是:

text 复制代码
Serial I/O
Socket I/O
Timer
File I/O

合理使用 async/await 可以显著减少线程资源浪费。


十六、StartAsync、ExecuteAsync 和 StopAsync 应该怎么分工

可以这样理解。

StartAsync

负责:

text 复制代码
Host 启动阶段
必须完成的初始化

不要做无限循环。


ExecuteAsync

负责:

text 复制代码
长期运行任务
轮询
状态机
Monitor
Worker

StopAsync

负责:

text 复制代码
Graceful Shutdown
等待后台任务退出
释放资源

不要把正常业务长期逻辑放进:

text 复制代码
StartAsync

否则会阻塞 Host 启动。


十七、BackgroundService 最危险的问题之一:异常

看下面代码:

csharp 复制代码
protected override async Task ExecuteAsync(
    CancellationToken stoppingToken)
{
    while (!stoppingToken.IsCancellationRequested)
    {
        await PollAsync(stoppingToken);

        await Task.Delay(
            1000,
            stoppingToken);
    }
}

如果:

csharp 复制代码
PollAsync()

抛出:

text 复制代码
IOException

而没有被处理:

text 复制代码
ExecuteAsync
    ↓
Exception
    ↓
Task Faulted

后台服务结束。

这绝对不能理解为:

下次循环再试。

因为:

text 复制代码
已经没有下一次循环了。

十八、不要简单 catch(Exception) 然后继续

很多人看到前面的问题以后,会改成:

csharp 复制代码
while (...)
{
    try
    {
        await PollAsync(stoppingToken);
    }
    catch (Exception ex)
    {
        _logger.LogError(
            ex,
            "Poll failed");
    }

    await Task.Delay(
        1000,
        stoppingToken);
}

这虽然避免了 Worker 死亡,但又走到了另一个极端。

假设错误是:

text 复制代码
NullReferenceException
ObjectDisposedException
状态机内部状态损坏
程序逻辑 Bug

程序仍然:

text 复制代码
catch
 ↓
Log
 ↓
继续

这可能比直接失败更加危险。


十九、异常应该分类

设备程序至少应该区分:

text 复制代码
可恢复的运行时错误

和

不可恢复的程序错误

例如:

text 复制代码
TimeoutException
IOException
DeviceUnavailableException
NetworkUnavailableException

这些通常属于:

text 复制代码
Operational Failure

可以进入:

text 复制代码
Retry
Recovery
Reconnect

但是:

text 复制代码
NullReferenceException
内部状态断言失败
非法状态转换
程序逻辑错误

很多情况下代表:

text 复制代码
Programming Error
Invariant Broken

不应该无脑吞掉。

例如:

csharp 复制代码
try
{
    await PollAsync(stoppingToken);
}
catch (OperationCanceledException)
    when (stoppingToken.IsCancellationRequested)
{
    break;
}
catch (TimeoutException ex)
{
    HandleOperationalFailure(ex);
}
catch (IOException ex)
{
    HandleCommunicationFailure(ex);
}

其他异常:

text 复制代码
让它向上抛

由 Host 或 systemd 处理。


二十、OperationCanceledException 不应该被当成 Error

例如:

csharp 复制代码
await Task.Delay(
    TimeSpan.FromSeconds(10),
    stoppingToken);

执行:

text 复制代码
systemctl stop

以后:

text 复制代码
stoppingToken

会被取消。

此时可能抛出:

text 复制代码
OperationCanceledException

这不是:

text 复制代码
业务错误

而是:

text 复制代码
正常关闭流程

所以日志不应该记录成:

text 复制代码
ERROR

更合理:

csharp 复制代码
catch (OperationCanceledException)
    when (stoppingToken.IsCancellationRequested)
{
    _logger.LogInformation(
        "Device worker is stopping.");
}

二十一、设备状态机比"无限 Retry"更合理

真实设备不应该把所有错误都设计成:

text 复制代码
失败
 ↓
Delay
 ↓
Retry
 ↓
失败
 ↓
Retry

更合理的是:

text 复制代码
             Initializing
                  │
                  ▼
              Checking
                  │
                  ▼
              Running
                  │
          Failure│
                  ▼
              Recovering
             /    |    \
            /     |     \
           ▼      ▼      ▼
      Interface  Device  Network
                  │
                  ▼
               Running

严重错误
   │
   ▼
 Error

这意味着:

text 复制代码
BackgroundService

真正负责的是:

驱动状态机持续向前运行。

而不是自己堆大量:

text 复制代码
if
try
catch
retry

二十二、BackgroundService 和状态机应该分开

不建议:

csharp 复制代码
public class DeviceService
    : BackgroundService
{
    protected override async Task ExecuteAsync(...)
    {
        // 大量状态机逻辑
        // 通信逻辑
        // Recovery
        // Parser
        // 系统状态判断
    }
}

更加合理:

text 复制代码
DeviceBackgroundService
          │
          ▼
     DeviceWorkflow
          │
          ▼
    DeviceStateMachine
          │
     ┌────┼────┐
     ▼    ▼    ▼
 Device Network System

BackgroundService 只负责:

text 复制代码
Lifetime
Cancellation
Loop

状态机负责:

text 复制代码
State Transition
Recovery
Business Rules

底层负责:

text 复制代码
Serial
Socket
Linux
Hardware
Storage

二十三、例如这样设计

后台宿主:

csharp 复制代码
public sealed class DeviceHostedService
    : BackgroundService
{
    private readonly DeviceWorkflow _workflow;

    public DeviceHostedService(
        DeviceWorkflow workflow)
    {
        _workflow = workflow;
    }

    protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
    {
        await _workflow.RunAsync(
            stoppingToken);
    }
}

状态机:

csharp 复制代码
public sealed class DeviceWorkflow
{
    public async Task RunAsync(
        CancellationToken cancellationToken)
    {
        while (
            !cancellationToken
                .IsCancellationRequested)
        {
            await ExecuteCurrentStateAsync(
                cancellationToken);
        }
    }
}

于是:

text 复制代码
BackgroundService

不再关心具体:

text 复制代码
温度采集
串口协议
网卡状态
设备寄存器
系统文件

这些业务细节。


二十四、Web API 又应该和状态机分开

最终架构:

text 复制代码
                  ASP.NET Core Host
                         │
          ┌──────────────┴───────────────┐
          │                              │
          ▼                              ▼
       Web API                 DeviceHostedService
          │                              │
          ▼                              ▼
     Status Store                Device Workflow
          ▲                              │
          │                              ▼
          └──── Snapshot ───── Device State Machine
                                         │
                                ┌────────┼────────┐
                                ▼        ▼        ▼
                             Device   Network   System

Web API:

text 复制代码
读取状态

后台:

text 复制代码
产生状态

两边不会直接互相控制。


二十五、Snapshot 是设备 Web 中非常重要的设计

假设定义:

csharp 复制代码
public sealed record DeviceStatusSnapshot(
    string State,
    bool DeviceOnline,
    bool NetworkReady,
    double Temperature,
    DateTimeOffset UpdatedAt);

状态服务:

csharp 复制代码
public sealed class DeviceStatusStore
{
    private DeviceStatusSnapshot _current =
        new(
            "Initializing",
            false,
            false,
            0,
            DateTimeOffset.UtcNow);

    public DeviceStatusSnapshot Current =>
        Volatile.Read(ref _current);

    public void Update(
        DeviceStatusSnapshot snapshot)
    {
        Volatile.Write(
            ref _current,
            snapshot);
    }
}

后台:

text 复制代码
Device Service
     ↓
生成新 Snapshot
     ↓
Atomic Replace

Web:

text 复制代码
读取 Current

这种方式比多个共享 mutable property 更容易管理。


二十六、为什么 Immutable Snapshot 很适合设备状态

假设使用:

csharp 复制代码
class Status
{
    public bool Online;
    public bool NetworkReady;
    public double Temperature;
}

后台正在更新:

text 复制代码
Online = true

        ↓

NetworkReady = true

        ↓

Temperature = 42.1

与此同时 Web API 读取:

text 复制代码
Online = true
NetworkReady = false
Temperature = 0

可能得到:

一个从未真正存在过的中间状态。

而 Immutable Snapshot:

text 复制代码
Old Snapshot
      │
      ▼
构造 New Snapshot
      │
      ▼
一次替换引用

消费者看到的是:

text 复制代码
Old

或者:

text 复制代码
New

不会轻易看到更新一半的对象。


二十七、Snapshot 一定要有时间语义

设备状态非常容易出现:

text 复制代码
页面一直显示正常

但实际上设备已经:

text 复制代码
掉线
后台任务死亡
通信失败

因为:

text 复制代码
最后一次正常 Snapshot

仍然留在内存中。

所以状态至少应该携带:

text 复制代码
UpdatedAt

甚至:

text 复制代码
LastSuccessfulProbeAt

例如:

json 复制代码
{
  "state": "Running",
  "deviceOnline": true,
  "updatedAt": "2026-09-16T04:21:35Z"
}

前端可以判断:

text 复制代码
CurrentTime - UpdatedAt

是否超过有效期。


二十八、设备状态应该区分 Value 和 Freshness

例如:

text 复制代码
DeviceOnline = true

实际上只能说明:

最后一次成功观察时设备是在线的。

它并不能天然说明:

当前设备仍然在线。

因此更完整的状态应该包含:

text 复制代码
Value
+
ObservedAt
+
Freshness

比如:

json 复制代码
{
  "deviceOnline": true,
  "observedAt": "2026-09-16T04:21:35Z",
  "stale": false
}

如果后台失联:

text 复制代码
stale = true

而不是继续显示:

text 复制代码
Online

这对工业设备非常重要。


二十九、不要让多个 BackgroundService 同时直接访问同一个硬件资源

假设:

text 复制代码
StatusService
SensorService
DiagnosticsService
HealthService

全部直接操作:

text 复制代码
/dev/ttyS0

或者同一个 Socket。

架构看似模块化:

text 复制代码
Service A
Service B
Service C
Service D

但最后都指向:

text 复制代码
同一个物理资源

于是形成:

text 复制代码
        Status
           │
Sensor ────┼────── Serial Port
           │
Diagnostics
           │
         Health

这是典型的:

逻辑模块分开了,物理资源却没有统一所有权。

正确方式通常是:

text 复制代码
Status ──────┐
Sensor ──────┤
Diagnostics ─┼──> Device Session
Health ──────┘

让一个组件拥有:

text 复制代码
Serial Port / Socket / Device Handle

三十、Single Owner 原则

硬件资源最好满足:

text 复制代码
Single Owner

例如:

text 复制代码
DeviceSession
   │
   ├── Command Queue
   ├── Reader
   ├── Parser
   └── Timeout

其他业务 Service 不直接操作:

text 复制代码
SerialPort

而是:

text 复制代码
DeviceWorkflow
    ↓
IDeviceSession
    ↓
DeviceSession
    ↓
Serial / Socket

这样才能把:

text 复制代码
并发
超时
Reader 生命周期
关闭
重新打开
设备掉线

统一起来。


三十一、BackgroundService 里面可以直接注入 Scoped Service 吗?

需要特别小心。

Hosted Service 本身通常是长生命周期的。

例如:

text 复制代码
DbContext

一般属于 Scoped。

不要简单让一个长期运行的 Service 永久持有它。

可以使用:

csharp 复制代码
IServiceScopeFactory

例如:

csharp 复制代码
public sealed class Worker
    : BackgroundService
{
    private readonly IServiceScopeFactory
        _scopeFactory;

    public Worker(
        IServiceScopeFactory scopeFactory)
    {
        _scopeFactory = scopeFactory;
    }

    protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
    {
        while (
            !stoppingToken.IsCancellationRequested)
        {
            await using var scope =
                _scopeFactory
                    .CreateAsyncScope();

            var service =
                scope.ServiceProvider
                    .GetRequiredService<
                        MyScopedService>();

            await service.DoWorkAsync(
                stoppingToken);

            await Task.Delay(
                1000,
                stoppingToken);
        }
    }
}

三十二、设备 Runtime Service 往往更适合 Singleton

例如:

text 复制代码
Device Runtime
Configuration Store
Network State Store
Device Status Store

很多属于:

整台设备唯一的一份运行状态。

这种对象通常更接近:

csharp 复制代码
AddSingleton

例如:

csharp 复制代码
builder.Services
    .AddSingleton<DeviceRuntime>();

builder.Services
    .AddSingleton<DeviceStatusStore>();

builder.Services
    .AddHostedService<DeviceHostedService>();

形成:

text 复制代码
ASP.NET Core
    │
    ├── DeviceRuntime #1
    │
    ├── DeviceStatusStore #1
    │
    └── DeviceHostedService #1

三十三、Singleton 不代表线程安全

这是非常重要的一点。

csharp 复制代码
AddSingleton

只意味着:

text 复制代码
只有一个实例

并不意味着:

text 复制代码
线程安全

例如:

text 复制代码
BackgroundService
        │
        ▼
DeviceStatusStore
        ▲
        │
HTTP Request

已经至少存在:

text 复制代码
一个 Writer
多个 Reader

因此仍然必须考虑:

text 复制代码
Memory Visibility
Race Condition
Atomicity
Lock
Immutable Snapshot

这也是为什么前面推荐:

text 复制代码
Immutable Snapshot
+
Atomic Reference Replacement

三十四、多个 BackgroundService 之间不要随意共享可变字段

例如:

text 复制代码
DeviceService

NetworkService

SystemService

全部修改:

text 复制代码
DeviceState

很容易出现:

text 复制代码
谁拥有这个状态?

谁可以修改?

修改顺序是什么?

某一步更新失败怎么办?

更加合理:

text 复制代码
DeviceService
      ↓
DeviceSnapshot

NetworkService
      ↓
NetworkSnapshot

SystemService
      ↓
SystemSnapshot

最后由:

text 复制代码
Status Aggregator

组合:

text 复制代码
Overall Device Snapshot

三十五、健康检查不应该等于业务状态

比如:

text 复制代码
State = Running

不代表后台 Service 本身一定健康。

可以单独维护:

text 复制代码
ServiceHealth

例如:

text 复制代码
Running
LastLoopAt
LastSuccessAt
LastFailureAt
ConsecutiveFailures

这样 Web 页面不仅知道:

text 复制代码
设备状态

还知道:

text 复制代码
状态生产者是否还活着。

三十六、Monotonic Clock 和 Wall Clock 应该区分

如果用于展示:

text 复制代码
2026-09-16 12:35:21

应该使用:

text 复制代码
DateTimeOffset

因为这是:

text 复制代码
Wall Clock

但是如果用于:

text 复制代码
已经等待多久
Timeout
30 秒 Recovery Budget
500 ms Poll Interval

则不应该过度依赖系统时间。

因为设备时间可能被:

text 复制代码
NTP
手工设置
RTC 同步

调整。

用于 elapsed time 更适合:

text 复制代码
Stopwatch

或者:

text 复制代码
TimeProvider

提供的单调时间语义。

概念上:

text 复制代码
Wall Clock
    ↓
几点了?

Monotonic Clock
    ↓
过去多久了?

三十七、设备恢复应该有 Budget

不要:

text 复制代码
while(true)
{
    retry();
}

更合理:

text 复制代码
Recovery Episode
       │
       ├── Attempt 1
       │
       ├── Attempt 2
       │
       ├── Attempt 3
       │
       └── Budget Exhausted

例如:

text 复制代码
Interface Recovery
       ↓
Device Reconnect
       ↓
Network Recovery
       ↓
Subsystem Restart
       ↓
Error

这样系统才能回答:

text 复制代码
现在在恢复什么?

已经尝试几次?

为什么失败?

还能不能继续恢复?

三十八、不要把业务 Error 和进程 Failure 混在一起

这是设备软件尤其重要的一点。

状态机可能进入:

text 复制代码
Error

但:

text 复制代码
ASP.NET Core Process

仍然完全正常。

例如:

text 复制代码
DeviceState = Error

Web Server = Running

BackgroundService = Running

Process = Running

这时 systemd 看见的仍然是:

text 复制代码
active (running)

因此:

ini 复制代码
Restart=on-failure

不会因为你的业务状态:

text 复制代码
Error

自动重启程序。

因为 Linux 并不知道:

text 复制代码
你的内部状态机已经进入 Error。

三十九、建议明确区分三层 Failure

至少可以分为三层。

Level 1:Operational Failure

例如:

text 复制代码
传感器读取失败
网络临时断开
一次设备通信超时

由:

text 复制代码
Retry / State Recovery

负责。


Level 2:Subsystem Failure

例如:

text 复制代码
串口设备消失
Socket 长期不可用
底层设备需要重新连接

由:

text 复制代码
Subsystem Recovery

负责。


Level 3:Process Failure

例如:

text 复制代码
内部不变量被破坏
未处理异常
关键初始化失败

可以交给:

text 复制代码
Host
+
systemd

处理。

架构:

text 复制代码
Operational Failure
      ↓
State Recovery

Subsystem Failure
      ↓
Reconnect / Restart Subsystem

Process Failure
      ↓
Process Exit
      ↓
systemd

四十、systemd 不是业务 Recovery Engine

例如:

ini 复制代码
Restart=on-failure

不应该替代:

text 复制代码
重新连接设备
重新初始化接口
重新建立网络

因为每一个小故障都重启整个进程:

text 复制代码
设备超时
      ↓
Kill Process
      ↓
Restart ASP.NET
      ↓
重新加载配置
      ↓
重新初始化所有 Service

代价太大。

systemd 更适合:

最后的 Process Supervisor。

而不是:

第一层业务错误恢复机制。


四十一、推荐的完整分层

最终设备软件可以形成:

text 复制代码
┌─────────────────────────────────┐
│           systemd               │
│                                 │
│        Process Supervisor       │
└────────────────┬────────────────┘
                 │
                 ▼
┌─────────────────────────────────┐
│       ASP.NET Core Host         │
│                                 │
│ HTTP / DI / Logging / Lifetime  │
└────────────────┬────────────────┘
                 │
        ┌────────┴────────┐
        │                 │
        ▼                 ▼
      Web API      BackgroundService
        │                 │
        ▼                 ▼
     Snapshot         State Machine
                          │
                          ▼
                       Recovery
                          │
                   ┌──────┼──────┐
                   ▼      ▼      ▼
                Device  Network System

每一层都有自己的职责。


四十二、一个更加完整的 BackgroundService 示例

最后给一个更接近真实工程的结构:

csharp 复制代码
public sealed class DeviceHostedService
    : BackgroundService
{
    private readonly DeviceWorkflow _workflow;
    private readonly ILogger<DeviceHostedService>
        _logger;

    public DeviceHostedService(
        DeviceWorkflow workflow,
        ILogger<DeviceHostedService> logger)
    {
        _workflow = workflow;
        _logger = logger;
    }

    protected override async Task ExecuteAsync(
        CancellationToken stoppingToken)
    {
        _logger.LogInformation(
            "Device service started.");

        try
        {
            await _workflow.RunAsync(
                stoppingToken);
        }
        catch (OperationCanceledException)
            when (
                stoppingToken
                    .IsCancellationRequested)
        {
            // Normal shutdown
        }
        catch (Exception ex)
        {
            _logger.LogCritical(
                ex,
                "Device service terminated unexpectedly.");

            throw;
        }
        finally
        {
            _logger.LogInformation(
                "Device service stopped.");
        }
    }
}

注意这里没有:

text 复制代码
具体协议
具体设备寄存器
具体串口命令
具体网络接口

这些全部属于:

text 复制代码
DeviceWorkflow

或者更底层的:

text 复制代码
Infrastructure

而不是:

text 复制代码
BackgroundService

四十三、注册 Service

例如:

csharp 复制代码
builder.Services
    .AddSingleton<DeviceStatusStore>();

builder.Services
    .AddSingleton<DeviceSession>();

builder.Services
    .AddSingleton<DeviceWorkflow>();

builder.Services
    .AddHostedService<DeviceHostedService>();

然后:

csharp 复制代码
var app = builder.Build();

启动后:

text 复制代码
ASP.NET Core Host
        │
        ├── Kestrel
        │
        └── DeviceHostedService

两个生命周期由同一个:

text 复制代码
Generic Host

统一管理。


四十四、Web API

API 只读取:

csharp 复制代码
app.MapGet(
    "/api/device/status",
    (DeviceStatusStore store) =>
    {
        return Results.Ok(
            store.Current);
    });

因此 HTTP 路径非常简单:

text 复制代码
HTTP
 ↓
Status Store
 ↓
Snapshot
 ↓
JSON

而不是:

text 复制代码
HTTP
 ↓
Hardware I/O
 ↓
等待设备
 ↓
Timeout
 ↓
HTTP

四十五、整个运行模型

最后把所有内容串起来:

text 复制代码
                    Linux
                      │
                      ▼
                   systemd
                      │
                      ▼
              ASP.NET Core Host
                      │
        ┌─────────────┴─────────────┐
        │                           │
        ▼                           ▼
     Kestrel               BackgroundService
        │                           │
        ▼                           ▼
     Web API                 DeviceWorkflow
        │                           │
        │                           ▼
        │                      State Machine
        │                           │
        │                     ┌─────┼─────┐
        │                     ▼     ▼     ▼
        │                  Device Network System
        │
        │                  Update
        │                     │
        └────────────► Snapshot
                              ▲
                              │
                           Web Read

这是比较适合工业 Linux 设备的一种 ASP.NET Core 运行模型。


四十六、几个最重要的工程原则

如果把整篇文章压缩成几个原则,我认为最重要的是:

1. Web API 不要直接承担设备持续运行逻辑

Web:

text 复制代码
Read
Command
Configuration

后台:

text 复制代码
Monitor
State Machine
Recovery

职责分开。


2. CancellationToken 必须一直向下传播

text 复制代码
Host
 ↓
BackgroundService
 ↓
Workflow
 ↓
Device Session
 ↓
I/O

不要中途断掉。


3. 不要吞掉所有异常

区分:

text 复制代码
Operational Failure
Programming Error

能够恢复的:

text 复制代码
Recover

不能安全恢复的:

text 复制代码
Fail Fast

4. 硬件资源应该有明确 Owner

尤其:

text 复制代码
Serial Port
Socket
Device Handle

不要让多个 BackgroundService 同时直接访问。


5. Web 页面看到的设备状态应该是 Snapshot

最好包含:

text 复制代码
Value
ObservedAt
Freshness
State
Failure

而不是直接读取不断变化的内部对象。


6. Error 不等于 Process Failure

text 复制代码
业务 Error

由:

text 复制代码
State Machine

处理。

真正:

text 复制代码
Process Failure

再交给:

text 复制代码
systemd

总结

BackgroundService 表面上非常简单:

csharp 复制代码
protected override async Task ExecuteAsync(
    CancellationToken stoppingToken)
{
    while (
        !stoppingToken.IsCancellationRequested)
    {
        await DoWorkAsync(stoppingToken);
    }
}

但真实工程里真正困难的从来不是:

text 复制代码
怎么启动一个后台循环

而是:

text 复制代码
谁拥有资源?

任务什么时候停止?

取消如何传播?

异常如何分类?

失败在哪里恢复?

数据如何共享?

HTTP 和设备状态机如何解耦?

后台服务挂了以后谁负责?

状态过期以后页面怎么知道?

systemd 和应用恢复边界在哪里?

在普通 Web 后台,这些问题有时并不明显。

但在:

text 复制代码
ASP.NET Core
+
Linux ARM64
+
工业设备
+
硬件通信

这种架构下,它们直接决定了系统能不能长期稳定运行。

因此更准确地说,BackgroundService 可以理解为:

ASP.NET Core Host 与设备 Runtime 之间的生命周期桥梁。

它负责把设备的长期运行逻辑托管到 ASP.NET Core Host 中。

而真正的:

text 复制代码
设备状态机
硬件通信
状态采集
网络管理
故障恢复

则应该继续放在独立的业务层和基础设施层。

最终形成:

text 复制代码
systemd
   ↓
ASP.NET Core Host
   ↓
BackgroundService
   ↓
Workflow / State Machine
   ↓
Infrastructure
   ↓
Hardware

这才是一套真正能够用于工业设备的后台服务架构。


下一篇

下一篇继续往底层走:

ASP.NET Core Native AOT 到底是什么?从 C#、IL、JIT 一直讲到 Linux ARM64 ELF》

会重点讲清楚:

text 复制代码
C#
 ↓
Roslyn
 ↓
IL
 ↓
JIT / ReadyToRun / Native AOT
 ↓
Native Code
 ↓
Linux ARM64 ELF

以及:

text 复制代码
为什么 Native AOT 不需要目标机安装完整 .NET Runtime?

为什么反射会成为问题?

为什么 System.Text.Json 要 Source Generator?

为什么 ARM64 交叉编译需要 Toolchain?

Sysroot 到底是什么?

glibc 到底在哪里参与?

PublishAot 到底发生了什么?

这一篇会开始真正进入:

text 复制代码
.NET Runtime
编译器
Native AOT
Linux Toolchain
ARM64

这些更底层的内容。

相关推荐
j7~1 小时前
【Linux网络加餐课】(篇八)网络版计算器(终篇):守护进程、标准 IO 重定向与部署打包
linux·运维·网络编程·tcp·守护进程·io重定向·部署打包
Escalating_xu1 小时前
【Linux 多线程】自旋锁:从 atomic_flag 原子操作到 pthread_spin_* 与高竞争优化
linux·系统架构
嵌入式阿蔡1 小时前
CRA合规的工程化落地:SBOM、24小时响应与攻击面分析
嵌入式
kkkkkkkkkk_Z2 小时前
学嵌入式和Linux应用编程|学习日记:深入理解TCP协议与网络编程实战
linux·笔记·学习
kaoa0002 小时前
Linux入门攻坚——90、Hadoop-2-MapReduce计算框架及Hadoop生态系统概览
linux·hadoop·mapreduce
嵌入式阿蔡2 小时前
工业物理AI 2026:从巡检机器人到AI原生工厂
嵌入式
嵌入式阿蔡2 小时前
边缘AI芯片2026:SoC与协处理器的路线分化
嵌入式
The Chosen One9852 小时前
OS第二章随手记(2.1)
linux·运维·服务器·笔记
嵌入式阿蔡3 小时前
工业控制的国产化路径:从PLC替代到鸿蒙生态
嵌入式