← 全部文章 C# 语言

C# 多线程(二):Thread

2023 年 7 月 31 日 · 2 分钟读完

目录
  1. Thread 是什么
  2. 一、前台线程和后台线程
  3. 二、把耗时操作放到线程里
  4. 三、线程状态
  5. 四、不要用 Abort
  6. 五、Join:等线程结束
  7. 延伸

上一篇讲了 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 怎么选。

# C# # 多线程

原文首发于 blog.csdn.net