第 59 讲:多线程(一):多线程的基本概念及用法
多线程(Multi-threading)。多线程往往是众多编程语言里较难的部分,所以经常被许多编程语言放在最后来给大家介绍。多线程用好了可以让代码工作效率提升,但用不好可能会使得程序出现非常多奇奇怪怪的异常错误导致程序稳定性变差。所以,学会它们还需要自己下来多多思考和使用。
Part 1 何为多线程
多线程是一种机制,它允许我们把一个程序分成多个不同的线程(Thread)来并行(Parallel)执行。当然,这个说法初学肯定是看不太懂的,下面我们来说一下,什么是线程,什么是并行。
你可以把整个程序比喻成一个工厂。这个工厂生产零部件提供给别的地方使用。整个工厂一般情况下只有一个小组在完成人物。这个小组整体被我们称为一个线程。换句话说,一个程序一般只有一个线程在运作,完成我们想要做的任务。这个唯一的线程,我们称为主线程(Main Thread)。为什么叫主线程呢,线程不就一个吗,那还分个啥主次呢?
实际上,C# 仍然允许我们创建额外的线程(具象化理解就是,一个工厂允许招聘额外的小组团队来协同主队伍,即主线程完成任务);而其它额外的线程我们都称为副线程、次要线程或者辅助线程(Auxiliary Thread)。副线程可以有很多,但主线程只有一个,它们是这样的关系。
只要包含两个及以上的线程数量的话,我们就可以称这个程序使用了多线程技术。
线程的出现是为了复杂的行为简单化,把同一个任务(如果能分化成小零件的话)分线程执行,然后归并到一起形成结果,加快程序执行效率的一种非常棒的技术。但问题就在于,线程一旦发出,就不容易控制了。你想想这个道理:我在主线程工作,一旦我把任务委派给各个副线程执行的话,因为我只管主线程,所以副线程就不再受我控制。如果代码书写的内容不合适的话,由于无法在运行期间控制别的线程,就会导致程序出现意想不到的问题。所以,对初学的朋友来说,多线程的弊大于利;但是有了丰富的经验之后,多线程能够帮助我们完成很复杂的任务,因此对于他们来说利大于弊。多线程技术就是这么一个东西。
Part 2 线程的分类
线程分两类,前台线程(Foreground Thread)和后台线程(Background Thread)。
2-1 前台线程
前台线程是程序一旦发出就不受控制的线程,它不依赖什么,可以通过 new Thread
的语句来生成:
假设我们有一个 ThreadProc
方法(这个 proc 是 process 的缩写,如果后面单词有这类型的缩写的话你先记一下吧……实际上老外也经常用 proc 表示 process 的缩写,用在方法里当标识符或者标识符一部分),它是一个无参无返回值的方法。然后我们通过第一行代码,使用 new Thread
生成 Thread
类型的对象。这个 Thread
的实例默认情况下,就是前台线程。
这个实例化行为需要传入一个参数。参数是一个叫做 ThreadStart
的委托类型。这个委托类型大概长这样:
是的,它无参无返回值。所以我们传入的方法名必须匹配签名,也得是无参无返回值。这样的话,一会儿启动线程后,它就会执行这个方法。现在这么写是为了实例化的时候能够知道我一会儿线程该做什么。这就是委托类型的方便之处,直接把执行方法给参数化(Parameterize),就可以允许一些方法本身可以以后使用和调用了。然后,我们认为第一行已经实例化了一个前台线程了,现在我们要启动它。很简单。Thread
类型里有一个叫 Start
的实例方法,你只需要使用 实例.Start()
就可以开始执行这个任务了。不过请你注意这几点。
第一,此时 foregroundThread
是一个单独的线程,一旦发出就不受控制。如果代码写得不合适的话,这个线程怎么都不会得到终止或者停止运行(比如死循环)。
第二,线程是单独执行的,因此如果你的主线程不管有没有终止(比如从 Main
方法里退出了之类),隔壁那个 foregroundThread
都会不停运作。换句话说,这俩线程是并行(Parallel)执行的。
第三,只要程序剩下至少一个线程没有终止掉,程序就不算终止掉。换句话说,主线程和副线程虽然有主副之分,但它们的行为和目的都是为了程序执行更快和分层并行执行,所以在线程级别来说没有区别——它们都是一个单独的线程。我们给它们美其名曰主线程和副线程只是为了区别和表达线程之间的关系。哪怕只剩下一个副线程执行,但主线程已经结束,程序都不算终止。如果副线程陷入死循环,因为程序无法自动终止,所以,请任务管理器伺候。
根据这个第二点来说,假设我主线程在 Start
了一个前台线程之后再写了执行自己的语句的话,比如这样:
比如这样。Main
里在第 4 行调用 Start
创建了一个单独的线程单独执行输出单数的行为,而在 Main
里也有一个 for
循环,在输出双数。因为它们最终都使用了 Console.WriteLine
把数据打在屏幕上,所以它们是共用同一个控制台的。此时,你看到的程序结果是如何的呢?你可以猜想一下。
实际上啥样的输出都可能。你甚至可能看到先出来单数(ThreadProc
方法里的输出)然后才是双数(Main
方法里的输出)。而且顺序是不确定的,可能并不会按照 0、1、2、3、4 这样的序列,而可以是 1、0、3、2、4 或者 0、1、2、4、6 之类的。这个数据输出的顺序可以认为是随机的,但它并不是随机算法生成的随机序列,而是不可控的随机序列。因为线程单独执行后,电脑会调配主线程和副线程的执行关系,但因为它俩是“并肩作战”、“并驾齐驱”的,所以系统会认为它俩既然都在做任务,干脆就让随便哪个先哪个后都无所谓,因此我们是不知道主线程先得到输出还是副线程先得到输出的。
这里说话其实不太严谨,逻辑大体上是对的。电脑会调度(Schedule)同一个程序里的不同线程,线程在底层是有优先级等等区分执行高低的量的,但是像是刚才的这种声明形式下,主线程和副线程是一样的优先级,所以程序完全不考虑谁先谁后。
有人可能会问我,
Main
方法不就是主线程么?你在Start
副线程的时候,主线程不是已经处于运行状态了么?那不就说明主线程已经开始运行了一段时间了,那不还是Main
这个主线程先执行?实际上我这里说法是指第 6 行开始的这个for
循环这部分,和ThreadProc
方法的这个for
作对比的。我说的意思其实是,这里第 6 行代码和第 13 行的这俩for
的执行开始先后顺序是不知道的。可能有些人学过多线程,会觉得我说错了。这个前台线程是有比如
Abort
方法来终止的。实际上在 .NET 5 开始(实际上我们这份教程也是基于 .NET 5 的 API 开始给大家介绍的),Abort
方法已经不再允许使用,并永远抛出PlatformNotSupportedException
异常表示在 .NET 平台根本不支持使用此方法。所以,此时的前台线程不允许和无法得到有效终止;而在以前的 API 里,确实拥有Abort
方法来终止,写法是foregroundThread.Abort()
,但它的本质也是抛异常来掐断程序执行,然后你需要在foreground.Abort()
的外层包裹一个try
-catch (ThreadAbortedException)
来避免程序层面的中断。而且,我相当不建议使用这个方法,因为它不保证线程安全性,这个后面我们会提到。
2-2 后台线程
前台线程是一种开始就不受控制的存在。这种东西就非常不好控制,也不安全。所以有了后台线程的概念。
后台线程是一种受程序自身控制和约束的线程。这种线程会在主线程终止后自动终止,不论你这个线程做没做完。当然,如果副线程已经完成,主线程终止也跟你这个线程没关系了(毕竟你这个副线程现在已经结束了)。后台线程从这个角度来说,它比前台线程更棒,因为如果后台线程陷入死循环的话,我们可以在主线程设定执行时间。假设在调用 Start
方法后开始计时。如果副线程执行了超过 10 秒的时间,我们可以强制终止掉副线程。
首先,我们介绍一下如何实例化一个后台线程。后台线程和前台线程的声明过程只差一句话:
是的,我们就在第二行代码上加了这么一句话:实例.IsBackground = true;
。所有的 Thread
类型实例都带有这样的属性,它就表示你现在这个线程是不是后台线程。如果是 true
就是后台线程,如果是 false
则是前台线程。按默认情况下来说,线程都是前台线程,也就是说这个属性默认是 false
。
注意,我们必须在 Start
方法执行调用之前给 IsBackground
赋值,不要写反了。如果你先调用了 Start
了才来给 IsBackground
赋值的话,是不会修改和变更线程信息的,而且会引起线程级别的异常,因此必须先 IsBackground
属性赋值,然后才是 Start
方法。
Part 3 父线程和子线程
这只是一个概念。从线程 A 里面创建了别的线程的话,此时线程 A 称为父线程(Parent Thread),而别的线程就称为子线程(Child Thread)。
父和子的翻译方式对应不上 parent 和 child 这两个单词,这可能是文献的翻译错误,但将错就错了。在计算机科学里我们经常把高一级别的东西称为父级,而低一级别的叫子级。但在英语文献里,为了减少对性别的特征阐述,采用的是 parent(双亲)和 child(孩子)这两个单词,但中文里翻译成了父和子,而不是双亲和孩子。比如数据结构里二叉树里的概念父节点(也可以写成结点,下同)和子节点(结点),虽然也有叫双亲节点(结点)和孩子节点(结点)的,但这种说法第一是没有父子节点(结点)说得多,二来是不够正式化。
然后,请不要对这种术语词上进行较真。翻译成父和子并非性别上的歧视,只是一个翻译问题。
比如,我在主线程里创建了一个 Thread
类型的实例。不管这个实例是前台还是后台的,因为是主线程里创建的,所以我们称主线程叫父线程,而创建出来的这个 Thread
实例则是子线程。
Part 4 其它关于线程的惯用词汇
有些其它的词语我们需要单独介绍,因为它们也比较重要。它们不是术语词,所以可能找不到对应和合适的英语术语词,但是这里口语上用得非常多,所以提一嘴。
开一个线程:创建一个线程。
掐断、掐掉、杀掉一个线程:终止线程。一般用于终止后台线程上。
中断、阻塞线程:让线程的执行操作暂时中断和冻结。中断并非终止,而更多被理解成暂停。