第 61 讲:多线程(三):线程池
前面两节我们使用了理论给大家介绍了一下多线程的基本概念,以及多线程的不安全性,以及使用 lock
简单告诉大家如何让非原子操作改成原子操作。今天我们来说一下线程池的基本使用方式。
Part 1 为什么要有线程池的概念
线程是一个好东西,它确实可以帮助我们开设额外的运行过程,让每一个线程都能够并行执行程序(宏观来说),来提高程序执行效率。不过问题在于,我们开设的线程都使用的是 Thread
的实例,并使用委托类型对象来完成对指定方法的执行。
问题就在这里。线程开设需要使用 new Thread
的代码来完成,但这样无疑会在底层开辟内存空间,导致程序耗费内存,影响空间消耗;另外,开辟内存空间等行为也都会耗费时间,不是说光 new Thread
看起来这么简单。
实际上,开辟一个线程所需要的内存空间,然后再来执行的话,有些时候甚至是可以感受到卡顿的;也就是说,这种过程是有可见的副作用的。因此,线程池的概念就诞生了。
所谓的线程池(Thread Pool),这个术语词看起来有点奇特,但也很形象。线程池就是一个逻辑上的池子,它里面预先就放好了一个已经给你分配好了的后台线程序列。这些后台线程序列其实是一个一个的模板一样的存在,它们自身不是拿来直接运行的,而是你在调出其中的一个线程后,然后给予它的执行行为,就可以直接执行了,是这么一个意思。
线程池的好处就是在于如下两点:
它是后台线程的序列构成的,这意味着我们创建的这些线程整体会受到程序本身关闭而自动终结,无需考虑出现异常情况的时候无法终结的问题;
它是预先分配好内存空间的线程序列,因此我们无需初始化即可调用,这样省去了内存分配需要用的时间。
Part 2 线程池的使用
线程池的使用相当简单,甚至简单到有手就行。之前我们用的是 new Thread
,然后在构造器里面传入一个 ThreadStart
这个委托类型的实例,并带上我们需要的方法即可。现在我们有了线程池后,我们需要用一个叫做 ThreadPool.QueueUserWorkItem
方法来完成这个任务。
假设我们有这样一个执行起来很慢的操作:
我们要让这个 DoWork
方法以线程形式执行的话,我们这么调用:
稍微更长了一点,但只是代码更长了一点,但效果是一样的,而且提升了性能。不过……直接这么写,我们发现编译器会给出错误:

可以发现,此时编译器告诉我们,DoWork
方法无法匹配这个叫做 WaitCallBack
的委托类型对象。我们点开 WaitCallBack
委托类型,可以发现它必须传入一个 object
类型的参数进去。

object?
的问号表示这个传入的object
类型的参数可以为null
值。这个语法不是 C# 原本就有的语法,包括上面的namespace System.Threading;
也不是 C# 的基本语法。它们分别在 C# 8 和 C# 10 里才可以使用,但这些语法我们以后再来说,这里你稍微了解一下就行了,这样也避免了发现这样不懂的语法然后跑来问我。你放心,这些语法我们以后会慢慢说到的,所以不要担心。
这里我们注意到,它有一个 object
类型的参数,这个参数名字叫 state
。在文本注释上对这个参数写的解释是“state
是一个对象,它包含一些基本信息,可以在这个回调函数里使用”。可……哪里来的回调函数?这个 WaitCallBack
的委托类型里通过 +=
添加进去的方法序列,都是我们的回调函数,这个在委托里讲过了,你没忘吧?可……这也没用 +=
符号啊……emmm,你是不是忘了一点。初始化委托对象是用的 new
来完成的;而这个 new
里传入了一个初始参数的;而这个参数是不是就是一个方法名?这个方法名就是初始值,它已经在初始情况下就加入到了委托的回调函数列表里了。它也等价于你这么写:
只是,这么写要复杂一点。所以我们直接把 DoWork
给传入到参数里了。这些都是委托的基本语法,没有忘吧?而这里的 state
就是恰好用于这个 DoWork
方法的;当然,你也可以不用它,也可以用它,这个随便你;只不过……这个参数必须传入,因为委托类型要求我们传入一个 object
类型的参数进去。
随后,我们加入参数 object state
进去。
这样就可以了。总之,这样我们就模拟好了一个完整的使用线程池调用线程的方法了。这个 state
我们没有用到,因为目前来说它是无用的。
然后,我们试着在 Main
方法里加上一些别的代码,模拟一下让代码卡起来才使用多线程技术的过程:
我们来看一下,这个代码到底是什么意思。
首先,我们在第 3 行使用 ThreadPool.QueueUserWorkItem
静态方法来调取一个线程池里的后台线程,并让其执行 DoWork
方法。接着,我们在主线程里写上了 for
循环,等同的代码,不过这次在主线程里输出的是减号,在线程池线程里输出的是加号。
接着,在第 15 行代码里我们使用了一个全新的方法:Thread.Sleep
静态方法。这个方法用在指定的线程的代码上,用于让这个指定的线程卡顿,停止执行指定的时长。单位是毫秒(1000 毫秒等于 1 秒,所以示例程序里是让程序等待 1 秒的意思)。因为程序输出 1000 个加号和 1000 个减号的总时长也用不到 1 秒,所以为了让大家能够看到两个线程均完成执行,我们使用了这个方法卡住主线程。如果主线程已经输出完成 1000 个减号而线程池线程还没有完成 1000 个加号的输出的话,这行代码会卡住主线程,让主线程暂时不会终止掉。按照正常的逻辑来说,由于线程池的线程都是后台线程,所以一旦主线程终止运行了(不论是异常终止的还是正确执行完成而终止的),这些后台线程全部都会停止执行。而此时,因为后台线程可能还没有完成对 1000 个加号的输出就停止了,就达不到我们这里的显示要求,因此我们故意卡住主线程,让主线程必须等待 1 秒钟后才能让程序继续往下执行;而下面没有别的代码了,所以写在最后的这个 Thread.Sleep
就表示主线程在 1 秒后才会退出。
为什么是 1 秒呢?因为 1 秒比较合适,不算极限时长,也不用卡住主线程过长时间。输出 2000 个符号时长压根到不了一秒钟,但如果你写两秒的话,可以发现程序卡住,两秒后才会恢复继续执行,所以完全没有必要多卡住一秒钟的时间,这多出来的时间就浪费了。所以选择 1 秒比较合适;相反,如果你写的时长比 1 秒要少的话,可能程序还没有真正完成对 2000 个符号的输出,等待时长就结束了,然后主线程就停止执行了,而此时符号还没有完成输出,这就没有达到我们的目的。
因为是多线程完成的操作,所以输出结果是这样的:

当然,这是其中一种情况而已。因为多线程具有不可再现性,所以无法决定输出的加号和减号的先后顺序和相对输出个数的情况。
从这个图里我们还可以看出,线程池的线程是自动启动的,一旦调用 QueueUserWorkItem
方法后,线程就会自动开始执行。
Part 3 不要随便把 Thread.Sleep
用于主线程
主线程是一个神奇的东西。可能你在控制台程序里看不出来它有什么效果,但是在 UI(用户接口,说白了就是界面程序)上就不一样了。在微软的资料里,比如 Windows Form 这样的带界面的程序框架下,绘制和书写代码得到的窗体都是单线程的;这意味着你一旦使用 Thread.Sleep
方法在主线程上,就必然会卡死主线程,因为单线程的程序,UI 只会存在于主线程上;而你一旦在主线程上随便哪个代码的位置上加一句 Thread.Sleep
方法的调用,那么 UI 就百分之百会被卡住。所以,Thread.Sleep
一般不要随便用。控制台的程序不会卡住,是因为它是一个底层比较复杂的存在,它避免了 UI 这类情况,因此我们刚才即使用了 Thread.Sleep
也可以发现,控制台程序的光标仍然在闪烁,这就说明程序没有卡住。
所以,不要以为控制台程序没卡住,UI 程序就不会卡住。所以不要随便用。
Part 4 state
参数的用途
刚才我们没有说明 state
参数的具体用法,下面我们来说一下线程池的该参数的用法。其实这个参数是为了和外部交互而产生的一个额外的“附加信息”。如果你想要让这个程序灵活起来,可能就需要从外部传入该参数。
用法是这样的。假如我们变更一下刚才的逻辑,在 DoWork
里本来应该固定输出加号,现在我们想要自定义输出的符号,那么我们需要借助 state
参数来完成。我们改写 DoWork
方法:
我们使用参数,在最开头判断一下,是否 state
是 char
类型的参数。如果不是的话,DoWork
方法直接自动终止;如果是一个字符的话,那么我们就使用强制转换来得到 element
变量,然后在循环里使用它。
接着,改变 DoWork
的参数后,我们也得增加参数吧。于是 QueueUserWorkItem
里也一样,需要追加一个参数。
我们直接在 QueueUserWorkItem
方法参数表列上增加一个参数。注意,这个参数不是写在 new WaitCallBack
的小括号里,而是写在 QueueUserWorkItem
的小括号里,因为我们这个 '*'
的字符参数不是 WaitCallBack
委托类型本身的合法的参数,所以 QueueUserWorkItem
方法提供了一个重载,带上这个参数到第二个参数位置上即可。
此时,我们再来看运行程序,别的地方都不改,就变动这样一点内容,看看效果:

很好,效果达到了,这就是我们使用 state
参数的情况。
Part 5 顺带说一下 new Thread
也传入额外参数的情况
既然我们说到了 state
参数用来交互传参的情况,那么我们就应该说一下 new Thread
这个情况下的外部传参的情况。虽然这个点在之前就应该说了,但因为前面我们用不上,所以就没提到。
之前我们用的是 ThreadStart
委托类型对象作为参数的情况。因为此时我们模拟的情况也需要从外部传入参数,因此这个委托类型就不够用了。这里我们用的是一个叫 ParameterizedThreadStart
的委托类型对象。这个委托类型的签名和前面的 WaitCallBack
的委托的签名是完全一样的,它们没有区别,所以你甚至连方法的签名都不用改,只需要用 new Thread
来改一下这个就可以了。
注意代码的第 3 行和第 4 行。第 3 行我们实例化一个 Thread
类型的对象,但此时参数改为了 ParameterizedThreadStart
的委托类型的实例。接着,我们执行使用 .Start
的时候,传入这个我们从外部交互进去的参数。
因为这个线程创建方式默认创建的是前台线程,所以这里的 Thread.Sleep
是可以不写的,因为程序终止需要等待所有线程全部完成执行才会终止,因此不论这个创建出来的前台线程先完成还是主线程先完成,所有线程都得全部完成执行后,程序才会终止,因此无需担心先后顺序。但需要注意的是,这个不是线程池,因此要手动调用 Start
方法来开始启动线程执行。
最后我们还是来看一下结果:

ParameterizedThreadStart