C# 发展到今天,多线程的写法也经历了几代演变。你不需要全都精通,但至少要知道它们是什么、什么时候用。
Thread 是 .NET 最底层的线程类,给你最大的控制权,也意味着你要自己管好一切。
|
1 2 3 |
Thread thread = new Thread(DoWork); thread.IsBackground = true; // 设为后台线程,程序退出时自动带走 thread.Start(parameter); |
适合什么场景?长时间运行的独立任务,比如一个持续监听硬件数据的采集循环。
缺点也很明显:线程创建和销毁开销大,数量一多系统调度压力就上来了。
既然创建线程贵,那我提前建好一批、用完放回去行不行?这就是线程池的思路。
|
1 2 3 4 |
ThreadPool.QueueUserWorkItem(state => { // 你的后台任务 }); |
适合短而频繁的小任务。但在上位机开发里用得不算多——因为我们的任务往往是"长驻"的。
Task 是 .NET 4.0 推出的 TPL(任务并行库)的核心,也是目前最主流的写法。
|
1 2 3 4 5 6 7 8 |
Task.Run(() => { // 后台干活 return result; }).ContinueWith(t => { // 干完了通知 UI }, TaskScheduler.FromCurrentSynchronizationContext()); |
它比 Thread 轻量,比 ThreadPool 灵活。绝大多数上位机场景,用 Task 就对了。
如果说 Task 是标配,那 async/await 就是"顶配"。它让异步代码写起来跟同步代码一样顺。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
private async void btnStart_Click(object sender, EventArgs e) { btnStart.Enabled = false; try { // 后台去读数据,UI 线程不阻塞 var data = await Task.Run(() => ReadDataFromDevice()); // await 之后自动回到 UI 线程,直接更新控件 txtResult.Text = data; } finally { btnStart.Enabled = true; } } |
注意看,这里没有 Invoke,没有跨线程异常——await 帮你把后续代码自动封送回了 UI 线程。
一句话总结:能写 async/await 就别写别的,代码干净、bug 少。
做上位机,第一个会撞上的墙就是——"线程间操作无效: 从不是创建控件的线程访问它"。
这条规则你得刻在脑子里:只有创建控件的线程,才能修改控件。
最朴素的写法,用 InvokeRequired 判断一下:
|
1 2 3 4 5 6 7 8 9 |
private void UpdateTextBox(string text) { if (txtLog.InvokeRequired) { txtLog.Invoke(new Action<string>(UpdateTextBox), text); return; } txtLog.AppendText(text + Environment.NewLine); } |
每个控件都写一遍太麻烦?封装个扩展方法一劳永逸:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
public static class ControlExtensions { public static void InvokeIfRequired(this Control c, Action action) { if (c.InvokeRequired) c.Invoke(action); else action(); } } // 用起来就一行 txtLog.InvokeIfRequired(() => txtLog.AppendText("收到数据啦")); |
WPF 用 Dispatcher,思路是一样的:
|
1 2 3 4 |
Application.Current.Dispatcher.Invoke(() => { txtResult.Text = "数据已更新"; }); |
如果用了 MVVM 模式,那就更省心了——INotifyPropertyChanged 的绑定机制会自动处理线程切换,你在后台线程改 ViewModel 属性就行。
理论说了一堆,不来点实际的怎么行。下面这三个场景,做上位机的大概率会遇到。
最常见的需求:打开串口,不停读数据,实时显示到界面上。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
private CancellationTokenSource _cts; private Task _readTask; private void btnStart_Click(object sender, EventArgs e) { _cts = new CancellationTokenSource(); _readTask = Task.Run(() => ReadLoop(_cts.Token)); btnStart.Enabled = false; btnStop.Enabled = true; } private void ReadLoop(CancellationToken token) { while (!token.IsCancellationRequested) { try { string data = serialPort.ReadLine(); txtLog.InvokeIfRequired(() => { txtLog.AppendText($"[{DateTime.Now:HH:mm:ss}] {data}\r\n"); }); } catch (Exception ex) { // 异常处理,别让线程崩了 } } } private void btnStop_Click(object sender, EventArgs e) { _cts?.Cancel(); btnStart.Enabled = true; btnStop.Enabled = false; } |
注意两个细节:
手上有五台设备要轮询,一台一台读太慢?并行走起:
|
1 2 3 4 5 6 7 8 9 10 |
private async Task<List<DeviceData>> ReadAllDevicesAsync(){ var tasks = new List<Task<DeviceData>>(); foreach (var device in _devices) { tasks.Add(Task.Run(() => device.ReadData())); } // 等所有设备都读完 var results = await Task.WhenAll(tasks); return results.ToList(); } |
Task.WhenAll 会等所有任务都完成,然后一次性给你结果。配合 async/await,代码干净得不像话。
数据采集速度快、处理速度慢,或者 UI 更新太频繁会卡?用队列做个缓冲。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// 线程安全的队列 private BlockingCollection<DataFrame> _dataQueue = new BlockingCollection<DataFrame>(); // 生产者:采集线程,只管往队列里塞 void ProducerLoop() { while (true) { var frame = ReadFrameFromHardware(); _dataQueue.Add(frame); } } // 消费者:处理线程,慢慢从队列里取 void ConsumerLoop() { foreach (var frame in _dataQueue.GetConsumingEnumerable()) { ProcessAndDisplay(frame); } } |
BlockingCollection 是个好东西——队列空的时候消费者自动阻塞,不占 CPU;有数据来了自动唤醒。做数据缓冲、日志写入都很好用。