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

线程安全与并发控制

这是「从零搭建工业控制系统」系列第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篇:设备唤醒与重连机制

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

相关推荐
小羊没烦恼!5 天前
初探性能优化——2个月到4小时的性能提升
java·开发语言·windows·算法·c#
神仙别闹5 天前
基于C#+MySQL实现(WinForm)个人聊天室软件
c#
kybs19915 天前
全球灾害数据分析可视化 毕业设计-附源码66794
vue.js·spring boot·mysql·安全·django·c#·asp.net
Polevne5 天前
C# SFTP客户端实现文件传输
c#·ssh
samble5 天前
从零搭建灌装监控系统(四):深色主题与样式系统,ResourceDictionary 统一管理
c#·wpf·mvvm·样式·工业控制
_Jonas5 天前
.net framework开发环境正式环境配置文件的切换
.net·wpf
徐子童5 天前
介绍MVCC机制
java·mysql·面试题·秋招·并发·mvcc
cjp5605 天前
024 . WPF MVVM类封装优化
c#
淡海水6 天前
13-04-面试-源码级深度追问链
数据结构·unity·面试·c#·游戏引擎·源码·il2cpp
asdzx676 天前
如何使用 C# 提取 PPT 文本与图片
c#·ppt·提取文本