线程
1. 什么是「线程资源」?
线程 理解成一个「干活的工人」
那线程资源就是这个工人干活时用到的「工具、材料、场地」:
Task tCheck就是那个工人(后台采集线程),它要做的事是:循环读 Modbus 数据 → 更新 UI → 报警判断。- 它用到的「资源」包括:
CancellationTokenSource source:用来喊「停」的哨子_modbusHelper:串口通信工具serialPort:物理串口资源- 内存、CPU 时间片等系统资源
如果不释放线程资源:工人下班了还占着工具、场地,下次再喊他干活就没工具用了 → 表现为:串口被占用、程序关不掉、后台线程还在跑。
2. 什么是「线程控制」?
「线程控制」不是一个变量,而是一套控制线程「启动/暂停/停止」的逻辑,核心是:
- 让后台采集线程只在需要时运行(连接设备后启动)
- 让后台采集线程能被安全停止(断开连接/关闭程序时)
「线程控制」的关键部分:
csharp
Task tCheck = null; // 代表后台采集线程(工人)
CancellationTokenSource source; // 用来发「停止」信号的工具
CancellationToken token = source.Token; // 具体的「停止」信号
1. 变量声明(Form1 开头)
csharp
Task tCheck = null; // 后台采集线程(工人)
CancellationTokenSource source = new CancellationTokenSource(); // 哨子
bool isFirstRead = false; // 标记是否第一次采集
tCheck:用来引用后台线程,方便你知道线程是否在运行source:用来发停止信号,让线程安全退出isFirstRead:控制第一次采集时是否要读设备状态
2. 启动线程(StartMonitor 方法)
csharp
private void StartMonitor()
{
// 重置哨子(避免上次的信号干扰)
source = new CancellationTokenSource();
CancellationToken token = source.Token;
// 启动工人(后台线程)
tCheck = Task.Run(async () =>
{
// 循环:只要没收到停止信号,就一直采集
while (!token.IsCancellationRequested)
{
try
{
// 采集逻辑:读Modbus、更新UI、报警判断
await Task.Delay(1000, token); // 1秒采集一次,延时也会响应停止信号
}
catch (Exception ex)
{
// 采集失败时,延时后继续
await Task.Delay(1000, token);
}
}
}, token);
}
Task.Run(...):把采集逻辑放到后台线程,不卡 UIwhile (!token.IsCancellationRequested):线程的「主循环」,只要没收到停止信号就一直跑await Task.Delay(1000, token):既控制采集间隔,又能在延时期间响应停止信号
3. 停止线程(butConnect_Click 断开连接时)
csharp
if (_modbusHelper.IsOpen)
{
// 吹哨子:让后台线程停止
source.Cancel();
// 关闭串口,释放资源
_modbusHelper.Close();
// 重置状态
isFirstRead = false;
localDeviceRunning = false;
}
source.Cancel():给所有用这个source创建的token发「停止」信号- 后台线程里的
while (!token.IsCancellationRequested)会立刻变成false,循环结束,线程安全退出
三、为什么要这么设计?(避免踩坑)
1. 为什么不能直接 tCheck = null 停止线程?
- 线程是操作系统调度的,你把
tCheck = null只是丢掉了引用,工人还在后台干活 → 串口还占着、CPU 还在跑 - 必须用
source.Cancel()发信号,让线程自己主动结束循环,才是安全的
2. 为什么要 source.Dispose()?(在 Dispose 方法里)
csharp
protected override void Dispose(bool disposing)
{
if (disposing)
{
source?.Cancel(); // 先吹哨子
source?.Dispose(); // 再销毁哨子,释放资源
_modbusHelper?.Dispose();
components?.Dispose();
}
base.Dispose(disposing);
}
source本身也是一个「资源」,用完要销毁,避免内存泄漏- 顺序很重要:先
Cancel()让线程停,再Dispose()销毁哨子
终极正确顺序(Dispose 里必须这么来)
先 cts.Cancel() → 把后台采集循环立刻停掉
(循环一停,就不会再读串口、更 UI,从根源防报错)
再 SerialPort.Close() → 关闭串口硬件
最后释放其他资源
ModbusHelper.Dispose()
Timer.Dispose()
❌ 为什么不能先关串口、再 Cancel?
如果你先 Close() 串口,后台循环还在跑,它还会去读串口,直接报:
端口已关闭 / 访问已释放资源 错误!
先停循环 → 再关串口 → 再放资源 → 最后关窗体
绝对不允许直接关窗体,否则必报:跨线程错误、端口占用、内存泄漏。
标准 4 步流程
- 先取消后台循环(用CancellationTokenSource)
cts就是你的取消令牌,cts.Token是停止指示器
调用cts.Cancel(),立刻停止所有后台循环(数据采集、Modbus 轮询、UI 刷新)
作用:不让后台再碰 UI 控件,从根源杜绝跨线程崩溃 - 关闭串口硬件(SerialPort)
串口是硬件资源,必须先Close()
不 Close 就关窗体,直接报 "端口被占用",下次连不上 - 释放所有托管 / 非托管资源
Modbus 相关:ModbusHelper → Dispose()
定时器:Timer → Dispose()
其他后台对象:全部释放
作用:不占内存、不占线程、不留垃圾 - 最后关闭窗体
所有资源都放干净了,再关窗体,绝对安全
三、取消令牌大白话总结(你说得全对)
CancellationTokenSource(cts) = 遥控器
Token = 停止信号
Cancel() = 按 "关机键",直接断电停循环
3. CancellationTokenSource source 到底是什么?
这是 C# 里专门用来安全停止线程/异步任务的工具,你可以把它理解成:
一个「哨子 + 信号接收器」:
- 你吹哨子(
source.Cancel()),工人(线程)就收到「停止干活」的信号- 工人干活时会时不时摸一下口袋(
token.IsCancellationRequested),如果收到信号就立刻停手
核心用法(对应逻辑):
csharp
// 1. 创建哨子(初始化)
source = new CancellationTokenSource();
CancellationToken token = source.Token;
// 2. 让工人(线程)带着哨子干活
tCheck = Task.Run(async () =>
{
// 循环干活:只要没收到停止信号,就一直采集
while (!token.IsCancellationRequested)
{
// 读Modbus数据、更新UI...
await Task.Delay(1000, token); // 延时也会听哨子
}
}, token);
// 3. 吹哨子(停止线程)
source.Cancel();
四、总结成一句话
- 线程资源:后台线程干活用到的所有东西(串口、内存、信号工具等)
- 线程控制 :用
CancellationTokenSource这套工具,安全地让后台采集线程「启动/停止」 CancellationTokenSource source:就是那个「哨子」,用来喊后台线程「别干了,收工」
ModbusSerialHelper 与ModbusMaster
先明确:「两个对象的分工」
ModbusSerialHelper _modbusHelper = new ModbusSerialHelper(); 是封装的「Modbus通信容器」,这个类的核心职责就两个:
- 管串口连接 :打开/关闭串口、配置波特率/串口号、管理串口资源(对应
Open()/Close()/IsOpen); - 存核心通信工具 :把Modbus协议的「指令收发器」------
ModbusMaster,封装成自己的公共属性 (public IModbusMaster ModbusMaster { get; private set; })。
简单说:_modbusHelper 是**"通信终端机",ModbusMaster 是这台终端机里的"核心指令收发器"**------终端机负责连网线(串口),收发器负责发/收和设备的对话指令(读线圈、读寄存器、写指令)。
一、ModbusMaster (核心:所有Modbus读写操作,必须通过它执行!)
这是最关键的一点:_modbusHelper只负责「连串口」,但不会直接和设备发/收Modbus指令------真正执行「读线圈、读寄存器、写线圈、写寄存器」的,全是ModbusMaster!
代码里所有和设备的通信代码,底层都是调用ModbusMaster的方法,比如:
- 读设备启停状态(线圈1):
_modbusHelper.ModbusMaster.ReadCoilsAsync(...)→ 调用ModbusMaster的读线圈方法; - 读温度寄存器(寄存器1):
_modbusHelper.ModbusMaster.ReadHoldingRegistersAsync(...)→ 调用ModbusMaster的读保持寄存器方法; - 启动设备(写线圈1):
_modbusHelper.ModbusMaster.WriteSingleCoilAsync(...)→ 调用ModbusMaster的写单个线圈方法; - 写频率寄存器(寄存器20):
_modbusHelper.ModbusMaster.WriteMultipleRegistersAsync(...)→ 调用ModbusMaster的写多个寄存器方法。
👉 一句话总结:ModbusMaster是Modbus协议的「指令执行器」,没有它,你就算串口连成功了,也没法和设备说一句话(读/写全失效) ;而你封装的_modbusHelper,只是把这个"执行器"和"串口连接"打包在一起,方便管理而已。
二、为啥要调_modbusHelper.ModbusMaster这个属性?(因把"执行器"藏在"终端机"里了)
在ModbusSerialHelper类里,把ModbusMaster定义成了自己的私有赋值、公共读取的属性 (get; private set;):
csharp
// ModbusSerialHelper.cs里的代码
public IModbusMaster ModbusMaster { get; private set; }
private set:只有ModbusSerialHelper自己能给这个属性赋值(比如Open()方法里ModbusMaster = ModbusSerialMaster.CreateRtu(_serialPort);),外部(比如Form1)不能改;public get:外部(Form1)可以读取这个属性的值,拿到里面的"指令执行器"。
在Form1里调_modbusHelper.ModbusMaster,本质就是从封装的"通信终端机"里,把藏在里面的"指令执行器"拿出来用 ------不调这个属性,你根本拿不到ModbusMaster,自然没法和设备通信。
三、为啥要把_modbusHelper.ModbusMaster赋值给Form1里的master变量?(不是必须,但代码更简洁、维护更方便)
既然能直接用_modbusHelper.ModbusMaster.XXX(),为啥还要多一步master = _modbusHelper.ModbusMaster;?
答案:这步赋值「不是语法必须」,是「代码写法的优化」 ,完全可以删掉Form1里的master变量,所有地方都用_modbusHelper.ModbusMaster,功能完全一样!
比如代码里的:
csharp
// 赋值后用master
await master.ReadHoldingRegistersAsync(1,1,2);
// 不赋值,直接用_modbusHelper.ModbusMaster,效果完全一样
await _modbusHelper.ModbusMaster.ReadHoldingRegistersAsync(1,1,2);
那为啥要多这一步?两个实际好处(工业代码的常用写法):
1. 简化代码,少写重复字符
不用每次都写长长的_modbusHelper.ModbusMaster.XXX,直接写master.XXX,代码更短、更易读;
2. 降低耦合,方便后续修改
如果后续你想改ModbusSerialHelper里的属性名(比如把ModbusMaster改成Master),只需要改赋值那一行 (master = _modbusHelper.Master;),不用改Form1里所有的读写方法;
如果后续想换Modbus通信的封装类,也只需要改赋值行,不用动所有通信代码。
👉 简单说:Form1里的master变量,就是_modbusHelper.ModbusMaster的**"快捷方式"**,不用白不用,用了更方便。
总结:代码里的通信逻辑链(从新建对象到和设备对话)
新建_modbusHelper → 调用_modbusHelper.Open()连串口 → Open()里给_modbusHelper.ModbusMaster赋值(创建指令执行器) → Form1里读取_modbusHelper.ModbusMaster(拿出执行器)→ 要么直接用,要么赋值给master当快捷方式 → 通过ModbusMaster执行读/写指令,和设备通信。
所有环节的核心,都是为了拿到**ModbusMaster这个「指令执行器」------你的_modbusHelper只是它的"容器+连接管家"而已。
ModbusMaster就是你和设备通信的"唯一入口"**,其他都是为了让它能正常工作的辅助!