第 40 节 · 第四部分 进阶特性

多线程

线程是进程里的一条执行路径;new Thread(委托) 再 Start 就多一条路径;要它停,举旗让它自己停,不要硬中止。

时长 10:14授课计划第 16 周

学完这一节,你能

讲义

跟着视频的顺序写。每行代码都有注释,代码和运行结果都是实际运行得到的。

1线程是什么

线程是程序执行的一条路径,每个线程有自己的控制流,一条语句接一条语句往下走。又复杂又耗时的活可以分给不同的线程各干各的,CPU(电脑里负责计算的芯片)少闲着,程序也更快响应。

线程常被叫作“轻量级进程”,但两者不是一回事:进程是运行起来的整个程序,线程在进程里面;一个进程可以有好几个线程,它们共用这个进程的内存。视频里把线程比成工作台上的两条轨道,小车往前走就是线程在执行,两条轨道通到同一卷纸带(控制台);谁先走、走多快,由操作系统调度(安排哪条线程先用 CPU)。

2线程的生命周期

线程有四种状态:未启动(创建了还没 Start)、就绪(等着分到 CPU)、不可运行(调用了 Sleep,或在等输入输出)、死亡(执行完或被中止)。C# 里状态记在 ThreadState 属性里,名字不完全一样:

  • 未启动叫 Unstarted,死亡叫 Stopped。
  • 就绪和正在运行都算 Running,谁真正在跑由操作系统决定。
  • 调用 Sleep 或 Join 停下来时是 WaitSleepJoin。

Thread 用 Join 等待别的线程结束。下面的程序把一条子线程的状态逐个打出来(过程写在折叠代码的注释里):

查看完整代码(25 行)
C#
1using System;// 引入 System 命名空间
2using System.Threading;// 引入 System.Threading,Thread 类在这里面
3
4namespace MultithreadingApplication// 定义命名空间 MultithreadingApplication
5{// 命名空间开始
6 class StateProgram// 定义 StateProgram 类
7 {// StateProgram 类开始
8 public static void CallToChildThread()// 子线程要执行的方法
9 {// CallToChildThread 开始
10 Console.WriteLine("Child: " + Thread.CurrentThread.ThreadState);// Thread.CurrentThread 是正在跑这行的线程:Running
11 Thread.Sleep(1000);// 子线程暂停 1 秒
12 }// CallToChildThread 结束
13
14 static void Main(string[] args)// 程序入口 Main 方法
15 {// Main 开始
16 Thread childThread = new Thread(CallToChildThread);// 新建子线程,直接写方法名
17 Console.WriteLine("After new: " + childThread.ThreadState);// 打出 Unstarted
18 childThread.Start();// 启动子线程
19 Thread.Sleep(300);// 主线程等 0.3 秒
20 Console.WriteLine("Child sleeping: " + childThread.ThreadState);// 打出 WaitSleepJoin
21 childThread.Join();// 等子线程结束
22 Console.WriteLine("After Join: " + childThread.ThreadState);// 打出 Stopped
23 }// Main 结束
24 }// StateProgram 类结束
25}// 命名空间结束
运行结果
After new: Unstarted
Child: Running
Child sleeping: WaitSleepJoin
After Join: Stopped

到了 Stopped,状态就不会再变回来。

3主线程

程序一运行就自动有一个线程,叫主线程,Main 方法就跑在它上面。操作线程靠 Thread 类,它在 System.Threading 命名空间里。

C# · 第 10-12 行
10Thread th = Thread.CurrentThread;// 拿到当前正在运行的线程,这里就是主线程
11th.Name = "MainThread";// 给主线程起名 MainThread
12Console.WriteLine("This is {0}", th.Name);// {0} 处填进名字
运行结果
This is MainThread

用 Thread 新建出来的线程常叫子线程。子线程启动以后是自己往下跑的,不是被主线程调用。

4Thread 的属性

Thread 常用的属性有 13 个,最常用的几个:Name 名字、ManagedThreadId 编号、IsAlive 是否还活着、ThreadState 此刻的状态、IsBackground 是否后台线程(所有前台线程结束时,后台线程会被一起结束)、IsThreadPoolThread 是否线程池里的线程(线程池是 .NET 预先备好、反复使用的一批线程)、Priority 优先级、CurrentCulture 区域设置(决定日期、数字怎么显示;带 UI 的那个管界面用哪种语言)。下面的 th 是用 Thread.CurrentThread 拿到的主线程,已起名 MainThread:

C# · 第 12-19 行
12Console.WriteLine("Name: " + th.Name);// 名字
13Console.WriteLine("ManagedThreadId: " + th.ManagedThreadId);// 编号,主线程是 1
14Console.WriteLine("IsAlive: " + th.IsAlive);// 还活着:True
15Console.WriteLine("IsBackground: " + th.IsBackground);// 不是后台线程:False
16Console.WriteLine("IsThreadPoolThread: " + th.IsThreadPoolThread);// 不是线程池里的线程:False
17Console.WriteLine("Priority: " + th.Priority);// 优先级:Normal
18Console.WriteLine("ThreadState: " + th.ThreadState);// 正在运行:Running
19Console.WriteLine("CurrentCulture: " + th.CurrentCulture);// 区域设置,跟着系统走
查看完整输出(8 行)
运行结果
Name: MainThread
ManagedThreadId: 1
IsAlive: True
IsBackground: False
IsThreadPoolThread: False
Priority: Normal
ThreadState: Running
CurrentCulture: zh-CN

教程列的属性里,CurrentContext 只在老的 .NET Framework 里有,.NET 9 里已经查不到了(见下一节的检查结果)。

5Thread 的方法

Thread 常用方法有 23 个,按用途分几组:

  1. 最常用:Start 启动,Sleep 暂停,Join 等另一个线程结束;Interrupt 叫醒正在等待的线程,Yield 把 CPU 让给别的线程;SpinWait 原地空转一会儿,MemoryBarrier 保证内存读写按写好的先后进行。
  2. 数据槽:AllocateDataSlot 等六个方法,给每个线程存一份自己的数据;想要更好的性能,改用 [ThreadStatic] 标记的字段(每个线程各有一份的静态字段)。
  3. 宿主和应用程序域(老 .NET Framework 里把程序互相隔开的单位):GetDomain、BeginCriticalRegion 等六个,普通程序很少用到。
  4. 已过时:Abort、ResetAbort 不再支持中止线程;VolatileRead、VolatileWrite 从 .NET 9 起过时,改用 Volatile.Read、Volatile.Write。一用到它们,编译器就给出警告。

我们在 .NET 9 里把表里的成员逐个查了一遍:只有 CurrentContext 不存在,Abort、ResetAbort(SYSLIB0006)和 VolatileRead、VolatileWrite(SYSLIB0054)标为已过时,其余都可用。

最后一行 9.0.17 是实测用的 .NET 运行时版本。

查看完整输出(37 行)
运行结果
CurrentContext: 不存在
CurrentCulture: 可用
CurrentPrincipal: 可用
CurrentThread: 可用
CurrentUICulture: 可用
ExecutionContext: 可用
IsAlive: 可用
IsBackground: 可用
IsThreadPoolThread: 可用
ManagedThreadId: 可用
Name: 可用
Priority: 可用
ThreadState: 可用
Abort: 已过时 SYSLIB0006
AllocateDataSlot: 可用
AllocateNamedDataSlot: 可用
BeginCriticalRegion: 可用
BeginThreadAffinity: 可用
EndCriticalRegion: 可用
EndThreadAffinity: 可用
FreeNamedDataSlot: 可用
GetData: 可用
GetDomain: 可用
GetDomainID: 可用
GetNamedDataSlot: 可用
Interrupt: 可用
Join: 可用
MemoryBarrier: 可用
ResetAbort: 已过时 SYSLIB0006
SetData: 可用
Start: 可用
Sleep: 可用
SpinWait: 可用
VolatileRead: 已过时 SYSLIB0054
VolatileWrite: 已过时 SYSLIB0054
Yield: 可用
9.0.17

6创建线程

线程不能靠继承 Thread 类来创建(教程的说法不对)。试着写一个类继承 Thread:

C# · 第 6-8 行
6class MyThread : Thread// 让 MyThread 继承 Thread
7{// MyThread 类开始
8}// MyThread 类结束
编译报错
Program.cs(6,22): error CS0509: “MyThread”: 无法从密封类型“Thread”派生

Thread 是密封类(sealed),不能继承,要用 new 来创建:先写子线程要干的活,用委托 ThreadStart 把它包起来,再 new Thread,最后 Start。

C# · 第 15-18 行
15ThreadStart childref = new ThreadStart(CallToChildThread);// ThreadStart 是委托,装着子线程要执行的方法
16Console.WriteLine("In Main: Creating the Child thread");// 主线程先打出这一行
17Thread childThread = new Thread(childref);// 创建线程对象,这时是 Unstarted,一行都还没跑
18childThread.Start();// 启动子线程,它打出 Child thread starts
查看完整代码(22 行)
C#
1using System;// 引入 System 命名空间
2using System.Threading;// 引入 System.Threading 命名空间
3
4namespace MultithreadingApplication// 定义命名空间 MultithreadingApplication
5{// 命名空间开始
6 class ThreadCreationProgram// 定义 ThreadCreationProgram 类
7 {// ThreadCreationProgram 类开始
8 public static void CallToChildThread()// 子线程要干的活
9 {// CallToChildThread 开始
10 Console.WriteLine("Child thread starts");// 子线程打出一行
11 }// CallToChildThread 结束
12
13 static void Main(string[] args)// 程序入口 Main 方法
14 {// Main 开始
15 ThreadStart childref = new ThreadStart(CallToChildThread);// 用 ThreadStart 委托包住方法
16 Console.WriteLine("In Main: Creating the Child thread");// 在 Start 之前打印,所以先出现
17 Thread childThread = new Thread(childref);// 创建线程对象
18 childThread.Start();// 启动子线程
19 Console.ReadKey();// 主线程停在这里等按键,子线程照样在跑
20 }// Main 结束
21 }// ThreadCreationProgram 类结束
22}// 命名空间结束

两条线程同时往控制台写,顺序就说不准了。下面主线程和子线程各打四行,每打一行停 0.1 秒:

C# · 第 19-26 行
19Thread childThread = new Thread(CallToChildThread);// 子线程循环打 Child 0~3,每行停 0.1 秒
20childThread.Start();// 启动子线程
21for (int i = 0; i < 4; i++)// 主线程也循环四次
22{// for 循环开始
23 Console.WriteLine("Main " + i);// 打出 Main 0~3
24 Thread.Sleep(100);// 停 0.1 秒
25}// for 循环结束
26childThread.Join();// 等子线程打完

同一段程序跑了五次,五次顺序都不一样(另一次的结果在折叠块里):

运行结果
Child 0
Main 0
Child 1
Main 1
Child 2
Main 2
Child 3
Main 3
查看完整输出(8 行)
再运行一次的结果
Main 0
Child 0
Child 1
Main 1
Main 2
Child 2
Child 3
Main 3

所以写多线程程序,不能指望输出总是同一个顺序。

7管理线程:Sleep

Thread.Sleep 是静态方法,让调用它的那条线程暂停一段时间,参数单位是毫秒。

C# · 第 10-16 行
10Console.WriteLine("Child thread starts");// 子线程开始
11// 线程暂停 5000 毫秒
12int sleepfor = 5000;// 5000 毫秒 = 5 秒
13Console.WriteLine("Child Thread Paused for {0} seconds",// 打出要暂停几秒
14 sleepfor / 1000);// 5000 / 1000 = 5
15Thread.Sleep(sleepfor);// 这 5 秒里状态是 WaitSleepJoin,什么都不干
16Console.WriteLine("Child thread resumes");// 5 秒后醒来,接着往下走
运行结果
In Main: Creating the Child thread
Child thread starts
Child Thread Paused for 5 seconds

Child thread resumes

中间的空行是按下的回车;实测时,Paused 和 resumes 两行之间隔了五秒。

8销毁线程

老写法用 Abort 中止线程:子线程每半秒数一个数,主线程等两秒后调用 Abort。

C# · 第 41-44 行
41Thread.Sleep(2000);// 主线程等 2 秒,子线程大约数到 2、3
42// 现在中止子线程
43Console.WriteLine("In Main: Aborting the Child thread");// 打出要中止了
44childThread.Abort();// 想强行中止子线程

子线程的活包在 try 里,后面接 catch 和 finally:

C# · 第 23-30 行
23catch (ThreadAbortException e)// 捕获 ThreadAbortException(线程中止异常)
24{// catch 开始
25 Console.WriteLine("Thread Abort Exception");// 被中止时打出这一行
26}// catch 结束
27finally// finally:不管有没有异常,最后都会执行
28{// finally 开始
29 Console.WriteLine("Couldn't catch the Thread Exception");// 最后打出这一行
30}// finally 结束

在老的 .NET Framework 上,它会数到 2,再打出 catch 和 finally 里的两行。现在的 VS 会在 Abort 下面画波浪线(警告):

编译警告
Program.cs(23,41): warning CS0168: 声明了变量“e”,但从未使用过
Program.cs(44,13): warning SYSLIB0006: “Thread.Abort()”已过时:“Thread.Abort is not supported and throws PlatformNotSupportedException.” (https://aka.ms/dotnet-warnings/SYSLIB0006)

真运行一下,子线程数到 2 时主线程调用 Abort,程序就抛出 PlatformNotSupportedException: Thread abort is not supported on this platform.(这个平台不支持中止线程)报错退出,catch 和 finally 那两行一行都没出现:

查看完整输出(10 行)
运行结果
In Main: Creating the Child thread
Child thread starts
0
1
2
In Main: Aborting the Child thread
3
Unhandled exception. System.PlatformNotSupportedException: Thread abort is not supported on this platform.
   at System.Threading.Thread.Abort()
   at MultithreadingApplication.ThreadCreationProgram.Main(String[] args) in 多线程/abort/Program.cs:line 44
  • 在 .NET Core 和 .NET 5 以后 Abort 都这样报错,换成 .NET 10 跑结果一样。
  • 跑了五次,3 有时在中止那一行前面,有时在后面:两条线程同时在写。
  • 在老版本里,ThreadAbortException 能被 catch 捕获,只是 catch 结束后会自动再抛一次。

强行中止的问题在于,线程停在哪一行谁也不知道,该释放的资源可能就没释放。

9协作式取消:CancellationToken

现在推荐的做法是协作式取消:主线程不硬停子线程,只是举起一面旗“请你停下”,子线程隔一会儿看一眼,看到了就收好手里的活自己退出。下面的 cts 是一个 CancellationTokenSource(发出取消的工具),cts.Token 就是那面旗。

C# · 第 15-20 行
15Thread.Sleep(500);// 每次醒来
16if (cts.Token.IsCancellationRequested)// 先看一眼旗子:是不是已经喊了取消
17{// if 开始
18 Console.WriteLine("Child thread cancelled");// 喊了,就打出这一行
19 return;// 自己退出方法,线程结束
20}// if 结束
C# · 第 32-35 行
32Thread.Sleep(1800);// 主线程等 1.8 秒,子线程打出了 0、1、2
33Console.WriteLine("In Main: Cancelling the Child thread");// 打出要取消了
34cts.Cancel();// 原来调 Abort 的地方改成 Cancel:举旗
35childThread.Join();// 等子线程自己停下
查看完整代码(39 行)
C#
1using System;// 引入 System 命名空间
2using System.Threading;// 引入 System.Threading 命名空间
3
4namespace MultithreadingApplication// 定义命名空间 MultithreadingApplication
5{// 命名空间开始
6 class ThreadCreationProgram// 定义 ThreadCreationProgram 类
7 {// ThreadCreationProgram 类开始
8 static CancellationTokenSource cts = new CancellationTokenSource();// 用来发出取消;它的 Token 才是旗子
9
10 public static void CallToChildThread()// 子线程要干的活
11 {// CallToChildThread 开始
12 Console.WriteLine("Child thread starts");// 子线程开始
13 for (int counter = 0; counter <= 10; counter++)// 从 0 数到 10
14 {// for 循环开始
15 Thread.Sleep(500);// 每次停 0.5 秒
16 if (cts.Token.IsCancellationRequested)// 检查是否已经喊了取消
17 {// if 开始
18 Console.WriteLine("Child thread cancelled");// 打出被取消
19 return;// 自己退出
20 }// if 结束
21 Console.WriteLine(counter);// 没取消就打出这个数
22 }// for 循环结束
23 Console.WriteLine("Child Thread Completed");// 数完才会打这一行,本例走不到
24 }// CallToChildThread 结束
25
26 static void Main(string[] args)// 程序入口 Main 方法
27 {// Main 开始
28 ThreadStart childref = new ThreadStart(CallToChildThread);// 用委托包住方法
29 Console.WriteLine("In Main: Creating the Child thread");// 主线程打出一行
30 Thread childThread = new Thread(childref);// 创建子线程
31 childThread.Start();// 启动子线程
32 Thread.Sleep(1800);// 主线程等 1.8 秒
33 Console.WriteLine("In Main: Cancelling the Child thread");// 打出要取消了
34 cts.Cancel();// 发出取消
35 childThread.Join();// 等子线程结束
36 Console.WriteLine("In Main: Child thread finished");// 子线程停好后,主线程打出最后一行
37 }// Main 结束
38 }// ThreadCreationProgram 类结束
39}// 命名空间结束
运行结果
In Main: Creating the Child thread
Child thread starts
0
1
2
In Main: Cancelling the Child thread
Child thread cancelled
In Main: Child thread finished

子线程是在自己检查的地方停下的;这个例子跑了五次,结果都一样。

10小结

  • 线程是程序里的一条执行路径,程序一开始就有主线程。
  • ThreadStart 包住方法,new Thread 创建,Start 启动。
  • Sleep 暂停当前线程,Join 等别的线程结束。
  • Abort 已经不支持了,要停线程,就用 CancellationToken 让它自己停。
  • 几条线程一起写,输出顺序每次都可能不同。

到这里,这门课就全部讲完了。打开 VS,写个小程序,跑起来,再改一改;报错了也别怕,看懂一个报错,就又学会了一点。

检查题

5 道,选完马上告诉你对不对。

  1. 1一个进程里的几条线程,内存是怎样的?

  2. 2子线程调用 Thread.Sleep(1000) 期间,主线程读它的 ThreadState 是?

  3. 3class MyThread : Thread { } 编译结果?

  4. 4在 .NET 9 上调用 childThread.Abort()?

  5. 5主线程和子线程各打四行,运行五次,输出顺序?