C# 多线程(二):Thread
2023 年 7 月 31 日 · 2 分钟读完
上一篇讲了 Task,这一篇看更底层的 Thread。
Thread 是什么
System.Threading.Thread 是 .NET 里创建和管理线程最基本的类,每个 Thread 对象对应一个真正的操作系统线程,可以独立执行任务。实际开发中更常用 Task 和 async/await 这些更高层的抽象,但有些场景(比如一个一直运行的通讯循环)用 Thread 更直接。
一、前台线程和后台线程
线程默认是前台线程:只要还有前台线程在运行,进程就不会退出。主窗体关了,前台线程里的死循环还会一直跑,任务管理器里进程还在。
后台线程会随着所有前台线程(通常就是主线程)结束而被直接终止:
var thread = new Thread(WorkerMethod);
thread.IsBackground = true; // 设为后台线程
thread.Start();
上位机里的通讯、采集循环,基本都应该设成后台线程,否则关闭软件后进程残留,下次打开时串口或端口被占用。注意后台线程被终止时,finally 块不会执行,需要收尾的工作(关闭连接、保存数据)要在退出前主动做。
二、把耗时操作放到线程里
Thread t;
private void button1_Click(object sender, EventArgs e)
{
t = new Thread(Work);
t.Start();
// 也可以直接传 lambda;new 出来不会立刻执行,同样要 Start
var t2 = new Thread(() =>
{
while (true)
{
Console.WriteLine("Thread 2.");
Thread.Sleep(1000);
}
});
t2.Start();
}
void Work()
{
int num = 0;
while (true)
{
Console.WriteLine($"当前次数 {++num}");
Thread.Sleep(1000);
}
}

需要传参数时,用 lambda 捕获变量最方便:new Thread(() => Work(port))。
三、线程状态
线程的状态用 ThreadState 表示,常见的有:
Unstarted:已创建,还没Start;Running:正在运行;WaitSleepJoin:在Sleep、Join或等锁;Stopped:已经结束;Background:是后台线程(可以和其他状态叠加)。
线程方法执行完就自动结束,不需要手动销毁。
四、不要用 Abort
原文里用 t.Abort() 强制终止线程,它会在目标线程里抛出 ThreadAbortException:
void Work()
{
try
{
while (true) { /* ... */ }
}
catch (ThreadAbortException)
{
Console.WriteLine("Thread Abort Exception!");
}
finally
{
Console.WriteLine("Into Finally!");
}
}
private void button2_Click(object sender, EventArgs e) => t.Abort();

在 .NET Framework 上这能跑,但不推荐:线程可能在任何一行被打断,比如正写了一半文件、拿着锁、或者通讯报文发了一半,留下不一致的状态。而且从 .NET Core / .NET 5 起,Thread.Abort() 直接抛 PlatformNotSupportedException,代码迁移时会立刻出问题。
正确做法是让线程自己检查标志,主动退出:
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
void Work()
{
while (!_cts.IsCancellationRequested)
{
// 读 PLC、处理数据……
Thread.Sleep(100);
}
// 在这里关闭连接、保存数据
}
private void btnStop_Click(object sender, EventArgs e)
{
_cts.Cancel();
t.Join(2000); // 最多等 2 秒让它收尾
}
用一个 volatile bool _running 标志也可以,CancellationToken 的好处是能顺便传给其他支持取消的 API。
五、Join:等线程结束
private void button1_Click(object sender, EventArgs e)
{
var t = new Thread(() =>
{
Console.WriteLine("Will wait 2S");
Thread.Sleep(2000);
});
t.IsBackground = true;
t.Start();
t.Join(); // 阻塞当前线程,直到 t 结束
Console.WriteLine("End");
}

注意:在按钮事件里 Join,等待的 2 秒里界面是卡住的,因为界面线程被阻塞了。线程里如果是死循环,Join 会永远等下去,需要配合上面的退出标志,或者用 Join(超时毫秒数)。
延伸
- 多个线程改同一个变量要加锁:
lock (_sync) { count++; },简单计数可以用Interlocked.Increment(ref count)。count++不是原子操作,两个线程同时加,结果会少。 - 线程里不能直接改界面控件:会抛「线程间操作无效」,要用
control.Invoke或BeginInvoke回到界面线程。 - 线程是昂贵资源:每个线程默认预留 1 MB 栈空间,创建和切换都有开销。短小、频繁的活交给线程池(
Task.Run、ThreadPool.QueueUserWorkItem),长期运行的循环才单独开Thread,或者用Task.Factory.StartNew(..., TaskCreationOptions.LongRunning)。 - 给线程起名字:
t.Name = "PLC 通讯";,调试时在「线程」窗口里一眼能认出来。 - 下一篇比较 Thread 和 Task 怎么选。
原文首发于 blog.csdn.net