C# 工控上位机系列(五):多线程入门!Thread 基础使用与线程安全实战 写在前面第四篇开的线程这篇给你讲透各位小伙伴上一篇我们讲了跨线程更新 UI里面随手就new Thread()开了个线程。但很多同学反馈线程到底怎么创建前台后台有啥区别多个线程同时改一个变量为什么结果不对今天这篇就把这些问题一次性讲清楚。从Thread 基础用法、线程生命周期到最核心的线程安全与 lock 锁全部配上可运行的代码和工控实战场景学完就能在项目里正确使用多线程。一、为什么上位机开发必须用多线程先看一个最常见的场景点击按钮后去读 PLC 数据读取需要 3 秒。private void btnRead_Click(object sender, EventArgs e) { lblStatus.Text 读取中...; var data ReadFromPlc(); // 耗时3秒UI线程被阻塞 lblStatus.Text 读取完成 data; }运行后你会发现界面直接卡死 3 秒按钮点不动窗口拖不动。因为 WinForms 的 UI 线程既要响应界面操作又要执行读取逻辑两者互斥。解决办法就是把耗时操作放到另一个线程里跑UI 线程只负责界面响应private void btnRead_Click(object sender, EventArgs e) { lblStatus.Text 读取中...; // 开新线程执行耗时操作 Thread t new Thread(() { var data ReadFromPlc(); // 跨线程更新UI上一篇学的Invoke this.Invoke(new Action(() lblStatus.Text 读取完成 data)); }); t.IsBackground true; t.Start(); }二、Thread 基础用法手把手2.1 创建与启动线程// 方式一传入方法名 Thread t1 new Thread(DoWork); t1.Start(); // 方式二Lambda 表达式最常用简洁 Thread t2 new Thread(() { Console.WriteLine(线程执行中); }); t2.Start(); void DoWork() { Console.WriteLine(线程执行中); }2.2 给线程传参数// 方式一ParameterizedThreadStart参数必须是object Thread t1 new Thread((obj) { int id (int)obj; Console.WriteLine($线程编号{id}); }); t1.Start(100); // 方式二Lambda 闭包推荐类型安全可传多个参数 int axisId 1; string axisName X轴; Thread t2 new Thread(() { Console.WriteLine($轴编号{axisId}轴名称{axisName}); }); t2.Start();2.3 前台线程 vs 后台线程类型默认值程序退出时的行为前台线程IsBackground false默认程序会等它执行完才退出后台线程IsBackground true程序退出时直接终止不等它上位机建议采集、通信这类工作线程一律设为后台线程IsBackground true否则关程序时可能因为前台线程没结束而进程残留。2.4 线程休眠与等待// Sleep当前线程暂停指定毫秒 Thread.Sleep(1000); // 暂停1秒 // Join等待另一个线程执行完再继续 Thread worker new Thread(DoWork); worker.Start(); worker.Join(); // 主线程在这里等worker跑完 Console.WriteLine(worker已完成);三、线程生命周期与状态一个线程从创建到结束会经历以下状态状态说明Unstarted已创建但未调用 Start()Running正在执行WaitSleepJoin调用了 Sleep / Join / 等待锁处于阻塞Stopped执行完毕正常结束Aborted被强制终止不推荐使用 Abort可以通过thread.ThreadState查看当前状态。实际开发中不用太纠结状态名记住Start 启动、Sleep 暂停、Join 等待、跑完自动结束就够了。四、线程安全多个线程同时改一个变量会怎样这是多线程最核心的问题。先看一段代码int count 0; void Increment() { for (int i 0; i 10000; i) { count; // 看似简单实际分三步读→加1→写回 } } // 开10个线程同时累加 Thread[] threads new Thread[10]; for (int i 0; i 10; i) { threads[i] new Thread(Increment); threads[i].Start(); } foreach (var t in threads) t.Join(); Console.WriteLine($期望结果100000实际结果{count}); // 实际结果几乎一定小于100000而且每次都不一样为什么结果不对因为count不是原子操作它实际分三步从内存读取 count 的值CPU 把值加 1把结果写回内存当两个线程同时执行时可能发生线程A读到 5线程B也读到 5各自加1后都写回 6——两次累加只生效了一次。这就是竞态条件Race Condition。五、lock 锁最基础的线程安全方案解决办法就是用lock把共享资源的访问包起来保证同一时刻只有一个线程能进入int count 0; // 锁对象private、readonly、引用类型不要用值类型 private readonly object _lockObj new object(); void Increment() { for (int i 0; i 10000; i) { lock (_lockObj) { count; // 同一时刻只有一个线程能执行这里 } } } // 这次结果一定是100000lock 的三个关键点锁对象要 private readonly不要用lock(this)或lock(typeof(类名))外部代码也可能锁同一个对象导致死锁。锁的粒度要合适锁太大影响性能锁太小保护不住。只锁必须保护的共享资源访问。锁内不要做耗时操作比如 lock 里调用 Sleep、网络请求会导致其他线程长时间等待。底层预告这里我们只讲 lock 的基础用法。lock 编译后到底变成了什么、为什么必须锁引用类型、死锁怎么产生和排查这些进阶内容我会在后面《线程同步深入》一篇里详细拆解先把基础用法练熟。六、工控实战多线程采集 安全写日志结合上位机场景两个轴同时采集数据都要写同一份日志文件用 lock 保证不冲突。public class AxisCollector { private readonly object _logLock new object(); private readonly StreamWriter _logWriter; public AxisCollector(string logPath) { _logWriter new StreamWriter(logPath, true); } // 采集线程入口 public void StartCollect(string axisName, int intervalMs) { Thread t new Thread(() { while (true) { double pos ReadAxisPosition(axisName); WriteLog($[{DateTime.Now:HH:mm:ss.fff}] {axisName} 位置{pos:F3}); Thread.Sleep(intervalMs); } }); t.IsBackground true; t.Start(); } // 线程安全的日志写入 private void WriteLog(string message) { lock (_logLock) { _logWriter.WriteLine(message); _logWriter.Flush(); } } private double ReadAxisPosition(string axisName) { // 模拟从运动控制卡读取位置 return new Random().NextDouble() * 100; } } // 使用同时启动X轴和Y轴采集 var collector new AxisCollector(axis_log.txt); collector.StartCollect(X轴, 100); collector.StartCollect(Y轴, 100);两个线程同时写日志如果没有 lock可能出现两行日志交错、文件损坏。加了 lock 后每次只有一个线程能写日志完整有序。七、新手高频易错点建议收藏闭包陷阱循环变量捕获for循环里开线程用循环变量所有线程可能拿到同一个最终值。解决在循环内用局部变量拷贝// 错误所有线程可能都打印5 for (int i 0; i 5; i) { new Thread(() Console.WriteLine(i)).Start(); } // 正确每次循环拷贝一份 for (int i 0; i 5; i) { int temp i; new Thread(() Console.WriteLine(temp)).Start(); }前台线程导致程序关不掉忘记设IsBackground true关闭窗口后进程还在任务管理器里残留。工作线程一律设后台。lock(this) 的隐患外部代码也可能 lock 你的实例对象造成意外死锁。始终用 private readonly 的锁对象。锁内做耗时操作lock 里 Sleep 或发网络请求其他线程全部阻塞性能暴跌。用 Abort 强制终止线程Thread.Abort()可能在任意时刻终止导致资源未释放、状态不一致。推荐用CancellationToken优雅停止后面异步篇会讲。八、本篇小结多线程让耗时操作不阻塞 UI是上位机开发的基本功。Thread 创建 Start 启动Lambda 闭包传参最方便。工作线程设IsBackground true避免程序退出后进程残留。多个线程同时改共享变量会产生竞态条件结果不可预期。lock是最基础的线程安全方案锁对象要 private readonly锁内不做耗时操作。闭包陷阱、前台线程残留、lock(this) 是新手三大坑。补充说明本篇讲的 Thread 是最底层的线程操作。.NET 还提供了更现代的Task async/await异步编程模型基于线程池、自动管理线程生命周期写起来更简洁。这个我们放到后面异步编程篇详细讲这里先把 Thread 底层吃透后面学 Task 才会真正理解它帮你省了什么。下一篇预告C# Timer 三大类对比上位机轮询到底该用哪个我会把 WinForms.Timer、System.Timers.Timer、Threading.Timer 的区别和适用场景一次讲透为后面的生产者-消费者模型打基础。