1线程是什么
线程是程序执行的一条路径,每个线程有自己的控制流,一条语句接一条语句往下走。又复杂又耗时的活可以分给不同的线程各干各的,CPU(电脑里负责计算的芯片)少闲着,程序也更快响应。
线程常被叫作“轻量级进程”,但两者不是一回事:进程是运行起来的整个程序,线程在进程里面;一个进程可以有好几个线程,它们共用这个进程的内存。视频里把线程比成工作台上的两条轨道,小车往前走就是线程在执行,两条轨道通到同一卷纸带(控制台);谁先走、走多快,由操作系统调度(安排哪条线程先用 CPU)。
2线程的生命周期
线程有四种状态:未启动(创建了还没 Start)、就绪(等着分到 CPU)、不可运行(调用了 Sleep,或在等输入输出)、死亡(执行完或被中止)。C# 里状态记在 ThreadState 属性里,名字不完全一样:
- 未启动叫
Unstarted,死亡叫Stopped。 - 就绪和正在运行都算
Running,谁真正在跑由操作系统决定。 - 调用 Sleep 或 Join 停下来时是
WaitSleepJoin。
Thread 用 Join 等待别的线程结束。下面的程序把一条子线程的状态逐个打出来(过程写在折叠代码的注释里):
查看完整代码(25 行)
| 1 | using System; | // 引入 System 命名空间 |
| 2 | using System.Threading; | // 引入 System.Threading,Thread 类在这里面 |
| 3 | | |
| 4 | namespace 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. 命名空间里。
| 10 | Thread th = Thread.CurrentThread; | // 拿到当前正在运行的线程,这里就是主线程 |
| 11 | th.Name = "MainThread"; | // 给主线程起名 MainThread |
| 12 | Console.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. 拿到的主线程,已起名 MainThread:
| 12 | Console.WriteLine("Name: " + th.Name); | // 名字 |
| 13 | Console.WriteLine("ManagedThreadId: " + th.ManagedThreadId); | // 编号,主线程是 1 |
| 14 | Console.WriteLine("IsAlive: " + th.IsAlive); | // 还活着:True |
| 15 | Console.WriteLine("IsBackground: " + th.IsBackground); | // 不是后台线程:False |
| 16 | Console.WriteLine("IsThreadPoolThread: " + th.IsThreadPoolThread); | // 不是线程池里的线程:False |
| 17 | Console.WriteLine("Priority: " + th.Priority); | // 优先级:Normal |
| 18 | Console.WriteLine("ThreadState: " + th.ThreadState); | // 正在运行:Running |
| 19 | Console.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 个,按用途分几组:
- 最常用:
Start启动,Sleep暂停,Join等另一个线程结束;Interrupt叫醒正在等待的线程,Yield把 CPU 让给别的线程;SpinWait原地空转一会儿,MemoryBarrier保证内存读写按写好的先后进行。 - 数据槽:
AllocateDataSlot等六个方法,给每个线程存一份自己的数据;想要更好的性能,改用[ThreadStatic]标记的字段(每个线程各有一份的静态字段)。 - 宿主和应用程序域(老 .NET Framework 里把程序互相隔开的单位):
GetDomain、BeginCriticalRegion等六个,普通程序很少用到。 - 已过时:
Abort、ResetAbort不再支持中止线程;VolatileRead、VolatileWrite从 .NET 9 起过时,改用Volatile.、Read Volatile.。一用到它们,编译器就给出警告。Write
我们在 .NET 9 里把表里的成员逐个查了一遍:只有 CurrentContext 不存在,Abort、ResetAbort(SYSLIB0006)和 VolatileRead、VolatileWrite(SYSLIB0054)标为已过时,其余都可用。
最后一行 9. 是实测用的 .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:
| 6 | class MyThread : Thread | // 让 MyThread 继承 Thread |
| 7 | { | // MyThread 类开始 |
| 8 | } | // MyThread 类结束 |
Program.cs(6,22): error CS0509: “MyThread”: 无法从密封类型“Thread”派生
Thread 是密封类(sealed),不能继承,要用 new 来创建:先写子线程要干的活,用委托 ThreadStart 把它包起来,再 new Thread,最后 Start。
| 15 | ThreadStart childref = new ThreadStart(CallToChildThread); | // ThreadStart 是委托,装着子线程要执行的方法 |
| 16 | Console.WriteLine("In Main: Creating the Child thread"); | // 主线程先打出这一行 |
| 17 | Thread childThread = new Thread(childref); | // 创建线程对象,这时是 Unstarted,一行都还没跑 |
| 18 | childThread.Start(); | // 启动子线程,它打出 Child thread starts |
查看完整代码(22 行)
| 1 | using System; | // 引入 System 命名空间 |
| 2 | using System.Threading; | // 引入 System.Threading 命名空间 |
| 3 | | |
| 4 | namespace 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 秒:
| 19 | Thread childThread = new Thread(CallToChildThread); | // 子线程循环打 Child 0~3,每行停 0.1 秒 |
| 20 | childThread.Start(); | // 启动子线程 |
| 21 | for (int i = 0; i < 4; i++) | // 主线程也循环四次 |
| 22 | { | // for 循环开始 |
| 23 | Console.WriteLine("Main " + i); | // 打出 Main 0~3 |
| 24 | Thread.Sleep(100); | // 停 0.1 秒 |
| 25 | } | // for 循环结束 |
| 26 | childThread.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. 是静态方法,让调用它的那条线程暂停一段时间,参数单位是毫秒。
| 10 | Console.WriteLine("Child thread starts"); | // 子线程开始 |
| 11 | // 线程暂停 5000 毫秒 | |
| 12 | int sleepfor = 5000; | // 5000 毫秒 = 5 秒 |
| 13 | Console.WriteLine("Child Thread Paused for {0} seconds", | // 打出要暂停几秒 |
| 14 | sleepfor / 1000); | // 5000 / 1000 = 5 |
| 15 | Thread.Sleep(sleepfor); | // 这 5 秒里状态是 WaitSleepJoin,什么都不干 |
| 16 | Console.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。
| 41 | Thread.Sleep(2000); | // 主线程等 2 秒,子线程大约数到 2、3 |
| 42 | // 现在中止子线程 | |
| 43 | Console.WriteLine("In Main: Aborting the Child thread"); | // 打出要中止了 |
| 44 | childThread.Abort(); | // 想强行中止子线程 |
子线程的活包在 try 里,后面接 catch 和 finally:
| 23 | catch (ThreadAbortException e) | // 捕获 ThreadAbortException(线程中止异常) |
| 24 | { | // catch 开始 |
| 25 | Console.WriteLine("Thread Abort Exception"); | // 被中止时打出这一行 |
| 26 | } | // catch 结束 |
| 27 | finally | // 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. 就是那面旗。
| 15 | Thread.Sleep(500); | // 每次醒来 |
| 16 | if (cts.Token.IsCancellationRequested) | // 先看一眼旗子:是不是已经喊了取消 |
| 17 | { | // if 开始 |
| 18 | Console.WriteLine("Child thread cancelled"); | // 喊了,就打出这一行 |
| 19 | return; | // 自己退出方法,线程结束 |
| 20 | } | // if 结束 |
| 32 | Thread.Sleep(1800); | // 主线程等 1.8 秒,子线程打出了 0、1、2 |
| 33 | Console.WriteLine("In Main: Cancelling the Child thread"); | // 打出要取消了 |
| 34 | cts.Cancel(); | // 原来调 Abort 的地方改成 Cancel:举旗 |
| 35 | childThread.Join(); | // 等子线程自己停下 |
查看完整代码(39 行)
| 1 | using System; | // 引入 System 命名空间 |
| 2 | using System.Threading; | // 引入 System.Threading 命名空间 |
| 3 | | |
| 4 | namespace 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,写个小程序,跑起来,再改一改;报错了也别怕,看懂一个报错,就又学会了一点。
