你有没有遇到过这种情况:WinForms程序跑得好好的,点个按钮开始处理数据,窗口立刻变白,鼠标变转圈,标题栏上多出“未响应”三个字,过几秒甚至几十秒才缓过来。我最早做C#上位机的时候就吃过这个亏,串口数据一多、数据库查询一慢,界面直接卡死,用户在旁边干着急。后来才明白,这不是电脑太慢,而是你让UI线程干了不该它干的活。
这一篇是这个WinForms开发教程系列的第4篇,主题就是“后台运行”。说白了就一件事:把耗时的活儿从UI线程上挪走,让窗口保持流畅,任务跑完再把结果安全地交还给界面。这个需求有多普遍,看热搜词就懂了——C#上位机、串口通信、TCP断线重连、C#查询线程并中止线程,每一个都绕不开后台运行。这篇我会用完整可跑的代码,把BackgroundWorker、async/await、裸线程这三种主流方案讲透,顺便把跨线程访问UI、任务取消、超时控制、窗体关闭善后这些高频踩坑点一次讲清楚。
1. UI卡死的根源:WinForms的线程模型与消息循环
1.1 为什么一个循环能让整个窗口白屏
要理解后台运行,先得明白前台为什么会被卡住。WinForms的窗口不是“画”出来的,而是靠一个消息循环(Message Loop)驱动。鼠标点击、键盘输入、重绘请求、定时器触发,全部会变成一条条Windows消息,塞进当前线程的消息队列里。你的程序不断地从队列里取消息、处理消息、再取下一条,窗口才能正常响应。
这个循环跑在哪个线程上?答案就是UI线程,也就是调用Application.Run(new MainForm())的那个线程。所有控件都在这个线程上创建,所有控件的消息处理也都在这个线程上执行。
那问题就来了:如果你在按钮的Click事件里写了一个耗时的循环,比如
csharp复制for (int i = 0; i < 1000000; i++)
{
// 模拟耗时的数据解析
Thread.Sleep(10);
}
这段代码本身就运行在UI线程上。它一旦开始执行,消息循环就被占住了,没空去处理鼠标移动、窗口重绘这些消息。表现就是窗口白屏、无响应,操作系统甚至会弹出一个“程序未响应”的提示。
这里有个很关键的认知:卡顿不是CPU不够快,而是UI线程被你的业务代码堵死了。哪怕你的循环只有几秒钟,用户的感觉也像过了很久。后台运行的本质很简单——把这段耗时逻辑从UI线程上移出去,执行完了再回来更新界面。
1.2 线程亲和性:后台线程碰不得UI控件
既然要挪到后台线程,那后台线程执行完,怎么把结果交给界面?直接赋值行不行?不行,而且会抛异常。
WinForms里的控件是有线程亲和性的。一个控件在哪个线程上创建,就只能在哪个线程上访问。后台线程直接修改某个控件的属性,会得到经典的InvalidOperationException:
线程间操作无效:从不是创建控件“textBox1”的线程访问它。
这是WinForms特有的限制,根源在于Win32窗口句柄(Handle)本身就不是线程安全的。Windows的消息机制决定了,控件句柄只能由创建它的线程来发消息、收消息。其他线程强行访问,轻则数据不一致,重则直接让进程崩溃。
所以后台运行要解决的不只是“把任务搞到后台去”,还有一个更麻烦的问题:任务完成后,如何安全地回到UI线程更新界面。后面讲的三种方案,本质上都是在用不同的方式解决这两个问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四种后台方案怎么选:场景决定技术栈,不是越新越好
2.1 方案总览与适用场景对比
C#和.NET Framework环境下,WinForms实现后台运行主流有这几种路子:BackgroundWorker、Thread/ThreadPool、Task搭配async/await。我见过不少新手一上来就纠结选哪个,其实先想清楚自己的场景,答案就很明显。我把它们的基本情况整理成了一张表:
| 方案 | 代码复杂度 | 进度上报 | 取消支持 | 超时控制 | 适用场景 |
|---|---|---|---|---|---|
| BackgroundWorker | 低 | 原生支持 | 原生支持 | 需要自己写 | 经典WinForms,含进度条的批量任务 |
| Thread/ThreadPool | 中 | 需要Invoke | 需要协作标志位 | 需要自己写 | 持续运行的长任务、需要精确控制线程行为 |
| Task + async/await | 中 | 结合Progress |
CancellationToken | 原生支持 | 现代C#首选,串口通信、HTTP请求、业务并发 |
| Timer | 低 | 不需要 | 不可直接取消 | 支持Interval | 周期性的轻量操作,比如心跳、轮询 |
注意最后一行的Timer,它很特别。WinForms自带的System.Windows.Forms.Timer的事件也是跑在UI线程上的,所以它只适合做周期性的轻量操作。一旦事件里的逻辑本身很耗时,一样会卡界面。真正的心跳、轮询逻辑,如果要做网络操作,我建议仍然放到Task或后台线程里。
2.2 BackgroundWorker:进度上报型任务的稳妥之选
BackgroundWorker是.NET Framework专门为WinForms设计的一个组件,目的就是简化后台任务和UI交互。它在工具箱里直接拖就能用,设计初衷就是“在后台跑一个任务,顺便报进度,做完了通知你”。
它的核心优势在于三个事件已经帮你把线程切换的问题解决了。DoWork跑在后台线程,ProgressChanged自动回到UI线程,RunWorkerCompleted也自动回到UI线程。写起来非常直观,几乎不会出线程问题。
适合BackgroundWorker的场景有个明显特征:任务包含明确的进度百分比。比如批量导入Excel、压缩一批文件、下载资源包。这类任务的取消机制也很成熟,一个CancelAsync()就能安全地协作取消。
但它的缺点也很明显:代码结构偏老,继承的是事件驱动的老路子,没有await那么灵活。你没法在DoWork里写await等待一个异步IO,整个模型是同步的。现代开发里,如果只是单纯跑一次性任务,我更推荐用Task那套。
2.3 Task + async/await:现代C#的默认主力
从.NET Framework 4.5开始,async/await语法成为C#做异步和后台任务的首选。它的核心价值在于:写起来像同步代码,跑起来是异步的。没有回调地狱,也没有一堆事件飞来飞去。
Task.Run可以把一个同步方法丢到线程池去执行,await会立即把控制权交还给UI线程,让窗口保持响应。等后台任务跑完,代码会像“断点续传”一样,继续执行await后面的部分,而且自动回到UI线程。
这里有个幕后机制叫SynchronizationContext。在WinForms里,await捕获了当前上下文(UI上下文),任务完成后通过它把后续代码重新投递回UI线程。所以你在await后面直接操作控件,完全不会触发跨线程异常。这个机制是WinForms能优雅写异步代码的根本原因,比手动写Invoke省心太多。
加上CancellationTokenSource之后,取消、超时、组合任务这些能力天然就是完整的。后面的实战一和实战二会分别演示这两套方案,体验一下差异就知道取舍了。
2.4 裸线程:什么时候才需要自己控制
有些场景需要的是长驻后台的“守护型”任务,比如TCP心跳、设备状态轮询、串口持续监听的收包线程。这种任务生命周期长、没有明显的“执行完”的概念,用BackgroundWorker反而别扭,用Task也不是不行,但如果你需要精确控制线程的优先级、需要线程必须随着进程退出而结束,那直接new Thread反而更直接。
但裸线程有两个硬性要求:一是访问UI控件必须手动Invoke或BeginInvoke,二是必须处理好线程的结束条件,否则窗口关了线程还在跑,进程可能关不掉。这是新手最容易翻车的地方。我的建议是:能用Task就用Task,裸线程留给真正需要“长驻+独立栈”的场景。
3. 第一仗:用BackgroundWorker实现带进度条的批量任务
3.1 界面搭建与属性设置
先从最经典的BackgroundWorker开始。我看很多教程都是讲理论,这次直接给你一个可以跑的完整例子。模拟场景是:批量处理100个文件,每个文件耗时0.5秒,需要实时显示进度和当前状态,并且允许用户中途取消。
新建一个WinForms窗体,拖一个BackgroundWorker组件(在工具箱的“组件”栏里),再拖一个ProgressBar、两个Label、两个Button。一个按钮启动,一个按钮取消。
选中backgroundWorker1,在属性面板里把这两个属性设为true,它们默认是false,这是最容易漏掉的点:
WorkerReportsProgress = true:允许后台任务上报进度。不设这个,调用ReportProgress会抛异常。WorkerSupportsCancellation = true:允许取消协作者。不设这个,CancelAsync根本没有意义。
3.2 DoWork / ProgressChanged / RunWorkerCompleted 三个事件的分工
我给三个事件分别挂上处理逻辑,结构如下:
csharp复制private void btnStart_Click(object sender, EventArgs e)
{
if (backgroundWorker1.IsBusy)
{
MessageBox.Show("任务正在执行中,请稍候");
return;
}
btnStart.Enabled = false;
btnCancel.Enabled = true;
progressBar1.Value = 0;
backgroundWorker1.RunWorkerAsync();
}
private void backgroundWorker1_DoWork(object sender, DoWorkEventArgs e)
{
BackgroundWorker worker = (BackgroundWorker)sender;
for (int i = 1; i <= 100; i++)
{
if (worker.CancellationPending)
{
e.Cancel = true;
return;
}
// 模拟耗时步骤:读文件、压缩、数据库查询等
System.Threading.Thread.Sleep(50);
// 上报进度,第二个参数可以携带业务对象
worker.ReportProgress(i, $"正在处理第 {i} 项 / 共 100 项");
}
e.Result = "所有任务执行完毕";
}
private void backgroundWorker1_ProgressChanged(object sender, ProgressChangedEventArgs e)
{
progressBar1.Value = e.ProgressPercentage;
lblStatus.Text = e.UserState as string;
}
private void backgroundWorker1_RunWorkerCompleted(object sender, RunWorkerCompletedEventArgs e)
{
if (e.Cancelled)
{
lblStatus.Text = "任务已被用户取消";
}
else if (e.Error != null)
{
lblStatus.Text = "任务出错:" + e.Error.Message;
}
else
{
lblStatus.Text = e.Result as string;
}
btnStart.Enabled = true;
btnCancel.Enabled = false;
}
这哥们的分工特别清晰。DoWork里的代码跑在后台线程,绝不能碰UI控件,但可以调用ReportProgress。ProgressChanged跑在UI线程,可以放心更新控件。RunWorkerCompleted也跑在UI线程,用来做收尾工作。三个事件把“干活、报进度、收尾”三段拆得明明白白,这也是BackgroundWorker最舒服的地方。
有个细节:DoWork的sender参数其实就是backgroundWorker1。为什么我推荐写成BackgroundWorker worker = (BackgroundWorker)sender;而不是直接用外面的backgroundWorker1?因为这样这个方法的代码可以复用到多个BackgroundWorker上,也避免了一些跨线程引用产生的奇怪问题。
3.3 取消机制:CancellationPending 的正确用法
再来看取消。btnCancel里只需要一行:
csharp复制private void btnCancel_Click(object sender, EventArgs e)
{
backgroundWorker1.CancelAsync();
}
CancelAsync不会强制杀掉后台线程,它只是设置了一个标志位。后台线程在下一个合适的时机检查CancellationPending,然后自行决定退出。这是典型的协作式取消——线程不会被突然终结,业务代码可以安全地清理资源后退出。
所以你在循环里必须定期检查CancellationPending。如果你写了一个没有循环的长任务,比如一次超大的File.Copy,那取消不会立即生效,必须等到这段方法执行完返回后,线程才会发现“哦要取消”。这是协作式取消的通病,不是BackgroundWorker的问题。要打断正在阻塞的IO操作,得用带CancellationToken的IO重载,BackgroundWorker做不到这一点,这也是它相比Task方案的一个硬伤。
实际运行这个程序,你会看到进度条平滑增长,窗口随便拖动、缩放都不卡,点击取消之后,进度条会在下一次循环时停下来,状态变成“任务已被用户取消”。这就把后台运行的核心体验跑通了。
4. 第二仗:用async/await + CancellationToken实现可取消、可超时的后台任务
4.1 事件处理器为什么可以写成async void
接下来是更现代的写法。同样的窗体布局,这次不需要BackgroundWorker组件,全靠代码。按钮的Click事件我会改写成async:
csharp复制private async void btnStart_Click(object sender, EventArgs e)
{
// 核心业务代码见下一节
}
注意async void。很多C#教材会说“避免async void”,那是指普通方法里不要这么干,因为异常无法被捕获。但UI事件处理器是个例外——事件委托的签名本来就是void返回,没有地方让你返回Task,所以UI事件用async void是官方认可的写法。
但一定要记住:async void方法内部的异常不是不抛,而是会被抛到SynchronizationContext上,在WinForms里直接导致程序崩溃。所以事件处理器内部必须有完整、兜底的try/catch,一个异常都不能漏。
4.2 Task.Run 与 await 的完整实战代码
这次任务的逻辑和上一章一样:处理100项,每项睡50毫秒,上报进度,支持取消和超时。完整的事件代码如下:
csharp复制private CancellationTokenSource _cts;
private async void btnStart_Click(object sender, EventArgs e)
{
// 上一次任务如果还没结束,先取消它
if (_cts != null)
{
_cts.Cancel();
_cts.Dispose();
}
_cts = new CancellationTokenSource();
CancellationToken token = _cts.Token;
btnStart.Enabled = false;
btnCancel.Enabled = true;
progressBar1.Value = 0;
try
{
// Progress<T> 是在创建它的线程(UI线程)上回调
var progress = new Progress<int>(value =>
{
progressBar1.Value = value;
lblStatus.Text = $"正在处理第 {value} 项 / 共 100 项";
});
// Task.Run 把耗时逻辑丢到线程池
var result = await Task.Run(() =>
{
int total = 100;
for (int i = 1; i <= total; i++)
{
// 检查取消:抛 OperationCanceledException
token.ThrowIfCancellationRequested();
System.Threading.Thread.Sleep(50);
// 安全地上报进度
progress.Report(i);
}
return "所有任务执行完毕";
}, token);
lblStatus.Text = result;
}
catch (OperationCanceledException)
{
lblStatus.Text = "任务已被用户取消";
}
catch (Exception ex)
{
lblStatus.Text = "任务出错:" + ex.Message;
}
finally
{
btnStart.Enabled = true;
btnCancel.Enabled = false;
}
}
private void btnCancel_Click(object sender, EventArgs e)
{
_cts?.Cancel();
}
这里有两个关键点。
第一,Progress<T>。它的回调会被捕获到创建时的SynchronizationContext上执行。因为我是在UI线程上创建的Progress<int>,所以progress.Report(i)即便在后台线程调用,回调也一定回到UI线程执行。这比手动Invoke干净太多。
第二,token.ThrowIfCancellationRequested()。调用_cts.Cancel()后,这个方法会抛OperationCanceledException,被catch接住,完成取消。和BackgroundWorker的CancellationPending相比,它的粒度更细,而且能顺着调用栈往下传——你调用的IO方法如果支持CancellationToken,直接从外部传入token,真正实现“立刻停止IO操作”。
4.3 用CancelAfter实现超时控制
Task方案另一个强项是超时,一行代码:
csharp复制_cts = new CancellationTokenSource();
_cts.CancelAfter(TimeSpan.FromSeconds(10));
这会在10秒后自动触发取消。效果是:如果后台任务10秒内没跑完,就会抛OperationCanceledException,代码走到catch分支,提示“任务已被用户取消”。你可以在界面上加一个CheckBox,勾选“启用10秒超时”,模拟一下真实场景:
csharp复制if (chkTimeout.Checked)
{
_cts.CancelAfter(TimeSpan.FromSeconds(10));
}
这比BackgroundWorker需要自己写一个定时器来检测超时优雅得多。很多实际项目里的“超时”其实就是这么实现的——不是外部掐断连接,而是用CancellationToken协作性地通知任务该停了。
到这里能明显感受到这两代的差别:BackgroundWorker侧重于“一个后台任务+进度+取消”,Task方案则是一个完整的异步编程模型,把取消、超时、组合、异常处理全部统一了。我的建议是,新项目无脑用async/await这套,BackgroundWorker更多是维护老项目时才会碰。
5. 这些坑我基本都踩过一遍:跨线程、资源清理、异常的三类事故现场
5.1 跨线程访问控件的正确姿势与错误示范
很多人一遇到跨线程访问控件报错,第一反应是上网搜到一行代码,直接写在构造函数里:
csharp复制Control.CheckForIllegalCrossThreadCalls = false;
这行的意思是“忽略跨线程访问控件的非法性检查”。但我要明确说:这行代码是掩盖问题,不是解决问题。它只是让WinForms不再抛异常,后台线程依然在非线程安全地修改控件状态。你可能会遇到界面闪一下、值不对、偶尔崩一次这种诡异问题,排查起来比直接报错痛苦十倍。实测项目里我一直主张永远不要写这行代码。
正确的做法归纳下来就这么几条:
- 做法一:
Invoke同步调用。后台线程把一段委托投递到UI线程,阻塞等待它执行完。
csharp复制textBox1.Invoke(new Action(() =>
{
textBox1.AppendText("收到数据");
}));
- 做法二:
BeginInvoke异步调用。投递到UI线程后立刻返回,不等待执行结果。适合UI更新不要求实时、后台线程不想被阻塞的场景。
csharp复制textBox1.BeginInvoke(new Action(() =>
{
textBox1.AppendText("收到数据");
}));
-
做法三:用
SynchronizationContext。不直接依赖控件,适合在非UI类里使用。在UI线程上保存SynchronizationContext.Current,后台任务里调用context.Post(_ => { ... }, null)。 -
做法四:
async/await+Progress<T>。让框架帮你处理,这是最推荐的方式。上一章的代码就是这么干的。
5.2 后台任务里的异常会去哪里
后台线程里抛了异常,跟UI线程抛异常完全不是一回事。UI线程异常会被消息循环捕获,弹出错误提示,程序可能继续用。但后台线程的异常如果没有处理,默认行为是直接把进程干掉——连报错机会都没有,程序就像被“凭空消失”了一样。
BackgroundWorker的方案里,DoWork里的异常会被捕获,放进e.Error,在RunWorkerCompleted里通过e.Error != null判断。由于这两个事件都是框架帮你挂接的,异常反而被兜住了。Task方案里,await会重新抛出后台任务的异常,所以try/catch能直接抓住。但如果你用了async void事件处理器,千万记得catch一定要覆盖全部异常类型。
还有一种情况:你启动了一个Task,但从不await它,也不读它的Exception属性。这种“游离”的任务异常会触发TaskScheduler.UnobservedTaskException事件,并且可能导致进程崩溃。稳妥的做法是给全局挂一个兜底:
csharp复制TaskScheduler.UnobservedTaskException += (s, e) =>
{
// 记日志,避免进程被异常击穿
File.AppendAllText("task_error.log", e.Exception.ToString());
e.SetObserved();
};
这类异常处理经验,很多教程不会写,但线上环境吃过几次亏就能明白它的价值。
5.3 窗体关闭时,后台任务还在跑怎么办
最后一个高频坑:用户点击窗口右上角的“X”关闭窗体,但后台任务还活着。更麻烦的是,如果你在后台任务里还访问了已经销毁的控件,会抛ObjectDisposedException。
先明确一个底层规则:如果你用的是new Thread手动创建的线程,默认IsBackground = false,这会让进程在窗口关闭后依然存活。尤其做上位机程序时,用户关了窗口以为程序退出了,结果进程还在任务管理器里,串口还被占着,这就是典型的裸线程引发的问题。自己创建的线程,在启动后最好显式设置:
csharp复制Thread t = new Thread(WorkerMethod)
{
IsBackground = true
};
t.Start();
线程池线程和Task里的线程默认都是后台线程,这倒是让人省心的。
不管哪种方案,窗体关闭前都应该礼貌地通知后台任务结束,留一个干净的收尾过程。在FormClosing事件里可以这样:
csharp复制protected override void OnFormClosing(FormClosingEventArgs e)
{
if (_cts != null)
{
_cts.Cancel();
_cts.Dispose();
_cts = null;
}
base.OnFormClosing(e);
}
简单的任务,取消后它很快就会结束。拖后腿的情况来自于一个很常见的错误思路——在FormClosing里用Thread.Sleep或者while (task.IsBusy)死等任务完成。这会再次把UI线程堵死,窗口还是关不掉。正确的做法是:如果任务不允许丢失,用e.Cancel = true阻止关闭,异步等任务结束后再真正关闭;如果任务允许丢弃(比如日志落盘),直接设IsBackground = true,取消后随进程退出即可。
6. 上位机场景的延伸:串口接收、TCP心跳、日志队列的通用后台模式
6.1 串口DataReceived事件里的Invoke写法
C#上位机开发是WinForms后台运行的典型应用场景。串口通信时,SerialPort的DataReceived事件是在一个后台线程上触发的。这意味着你在事件里接收到的数据,不能直接往TextBox里写。最基础的写法是:
csharp复制serialPort1.DataReceived += (s, e) =>
{
string data = serialPort1.ReadExisting();
if (textBox1.IsHandleCreated)
{
textBox1.BeginInvoke(new Action(() =>
{
textBox1.AppendText(data);
}));
}
};
这里判断IsHandleCreated是因为:窗体还没创建完成或已经销毁时,控件的句柄可能不可用,直接BeginInvoke会抛异常。这是串口接收最常见的崩溃点之一。
如果你用的现代写法,串口那边配合async/await也很方便。把数据解析逻辑放到Task里,解析完再用Progress<string>丢回UI线程,整个接收流程会清爽很多。
6.2 TCP断线自动重连的心跳线程
上位机项目中TCP通信的断线重连,后台运行逻辑一般分两层:一层是心跳循环,定时发一个心跳包;另一层是断线检测,超时没响应就触发重连。心跳循环非常适合写成带取消标记的Task循环:
csharp复制private async Task SendHeartbeatLoopAsync(TcpClient client, CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
NetworkStream stream = client.GetStream();
byte[] buffer = Encoding.UTF8.GetBytes("{\"type\":\"heartbeat\"}");
await stream.WriteAsync(buffer, 0, buffer.Length, token);
}
catch (OperationCanceledException)
{
// 正常取消,不做处理
break;
}
catch (Exception ex)
{
// 连接断开,触发重连逻辑
RaiseDisconnected(ex);
break;
}
await Task.Delay(TimeSpan.FromSeconds(5), token);
}
}
这个循环有两个精妙之处。一是await stream.WriteAsync(..., token)把网络IO异步化,不会阻塞线程池线程。二是await Task.Delay(TimeSpan.FromSeconds(5), token)把间隔等待也做成可取消的——窗体关闭时,Cancel之后这个等待立刻结束,循环退出,任务干净释放。如果你用Thread.Sleep做延迟,取消后还得傻等5秒,线程才醒过来检查标志位。
配合重连,启动逻辑通常是这样的:
csharp复制private CancellationTokenSource _heartbeatCts;
private void StartHeartbeat()
{
StopHeartbeat();
_heartbeatCts = new CancellationTokenSource();
// 不 await,让心跳在后台独立运行
_ = SendHeartbeatLoopAsync(_client, _heartbeatCts.Token);
}
private void StopHeartbeat()
{
if (_heartbeatCts != null)
{
_heartbeatCts.Cancel();
_heartbeatCts.Dispose();
_heartbeatCts = null;
}
}
这里用_ =丢弃Task,但内部已经有完整的try/catch兜底,所以不会出现无主任务异常。
6.3 用生产者消费者队列做后台日志落盘
做上位机或者服务端程序,日志写文件是最吃性能的操作之一。如果每次收到数据都直接File.AppendAllText,IO频繁会拖慢主逻辑。业界通用的解法是生产者消费者队列:业务线程往队列里丢日志,一个后台线程慢慢落盘。在.NET Framework里,BlockingCollection<T>是这个模式最顺手的实现:
csharp复制private BlockingCollection<string> _logQueue = new BlockingCollection<string>(1000);
private void StartLogger()
{
Task.Factory.StartNew(() =>
{
foreach (string line in _logQueue.GetConsumingEnumerable())
{
File.AppendAllText("app.log", line + Environment.NewLine);
}
}, TaskCreationOptions.LongRunning);
}
public void EnqueueLog(string message)
{
// 队列满了就丢弃,防止积压
if (!_logQueue.IsAddingCompleted)
{
_logQueue.TryAdd($"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} {message}");
}
}
GetConsumingEnumerable()会阻塞等待新消息,TryAdd在队列满时返回false,避免业务线程被日志拖死。设置TaskCreationOptions.LongRunning是提示线程池这个任务会长期占用线程,别用普通线程池调度策略。
这个模式在日志、消息转发、串口数据缓存里都能用。窗口关闭时,记得调用_logQueue.CompleteAdding(),让消费线程在消费完剩余日志后自然退出。
我在实际项目里的选型经验,干脆总结成一句话:界面上的“一次性的耗时操作”,用async/await + Task.Run;带进度条、且任务是同步代码的情况,BackgroundWorker也很顺手;需要常驻后台的循环,用CancellationTokenSource驱动的Task循环;需要精确控制线程行为和优先级,才用裸Thread。选型不是越高级越好,关键在于匹配场景。这套跑顺了,WinForms的卡顿、假死、串口丢数据、TCP掉线重连这些问题,基本就都有了一套可以反复复用的解法。
