C#多线程完全指南:从Thread到Task、async/await与线程安全实践
2026/9/10 3:27:03 网站建设 项目流程

从一次界面假死开始吧。当时我在做一个串口采集上位机,界面上放了一个“开始采集”按钮,后台要不断从串口读数据并在界面上实时画曲线。第一版代码很天真,直接在按钮的 Click 事件里写了一个 while 循环去读串口,结果一运行,窗口白屏,鼠标拖不动,点击没反应,直到串口数据排完才恢复。那是我第一次真正意识到:UI 线程和业务逻辑线程必须分清

后来做了几年 C# 开发,从 WinForm 到 WPF,再到 .NET 8 后端服务,发现“多线程”几乎是无处不在的必修课。无论是上位机、爬虫、批处理工具还是 Web API 高并发场景,最终都会撞上同一个主题:怎样让代码在多个线程上安全、高效、可控地跑。这篇就按我从入门到实践的真实路径来写,梳理 C# 多线程的完整知识链,重点放在“能用、够用、别翻车”上。适合刚学 C# 不久、正被多线程纠缠的开发者,也适合做过一段时间但总觉得云里雾里的朋友。

1. 从UI卡死事故说起:多线程究竟解决了什么问题

1.1 一次串口采集引发的假死

先还原一下开头的卡死场景。WinForms/WPF 的界面跑在一个UI 线程上,这个线程内部维护着一个消息循环,不断从消息队列里取“用户点击”“键盘输入”“系统重绘”等消息并交给对应的处理方法。当你在一个按钮事件里写了Thread.Sleep(5000)或者一个无限循环,UI 线程就被占住了,消息循环无法继续,于是窗口白屏、无法拖动、按钮无响应,表现出来就是“程序死了”。

那不是程序整体死亡,只是负责界面刷新的线程被堵住了,所以叫界面假死。如果把耗时操作放在一个单独的工作线程里跑,UI 线程就能继续处理用户输入和重绘事件,界面就不会卡顿。这是多线程的第一个价值:把阻塞 UI 的工作挪出去

我见过不少新手的做法是在while循环里加Application.DoEvents()强行让界面响应,这在短期内确实不卡,但它会引发重入问题——比如用户连点两次“开始采集”,可能开了两个循环同时读串口。正确做法永远是:UI 线程只做界面响应,耗时工作丢给后台线程

1.2 进程、线程和线程池的最小认知

聊多线程之前,得先把几个基本概念理清楚。

  • 进程:一个运行中的程序实例,拥有独立的内存空间。进程之间默认互不干扰。
  • 线程:进程内的执行流,同一个进程的多个线程共享内存空间,所以天然可以一起读写同一份数据。
  • 线程池:系统预先创建和维护的一组线程,有任务就分配线程去执行,执行完线程归还线程池。避免频繁创建/销毁线程的额外开销。

用一个生活类比:进程是一家餐厅,线程是餐厅里的服务员。服务员(线程)之间共享同一间仓库(内存),所以快速交流很方便,但也容易拿错东西(数据竞争)。线程池就像餐厅老板养着一批固定员工,客人(任务)来了就安排一个服务员去接待,接待完继续待命,而不是每个客人都临时招人。

1.3 多线程的价值与代价

价值说明典型场景
提升并行速度多核 CPU 上多个线程同时执行,把一个大任务拆成小任务并行处理批量处理文件、图片压缩、并行计算
避免阻塞耗时 IO 操作(串口、网络、磁盘)不占用 UI 线程,程序保持响应上位机数据采集、下载文件
提高吞吐量服务端同时处理多个请求,不互相等待Web API 并发请求、消息队列消费

但多线程不是没有代价。常见的三类问题:

  1. 竞态条件:多个线程同时读写同一份数据,结果取决于执行顺序,出现匪夷所思的结果。
  2. 死锁:两个线程各自占着一把锁,都在等对方释放,互相卡死。
  3. 调试难度:线程调度具有不确定性,同样的代码这次跑得好好的,下次就可能出问题。

多线程的核心哲学是:不追求让所有代码都跑多线程,而是只把真正需要多线程的地方多线程化,并且给共享数据立好规矩

2. Thread到Task的演进:为什么新代码我推荐直接上Task

2.1 Thread类与ThreadPool的原始用法

C# 最早提供的多线程工具是System.Threading.Thread

Thread t = new Thread(() => { for (int i = 0; i < 100; i++) { Console.WriteLine($"后台线程: {i}"); Thread.Sleep(100); } }); t.IsBackground = true; // 设为后台线程,主线程退出时自动结束 t.Start();

直接操作 Thread 的问题是:

  • 创建线程的开销较大(栈空间分配、内核对象创建等),大量短时任务频繁创建线程不划算。
  • 没有内置的结果返回机制,多个线程执行完要把结果汇总到共享变量,容易引发竞争。
  • 异常处理麻烦,线程内一旦抛出未处理异常,程序可能直接崩。

ThreadPool.QueueUserWorkItem是改进版,它让线程池来分配和复用线程:

ThreadPool.QueueUserWorkItem(state => { Console.WriteLine("在线程池线程中执行"); });

但 ThreadPool 提供的抽象仍然很低级:没有返回值、没有取消机制、没法知道一个任务何时完成。你必须在任务结束时手动设置一个ManualResetEvent之类的信号,写起来很别扭。

2.2 Task是对“异步操作”的抽象

Task在思想上把“一个要执行的异步操作”本身抽象成了对象。你不再关心它跑在哪个线程上,你只关心它什么时候完成、有没有返回值、能不能取消、出错了怎么处理。

Task<int> task = Task.Run(() => { // 模拟耗时计算 Thread.Sleep(1000); return 1 + 1; }); int result = await task; Console.WriteLine(result);

相比之下,Task 的天然优势:

  • 有返回值Task<T>可以直接拿到结果,省掉共享变量汇聚的麻烦。
  • 异常封装:任务内抛出的异常会被捕获并包装进AggregateException,你可以统一处理,不会直接把进程炸掉。
  • 取消机制:通过CancellationToken协作式取消。
  • 组合能力Task.WhenAllTask.WhenAnyTask.Run、连续执行,能轻松编排复杂异步流程。

注意:Task.Run默认在线程池线程上执行,所以它适合耗时但不算特别久的任务。如果你想写一个常驻后台的运行循环(比如上位机里一个每 50ms 读一次数据的采集线程),Task.Run 里跑一个无限 while 循环也可以,但一个线程池线程会被长期占用,如果这种任务很多,线程池可能需要不断补充线程,反而带来调度开销。

2.3 取消、异常与返回值:用代码对比

看一个实际例子:用 Task 处理一个文件夹里的 100 个文件,支持取消,并把每个文件的处理结果收集起来。

public async Task<List<string>> ProcessFilesAsync(string[] filePaths, CancellationToken ct) { var results = new List<string>(); foreach (var file in filePaths) { ct.ThrowIfCancellationRequested(); // 每次循环检查取消信号 string content = await File.ReadAllTextAsync(file, ct); // 异步IO,不占线程 string processed = ProcessContent(content); results.Add(processed); } return results; }

调用方:

using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30)); try { List<string> results = await ProcessFilesAsync(files, cts.Token); } catch (OperationCanceledException) { Console.WriteLine("任务已取消"); }

你会发现异常的捕获和处理都在调用方完成,不需要手工维护线程状态,代码可读性高了一个量级。这就是 Task 模型的核心价值——把异步调用的复杂度封装成了可组合的操作

2.4 哪些场景仍然需要线程

Task 这么好,是不是不再需要 Thread?也不是。下面这些场景我仍然会直接用 Thread:

  • 需要独立的前台线程,比如一个单独的控制台监听循环,要求主进程退出后仍然能跑一段时间做清理,就要设置IsBackground = false
  • 需要控制线程的优先级,比如实时采集线程设置Priority = Highest,但这种做法要非常谨慎,因为高优先级线程可能抢占 UI 线程导致界面卡顿。
  • 线程的栈大小有特殊要求(如深度递归),Thread构造函数可以指定栈大小。
  • 某些老库要求线程有固定名称(方便抓 dump 时识别),线程池线程不太适合改名。

大多数业务代码,用 Task 就够了。如果你的场景是持续运行的后台任务,我也会推荐用Task.Runwhile + CancellationToken,而不是直接裸写 Thread。这样至少能享受任务的取消和异常处理能力。

3. async/await的魔法与陷阱:状态机、同步上下文和async void

3.1 await不阻塞线程,那它在做什么

很多人对async/await最大的误解是“async 方法就是多线程方法”。实际上,async/await的核心是用同步的写法来表达异步流程,它本质上是一个状态机。

比如:

async Task<string> ReadFileAsync(string path) { string content = await File.ReadAllTextAsync(path); return content.ToUpper(); }

编译器会把这段代码改造成一个状态机。遇到await时,先把当前状态保存下来,然后真正异步的操作开始执行,当前线程立刻被释放(回到调用方继续处理其他事情)。等异步操作完成,状态机从上次离开的地方恢复继续执行。

关键在于 IO 操作(读文件、访问网络等)根本不占用线程,而是由操作系统硬件通知机制完成。await 不会创建线程,而是释放线程。这一点理解了,很多后续的坑都能避免。

3.2 同步上下文:UI线程不卡的关键

WinForms/WPF 的 UI 线程有一个SynchronizationContext(同步上下文),它负责把操作“投递”回 UI 线程的消息循环。当你在 UI 线程上执行:

private async void Button_Click(object sender, EventArgs e) { var data = await Task.Run(() => FetchDataFromSerialPort()); // 这里回到 UI 线程执行 textBox1.Text = data; }

await默认会捕获当前的 SynchronizationContext,在异步操作完成后,通过这个上下文把后续代码切回 UI 线程执行。所以textBox1.Text = data这段代码不需要你手动 Invoke,也不会引发跨线程异常。

这就是async/await方便之处:它能自动还你一个“原来的线程”。但这也带来了一个陷阱:如果你在 UI 线程上用了.Result.Wait()同步等待一个异步方法,UI 线程被阻塞了,而异步方法的后续需要回到 UI 线程执行,于是产生死锁。

3.3 async void是颗定时炸弹

async void是 C# 里一个历史遗留设计,它专门给事件处理器用:

private async void Button_Click(object sender, EventArgs e) { await DoSomethingAsync(); }

事件处理器不能返回Task,所以只能用async void。但async void有严重的副作用:方法内抛出的异常无法被捕获,会直接抛到 SynchronizationContext 上,导致程序崩溃。相比之下,async Task的异常会包装在 Task 中,可以用 await 捕获。

我的经验是:

  • UI 事件处理器可以用async void(这是唯一合法的场景),但方法体内必须用try-catch包住所有可能抛异常的地方。
  • 其他任何场景,一律不用async void。包括构造函数、属性 getter、Main 方法(Main 可以直接返回Task)。
  • 如果你需要在async void里做多步操作且担心异常,可以给整个方法体套一层辅助方法:await SafeExecuteAsync(),把异常处理集中起来。

3.4 死锁陷阱:同步等待异步代码

经典的死锁代码长这样:

public async Task<string> GetDataAsync() { await Task.Delay(1000); return "data"; } public string GetDataSync() { return GetDataAsync().Result; // 危险! }

在 WinForms/WPF 的 UI 线程上调用GetDataSync()

  1. UI 线程被.Result阻塞,不再处理消息。
  2. GetDataAsync执行到await Task.Delay,捕获了当前的 UI 同步上下文,等 Delay 完成后要回到 UI 线程继续执行。
  3. UI 线程没空,永远等不到它回去。
  4. .Result也永远等不到 Task 完成。
  5. 死锁。

解决办法有几个:

  • 一路使用async/await,不要同步阻塞。这是最推荐的方式。
  • 使用ConfigureAwait(false)await GetDataAsync().ConfigureAwait(false);,让异步方法的后续不在原上下文上执行,从而避免等待 UI 线程。但注意:这个做法适合库代码,WinForms/WPF 的调用方如果后续要操作 UI,仍然要回到 UI 线程,不能到处都用ConfigureAwait(false)

我个人的经验是:从上到下都别用.Result.Wait()。遇到需要同步调用的场景(比如实现第三方回调接口),把异步代码包在一个单独的方法里再用GetAwaiter().GetResult(),但这只是最后的妥协,不能成为习惯。

4. 共享状态攻防战:lock、Interlocked与volatile的真实边界

4.1 两个线程同时++,结果为什么不对

先看一个最经典的竞态示例:

int counter = 0; var tasks = new List<Task>(); for (int i = 0; i < 10; i++) { tasks.Add(Task.Run(() => { for (int j = 0; j < 10000; j++) { counter++; } })); } await Task.WhenAll(tasks); Console.WriteLine(counter); // 大概率不是 100000

counter++不是原子操作,它在底层拆成了三步:读取 counter、加 1、写回 counter。两个线程可能同时读到同一个旧值,各自加 1 后写回,结果只增加了一次。比如线程 A 读到 59,线程 B 也读到 59,两个都写回 60,最终 60 而不是 61。

解决方式有三种层级:

  1. 排他锁:一次只让一个线程进入临界区。
  2. 原子操作:让“读-改-写”在底层一步完成。
  3. 无共享设计:每个线程私有的数据,最后统一合并(比如 Parallel LINQ 的 Aggregation)。

4.2 lock的正确姿势:锁什么、为什么锁这个

C# 里最常用的锁是lock语法糖,它编译后是Monitor.Enter/Monitor.Exit

private readonly object _lockObj = new object(); private int SafeIncrement() { lock (_lockObj) { return ++counter; } }

关于锁的对象选择,有几点经验:

  • 不要锁字符串:字符串被 CLR 拘留(Intern),不同地方可能引用同一个字符串实例,导致无关代码互相阻塞。
  • 不要锁 this:类的实例可能被外部当作锁对象引用,锁语义不可控。
  • 不要锁类型对象lock(typeof(Foo))范围过大,容易造成全局阻塞。
  • 用私有 readonly object:这是最安全的做法,锁的边界清晰。

还要注意:lock 的粒度不能太大也不能太小。粒度太大,比如把整个方法体都锁住,并发性能直线下降;粒度太小,临界区出现竞态又难查。我的习惯是:只锁住需要保护的那两三行代码,不要在锁内做 IO 操作(读文件、网络请求、数据库查询)——IO 操作耗时长,持锁时间长会严重拖垮并发。

4.3 Interlocked:没有锁的原子操作

如果只是想对整数做加减或交换,Interlocked类提供了一组原子操作,性能远高于锁:

Interlocked.Increment(ref counter); // 原子 +1,返回新值 Interlocked.Decrement(ref counter); Interlocked.Add(ref counter, 5); Interlocked.Exchange(ref counter, 100); // 原子赋值 Interlocked.CompareExchange(ref counter, newValue, expectedValue); // 如果当前值=expectedValue,则替换为newValue

Interlocked在底层直接用 CPU 的原子指令(比如 x86 的 LOCK CMPXCHG),不需要进入内核态的锁,开销小得多。我在写高性能计数器、自旋标志位时都优先用它。

但要注意:Interlocked只能解决单一变量的原子更新,如果涉及多个变量的联合更新,或者“先判断再修改”的复杂逻辑,还是要用 lock。

4.4 volatile的真相与局限

C# 中的volatile关键字告诉编译器和 CPU:每次读写这个字段都从内存读取/写入,不要做缓存优化。它解决的是可见性问题——一个线程修改了字段,另一个线程能否立即看到。

volatile有一个极其容易误解的边界:它不解决原子性问题volatile int count并不能让count++变得安全,因为++仍然是读-改-写三步。

我遇到的真实案例:在生产者-消费者模型里,用volatile bool _isRunning作为退出标志是合理的:

private volatile bool _isRunning; private void WorkerLoop() { while (_isRunning) { // 处理数据 } }

但如果_isRunning变量只是简单布尔值,用volatile标志线程退出是可以的。更推荐的替代方案是用CancellationToken,它内部做了更完善的内存屏障处理,语义也更明确。

4.5 死锁的现场还原与规避策略

死锁的经典场景是“交叉等待锁”:

// 线程1 lock (lockA) { lock (lockB) { ... } } // 线程2 lock (lockB) { lock (lockA) { ... } }

线程 1 持有 lockA 等待 lockB,线程 2 持有 lockB 等待 lockA,谁也等不到谁。

死锁产生的四个必要条件:互斥、持有并等待、不可抢占、循环等待。规避的常见思路:

  • 统一锁的获取顺序:让所有线程都先拿 lockA 再拿 lockB,破坏循环等待。
  • 使用Monitor.TryEnter带超时:获取不到锁就退出,避免无限期等。
  • 减少锁的数量:能一把锁解决的不用两把。
  • 考虑用SemaphoreSlimReaderWriterLockSlim等更细粒度的同步原语。

实际排查时用 Visual Studio 的“并行堆栈”窗口,可以看到各线程阻塞在哪个栈帧上,死锁链路一目了然。这个我放在第 7 章实战部分展开。

5. 并发集合与生产者消费者:从BlockingCollection到Channel

5.1 为什么不直接用List和Dictionary

多个线程同时往List<T>里 Add,几乎必然出问题:可能抛ArgumentOutOfRangeExceptionIndexOutOfRangeException,甚至数据静默丢失。Dictionary在高并发写入时还可能破坏内部哈希桶结构,导致后续访问全乱。

.NET 提供了一组并发集合,专门为多线程读写设计:

集合适用场景
ConcurrentDictionary<TKey, TValue>高频键值对读写,用分段锁或无锁算法
ConcurrentQueue<T>先进先出队列,生产消费
ConcurrentBag<T>无序集合,适合每个线程独立生产但总体聚合
ConcurrentStack<T>后进先出栈
BlockingCollection<T>包装并发队列并提供阻塞消费、限流、取消
Channel<T>异步流式数据通道,性能高,支持 await

如果只是追加数据并且顺序不重要,可以考虑ConcurrentQueue而不是List加锁:

var queue = new ConcurrentQueue<string>(); queue.Enqueue("item1"); if (queue.TryDequeue(out var item)) { // 处理 item }

5.2 BlockingCollection:阻塞式生产者消费者

BlockingCollection<T>是我在上位机和批处理工具里用得最多的集合,它内部默认用ConcurrentQueue<T>,但增加了一个关键能力:队列为空时,消费者会被阻塞,直到有新元素队列达到上限时,生产者会被阻塞,实现背压(Backpressure)

using var collection = new BlockingCollection<int>(boundedCapacity: 100); // 生产者 var producer = Task.Run(() => { for (int i = 0; i < 1000; i++) { collection.Add(i); } collection.CompleteAdding(); // 标记不再添加 }); // 消费者 var consumer = Task.Run(() => { foreach (var item in collection.GetConsumingEnumerable()) { Console.WriteLine(item); } }); await Task.WhenAll(producer, consumer);

GetConsumingEnumerable()会一直迭代到CompleteAdding()被调用且队列清空为止。这样生产者和消费者解耦,消费者用多少速度取多少,不会拿空队列疯狂旋转 CPU。

5.3 Channel:异步时代的队列

System.Threading.Channels是 .NET Core 3.0 之后推荐的异步队列模型,特别适合流式数据处理。它比BlockingCollection更现代,天然支持async/await读取:

var channel = Channel.CreateUnbounded<int>(); var writer = channel.Writer; var reader = channel.Reader; // 生产者 await writer.WriteAsync(1); writer.Complete(); // 消费者 await foreach (var item in reader.ReadAllAsync()) { Console.WriteLine(item); }

Channel 还支持有界通道(CreateBounded),带容量限制,写入超出容量时会异步等待,天然限流。我用 Channel 替代 BlockingCollection 的场景是:生产者本身是async方法,比如从网络读取数据写入队列,WriteAsyncBlockingCollection.Add更契合异步模型。

5.4 场景:一个批量日志写入器

写一个大量日志时要批量落盘的小工具,能直观感受并行设计。

public class AsyncBatchLogger : IAsyncDisposable { private readonly Channel<string> _channel = Channel.CreateBounded<string>(10000); private readonly Task _writeTask; public AsyncBatchLogger() { _writeTask = Task.Run(() => ConsumeAsync()); } public void Log(string message) => _channel.Writer.TryWrite(message); private async Task ConsumeAsync() { await foreach (var log in _channel.Reader.ReadAllAsync()) { await File.AppendAllTextAsync("app.log", log + Environment.NewLine); } } public async ValueTask DisposeAsync() { _channel.Writer.Complete(); await _writeTask; } }

生产者在任意线程调用Log写入队列,消费者在后台统一消耗,做到高频日志写入不阻塞业务线程。这种“队列+单消费者”模式比在日志方法里直接加锁写文件要高效得多,也比每个线程各写各的文件更易维护。

6. 跨线程更新UI的正确姿势:Invoke、BeginInvoke与Progress

6.1 跨线程访问控件为什么会被骂

WinForms/WPF 的控件不是线程安全的。后台线程直接修改控件的Text属性,可能抛出InvalidOperationException,或者造成界面状态错乱。WPF 里更是严格,后台线程访问DependencyObject基本都会被拦截。

正确的做法是把控件的更新操作“投递”回 UI 线程再执行。

6.2 Invoke和BeginInvoke怎么选

WinForms 控件有InvokeBeginInvoke两个方法:

  • Invoke:同步等待,当前线程会阻塞,直到 UI 线程执行完委托。如果 UI 线程正忙,调用线程会卡住。
  • BeginInvoke:异步投递,当前线程不会等待,立刻返回。

在后台采集线程里更新 UI,我基本都用BeginInvoke,原因很简单:采集线程投递完 UI 更新操作后可以继续做下一轮采集,不会被 UI 的刷新节奏拖住。但要注意:如果投递速度远高于 UI 消费速度,消息队列会堆积,界面反而越来越卡。所以高频更新要做合并——比如只更新最新值,或者用计时器定期刷新。

WPF 里对应的是Dispatcher

Application.Current.Dispatcher.BeginInvoke(() => { textBox.Text = data; });

6.3 Progress :更干净的进度回传

Progress<T>是一个专门用来把后台进度回传到 UI 线程的类。它内部会捕获创建时的 SynchronizationContext,自动把回调投递到 UI 线程,不需要你手动 Invoke。

public async Task ProcessAsync(IProgress<int> progress, CancellationToken ct) { for (int i = 0; i <= 100; i++) { await Task.Delay(50, ct); progress?.Report(i); } } // 调用方(UI 线程) var progress = new Progress<int>(value => progressBar.Value = value); await ProcessAsync(progress, cts.Token);

Progress<T>最舒服的地方是:后台方法完全不需要知道 UI 的存在,回调方自己决定如何处理进度值。这层解耦让后台逻辑可以在控制台、测试、UI 三种环境复用。

6.4 一个带有取消和进度的完整示例

做个带进度条的上位机数据解析演示:

private CancellationTokenSource _cts; private async void btnStart_Click(object sender, EventArgs e) { _cts = new CancellationTokenSource(); var progress = new Progress<string>(line => { textBoxLog.AppendText(line + Environment.NewLine); }); try { await Task.Run(() => ReadAndProcessData(progress, _cts.Token)); } catch (OperationCanceledException) { textBoxLog.AppendText("已取消"); } finally { _cts.Dispose(); } } private void ReadAndProcessData(IProgress<string> progress, CancellationToken ct) { while (!ct.IsCancellationRequested) { string line = ReadFromSerialPort(); // 假设是阻塞读 progress.Report($"收到: {line}"); Thread.Sleep(100); // 模拟处理耗时 } } private void btnCancel_Click(object sender, EventArgs e) { _cts?.Cancel(); }

这里ReadFromSerialPort()是阻塞调用,所以放在Task.Run里,而进度通过IProgress<string>回传,UI 更新完全避免手工跨线程操作。取消通过CancellationTokenSource.Cancel()协作完成,不会强制掐断线程,给了代码安全清理的机会。

7. 实战复盘:一个上位机温度采集程序的完整多线程设计

7.1 需求与架构选型:三个线程角色

最后用一个我实际做过很多次的场景来做完整串联:一台设备通过串口每秒发送一次温度数据,程序要持续采集、实时显示曲线、把数据保存到本地 CSV 文件,并且响应“开始/停止”操作。

需求拆解后有三个并发角色:

  1. UI 线程:负责按钮事件、图表刷新、状态显示。
  2. 采集线程:循环读串口数据,把原始数据写入队列。
  3. 存储线程:从队列取数据,批量写入 CSV 文件。

如果不用队列,采集线程直接写文件,遇到文件 IO 延迟,下一轮串口数据可能丢失。队列在这里起的就是缓冲和解耦作用。存储线程慢一点没关系,队列只要不塞满,采集线程就不会丢数据。

这里我选择Channel<string>作为数据队列,因为数据量适中且消费端是异步写文件,很适合 Channel 的异步模型。

7.2 核心实现:队列、循环和UI回传

public partial class MainForm : Form { private CancellationTokenSource _cts; private Channel<string> _channel; private Task _collectTask; private Task _saveTask; private async void btnStart_Click(object sender, EventArgs e) { _cts = new CancellationTokenSource(); _channel = Channel.CreateBounded<string>(new BoundedChannelOptions(5000) { FullMode = BoundedChannelFullMode.Wait // 队列满时,写入方异步等待 }); var progress = new Progress<string>(line => chart1.AddLine(line)); _collectTask = Task.Run(() => CollectLoop(progress, _cts.Token)); _saveTask = Task.Run(() => SaveLoop(_cts.Token)); btnStart.Enabled = false; btnStop.Enabled = true; } private void CollectLoop(IProgress<string> progress, CancellationToken ct) { using var sp = new SerialPort("COM3", 115200); sp.Open(); while (!ct.IsCancellationRequested) { string line = sp.ReadLine(); // 阻塞读取 _channel.Writer.TryWrite(line); progress.Report(line); // 回传 UI 更新曲线 } } private async Task SaveLoop(CancellationToken ct) { await using var writer = new StreamWriter("temperature.csv", append: true); await foreach (var line in _channel.Reader.ReadAllAsync(ct)) { await writer.WriteLineAsync(line); } } private async void btnStop_Click(object sender, EventArgs e) { _cts?.Cancel(); _channel.Writer.Complete(); await Task.WhenAll(_collectTask, _saveTask); btnStart.Enabled = true; btnStop.Enabled = false; } }

有几个关键点值得说明:

  • Channel.CreateBoundedFullMode = Wait表示队列满时写入方等待,而不是丢弃数据,这保证串口数据的完整性。
  • SerialPort.ReadLine()是阻塞调用,放在Task.Run里不会卡 UI;停止时通过ct不能中断阻塞的ReadLine,所以btnStop里还要串口关闭,这在实际项目中要注意:停止事件里除了 Cancel,还要关掉串口让阻塞读取返回。
  • SaveLoopawait foreach异步消费,写文件时采集线程不会被阻塞。

7.3 退出时的线程清理

上位机最常见的坑是:关闭窗体时后台线程还在跑,程序无法退出,或者一退出就报 AccessViolation。关窗时要做三件事:

protected override void OnFormClosing(FormClosingEventArgs e) { if (_collectTask != null && !_collectTask.IsCompleted) { _cts.Cancel(); // 等待线程退出,可加超时,避免无限等待 Task.WaitAll(new[] { _collectTask, _saveTask }, TimeSpan.FromSeconds(3)); } base.OnFormClosing(e); }

注意:如果在 UI 线程调用Task.WaitAll等待后台任务,而后台任务正在等待 UI 线程回传进度,又可能死锁。所以我通常用TimeSpan.FromSeconds(3)超时,超时就放弃等待直接退出,避免关窗卡死。更好的做法是:把清理逻辑放进btnStop_Click,在正常停止流程里等待线程结束,而不是退出时才清理。

7.4 排错经验与工具

多线程问题不是每次都能靠读代码发现,有两类工具帮了我很多:

  1. Visual Studio 并行堆栈窗口:调试时选择“调试 → 窗口 → 并行任务”和“并行堆栈”,能看到每个线程/任务阻塞在哪个调用点。死锁排查时,两个线程互相等待锁的状态会直接显示出来。
  2. “线程”与“堆栈”窗口:查看每个线程的调用栈,定位阻塞点。

一个我常遇到的怪问题:程序运行几小时后,界面停止更新,CPU 占用飙升。最后定位到原因是采集线程里某个异常被 catch 后吞掉了,导致循环空转。排查思路是给线程循环体的异常加日志,把异常堆栈打出来,别空 catch。

所以我的经验是:多线程代码里,catch 的日志绝不能省,而且catch (Exception ex)后加一行Log.Error(ex)是底线,不要只写注释“忽略异常”。

7.5 几条压箱底的经验

最后分享几条我做多线程项目时沉淀下来的原则,不证明,只展示:

  • 优先用并发集合,而不是 List 加锁。集合层面的线程安全比你自己的双检查加锁可靠得多。
  • 能用 async/await 就不用手工建线程。它降低的心智负担不是一点点。
  • 共享数据的读写必须有统一规则:要么全加锁,要么全走队列,不能一会儿直接改一会儿又加锁。
  • 写日志是排查多线程问题的第一手段,但日志本身也要并发安全,最好走队列批量写。
  • 入口处能设置Thread.CurrentThread.Name就设置。抓 dump 时看到Thread 12和看到Thread "SerialCollectThread"是完全不同的排查体验。

多线程不是玄学,它是一门关于“协调”的工程学。把线程的角色划分清楚、把数据流和锁的边界定义好,大多数问题都不会发生。哪怕真的发生了,也一定有迹可循。希望这篇从界面卡死讲到完整采集架构的梳理,能让你对接下来的 C# 多线程之路少一些迷惑,多一分掌控感。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询