线程安全与并发控制
这是「从零搭建工业控制系统」系列第22篇。前面讲了状态管理,这篇讲并发——工业软件有十几个后台服务同时跑,怎么保证不互相打架。
为什么工业软件特别需要线程安全
普通软件一个用户点一个按钮,大部分时间单线程。工业软件不一样:
- 后台轮询服务每500ms读一次Modbus
- 安全监控服务每200ms检查一次压力
- 追踪监控服务每100ms采集一次数据
- 命令执行器随时可能被触发
- UI线程随时响应操作员操作
这些线程都可能同时访问Modbus设备、状态对象、数据库。如果不做并发控制,轻则数据错乱,重则设备失控。

全局命令互斥闸门
最关键的并发控制:同一时间只允许一个命令执行。
为什么?因为Modbus设备是串行的——一个命令在写寄存器时,另一个命令不能同时写。如果两个命令同时发,设备可能收到混合的指令。
用 Interlocked.CompareExchange 实现无锁互斥:
private int _isBusy = 0;
public async Task<CommandResult> ExecuteAsync(Func<Task> action)
{
// 原子操作:0→1,如果已经是1则返回false
if (Interlocked.CompareExchange(ref _isBusy, 1, 0) != 0)
return CommandResult.Fail("BUSY", "有命令正在执行,请等待");
try
{
await action();
return CommandResult.Success();
}
finally
{
// 释放:1→0
Interlocked.Exchange(ref _isBusy, 0);
}
}
不用lock,不用Mutex,一个Interlocked搞定。性能好,不会死锁。
设备级锁:不同设备可以并行
全局闸门太粗了——开阀门A和读温度B互不干扰,为什么要互斥?
改进:按设备加锁。 同一设备的命令串行,不同设备的命令并行。
private readonly ConcurrentDictionary<string, SemaphoreSlim> _deviceLocks = new();
private SemaphoreSlim GetDeviceLock(string deviceKey)
{
return _deviceLocks.GetOrAdd(deviceKey, _ => new SemaphoreSlim(1, 1));
}
public async Task<CommandResult> ExecuteAsync(string deviceKey, Func<Task> action)
{
var lockObj = GetDeviceLock(deviceKey);
await lockObj.WaitAsync();
try
{
await action();
return CommandResult.Success();
}
finally
{
lockObj.Release();
}
}
DigitalIO、AnalogIO、ValveGroup三个设备各一把锁。开阀门(ValveGroup)和读温度(DigitalIO)可以同时执行。

AsyncLocal重入保护
有个场景:命令A执行过程中调用了命令B。如果用全局闸门,B会发现"有命令在执行"(就是A自己),然后拒绝执行。
这叫重入问题。同一线程内应该允许重入。
用 AsyncLocal<T> 解决——类似ThreadLocal但支持async/await:
private static readonly AsyncLocal<bool> _isInsideExecution = new();
public async Task<CommandResult> ExecuteAsync(Func<Task> action)
{
// 重入检查:如果当前已经在执行中,直接放行
if (_isInsideExecution.Value)
{
return await ExecuteInternalAsync(action);
}
// 非重入:走闸门
if (Interlocked.CompareExchange(ref _isBusy, 1, 0) != 0)
return CommandResult.Fail("BUSY", "有命令正在执行");
_isInsideExecution.Value = true;
try
{
return await ExecuteInternalAsync(action);
}
finally
{
_isInsideExecution.Value = false;
Interlocked.Exchange(ref _isBusy, 0);
}
}
AsyncLocal在async调用链中保持值。命令A设了 _isInsideExecution=true,A调用的B也能看到这个值,直接跳过闸门。

CancellationToken:协作式取消
所有命令都接受CancellationToken。操作员点"停止"后,不是杀线程,而是设置取消标志:
private CancellationTokenSource _cts;
public void Cancel()
{
_cts?.Cancel();
}
public async Task RunSequenceAsync()
{
_cts = new CancellationTokenSource();
var token = _cts.Token;
foreach (var step in steps)
{
token.ThrowIfCancellationRequested(); // 检查取消
await ExecuteStepAsync(step, token);
}
}
ThrowIfCancellationRequested 抛出 OperationCanceledException,调用方捕获后做清理。这是协作式取消——被取消的代码有机会做收尾,不是粗暴终止。

跨线程UI更新
后台线程不能直接更新UI属性。WPF要求UI元素只能被创建它的线程访问。
// 错误:后台线程直接赋值
_status.Pressure = newValue; // 可能抛InvalidOperationException
// 正确:切换到UI线程
Application.Current.Dispatcher.BeginInvoke(() =>
{
_status.Pressure = newValue;
});
BeginInvoke 是异步的——不等待UI线程处理完就返回。Invoke 是同步的——会阻塞调用线程直到UI处理完。优先用BeginInvoke,避免后台线程被UI阻塞。
踩坑记录
坑1:同步等待异步方法导致死锁
// 死锁代码
public void DoSomething()
{
ExecuteAsync().Wait(); // 同步等待异步方法
}
Wait() 阻塞当前线程,但异步方法完成后需要回到当前线程(SynchronizationContext),当前线程被阻塞了回不来——死锁。
解决办法:全链路异步(async/await一路到底),不要用 .Wait() 或 .Result。
坑2:锁粒度太粗
一开始用全局锁,所有命令串行。开阀门要等读温度完成才能执行,操作员觉得"反应慢"。改成设备级锁后,不同设备并行,响应快了。
坑3:AsyncLocal在Task.Run中丢失
Task.Run 创建的新Task不继承AsyncLocal的值。如果命令执行器内部用了Task.Run,重入保护失效。解决办法:避免在命令执行链中使用Task.Run,用 await 代替。
本篇小结
| 概念 | 关键做法 |
|---|---|
| 全局闸门 | Interlocked.CompareExchange无锁互斥 |
| 设备级锁 | 按设备分锁,不同设备并行 |
| 重入保护 | AsyncLocal允许同线程嵌套调用 |
| 协作式取消 | CancellationToken + ThrowIfCancellationRequested |
| 跨线程UI | Dispatcher.BeginInvoke异步切换 |
| 避免死锁 | 全链路async/await,不用Wait/Result |
并发控制的核心:互斥要精准——同设备串行,不同设备并行。重入要允许——同线程嵌套不锁。取消要协作——给被取消的代码收尾机会。
下期预告
第23篇:设备唤醒与重连机制
线程安全讲完了,下篇讲设备管理——设备睡眠了怎么唤醒,断线了怎么自动重连。
:线程安全与并发控制&spm=1001.2101.3001.5002&articleId=164153716&d=1&t=3&u=cb7b47f7376740788e6e683481fa8afe)
893

被折叠的 条评论
为什么被折叠?



