从零搭建工业控制系统(二十二):线程安全与并发控制

线程安全与并发控制

这是「从零搭建工业控制系统」系列第22篇。前面讲了状态管理,这篇讲并发------工业软件有十几个后台服务同时跑,怎么保证不互相打架。


为什么工业软件特别需要线程安全

普通软件一个用户点一个按钮,大部分时间单线程。工业软件不一样:

  • 后台轮询服务每500ms读一次Modbus
  • 安全监控服务每200ms检查一次压力
  • 追踪监控服务每100ms采集一次数据
  • 命令执行器随时可能被触发
  • UI线程随时响应操作员操作

这些线程都可能同时访问Modbus设备、状态对象、数据库。如果不做并发控制,轻则数据错乱,重则设备失控。


全局命令互斥闸门

最关键的并发控制:同一时间只允许一个命令执行。

为什么?因为Modbus设备是串行的------一个命令在写寄存器时,另一个命令不能同时写。如果两个命令同时发,设备可能收到混合的指令。

Interlocked.CompareExchange 实现无锁互斥:

csharp 复制代码
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互不干扰,为什么要互斥?

改进:按设备加锁。 同一设备的命令串行,不同设备的命令并行。

csharp 复制代码
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:

csharp 复制代码
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。操作员点"停止"后,不是杀线程,而是设置取消标志:

csharp 复制代码
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元素只能被创建它的线程访问。

csharp 复制代码
// 错误:后台线程直接赋值
_status.Pressure = newValue;  // 可能抛InvalidOperationException

// 正确:切换到UI线程
Application.Current.Dispatcher.BeginInvoke(() =>
{
    _status.Pressure = newValue;
});

BeginInvoke 是异步的------不等待UI线程处理完就返回。Invoke 是同步的------会阻塞调用线程直到UI处理完。优先用BeginInvoke,避免后台线程被UI阻塞。


踩坑记录

坑1:同步等待异步方法导致死锁

csharp 复制代码
// 死锁代码
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篇:设备唤醒与重连机制

线程安全讲完了,下篇讲设备管理------设备睡眠了怎么唤醒,断线了怎么自动重连。

相关推荐
姜穆澜2 小时前
OneID 从 0 到 1 完整生产案例(三)
大数据·wpf
UIU1142 小时前
scanf与cout的误区:探究其内部的机制
c++·学习·c#·scanf
风云3 小时前
MiniLog 1.0 GA 发布:零分配、零依赖的 .NET 高性能日志框架
开源·c#·.net·日志·高性能·minilog
慧都小妮子4 小时前
C# 工程图纸数字化入库实战:Aspose.Imaging 自动抠图、多页 TIFF 合卷与坏页容错
图像处理·c#·.net·aspose·tiff·归档·文档管理
dalong105 小时前
WPF:MVVM 示例
大数据·wpf
samble8 小时前
从零搭建工业控制系统(二十一):状态管理系统——中心状态对象设计
c#·wpf·mvvm·状态管理·工业控制
俊昭喜喜里10 小时前
C#中的func<>
java·前端·c#
highreport10 小时前
net报表工具对比:HighReport 与 FastReport
java·c#
reasonsummer10 小时前
【办公类-146-05】20260901《一分园、二分园四大教育》(优化版:标题excle+复制AI文字+Python占位符录入)
开发语言·数据库·c#