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

线程安全与并发控制

这是「从零搭建工业控制系统」系列第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)可以同时执行。

全局闸门 vs 设备级锁


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也能看到这个值,直接跳过闸门。

AsyncLocal重入保护


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
跨线程UIDispatcher.BeginInvoke异步切换
避免死锁全链路async/await,不用Wait/Result

并发控制的核心:互斥要精准——同设备串行,不同设备并行。重入要允许——同线程嵌套不锁。取消要协作——给被取消的代码收尾机会。


下期预告

第23篇:设备唤醒与重连机制

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值