ASP.NET Core BackgroundService 深度解析:设备后台任务到底应该怎么设计?
前言
前两篇分别介绍了:
这一篇开始进入真正的设备端工程设计。
在普通 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
这些更底层的内容。