18.5 threading模块

接下来,我们要介绍的是更高级别的threading模块,它不仅提供了Thread类,还提供了各种非常好用的同步机制。表18.2列出了threading模块里所有的对象。

在这一节中,我们会演示如何使用Thread类来实现多线程。之前已经介绍过锁的基本概念,这里我们将不会提到锁原语。而Thread类也有某种同步机制,所以,没有必要详细介绍锁原语。

18.5 threading模块 - 图1

18.5 threading模块 - 图2核心提示:守护线程

另一个避免使用thread模块的原因是,它不支持守护线程。当主线程退出时,所有的子线程不论它们是否还在工作,都会被强行退出。有时我们并不期望这种行为,这时就引入了守护线程的概念。

Threading模块支持守护线程,它们是这样工作的:守护线程一般是一个等待客户请求服务器,如果没有客户提出请求,它就在那等着。如果你设定一个线程为守护线程,就表示你在说这个线程是不重要的,在进程退出的时候,不用等待这个线程退出。就像你在第16章网络编程看到的,服务器线程运行在一个无限循环中,一般不会退出。

如果你的主线程要退出的时候,不用等待那些子线程完成,那就设定这些线程的daemon属性。即,在线程开始(调用thread.start())之前,调用setDaemon()函数设定线程的daemon标志(thread.setDaemon(True))就表示这个线程“不重要”。

如果你想要等待子线程完成再退出,那就什么都不用做,或者显式地调用thread.setDaemon(False)以保证其daemon标志为False。你可以调用thread.isDaemon()函数来判断其daemon标志的值。新的子线程会继承其父线程的daemon标志。整个Python会在所有的非守护线程退出后才会结束,即进程中没有非守护线程存在的时候才结束。

18.5.1 Thread类

threading的Thread类是你主要的运行对象。它有很多thread模块里没有的函数,详见表18.3。

用Thread类,你可以用多种方法来创建线程。我们在这里介绍三种比较相像的方法。你可以任选一种你喜欢的,或最适合你的程序以及最能满足程序可扩展性的(我们一般比较喜欢最后一个选择):

  • 创建一个Thread的实例,传给它一个函数;

  • 创建一个Thread的实例,传给它一个可调用的类对象;

  • 从Thread派生出一个子类,创建一个这个子类的实例。

18.5 threading模块 - 图3

创建一个Thread的实例,传给它一个函数。

第一个例子中,我们将初始化一个Thread对象,把函数(及其参数)像上一个例子那样传进去。在线程开始执行的时候,这个函数会被执行。把mtsleep2.py脚本拿过来,一些调整加入Thread对象的使用,就成了例18.4中的mtsleep3.py。

运行的输出跟之前很相似:

18.5 threading模块 - 图4

18.5 threading模块 - 图5

那么,都做了些什么修改呢?在使用thread模块时使用的锁没有了,新加了一些Thread对象。在实例化每个Thread对象的时候,我们把函数(target)和参数(args)传进去,得到返回的Thread实例。实例化一个Thread (调用Thread())与调用thread.start_new_thread()之间最大的区别就是,新的线程不会立即开始。在你创建线程对象,但不想马上开始运行线程的时候,这是一个很有用的同步特性。

例18.4 使用threading模块(mtsleep3.py)

threading模块的Thread类有一个join()函数,允许主线程等待线程的结束。

18.5 threading模块 - 图6

所有的线程都创建了之后,再一起调用start()函数启动,而不是创建一个启动一个。而且,不用再管理一堆锁(分配锁、获得锁、释放锁、检查锁的状态等),只要简单地对每个线程调用join()函数就可以了。

join()会等到线程结束,或者在给了timeout参数的时候,等到超时为止。使用join()看上去会比使用一个等待锁释放的无限循环清楚一些(这种锁也被称为“自旋锁”)。

join()的另一个比较重要的方面是它可以完全不用调用。一旦线程启动后,就会一直运行,直到线程的函数结束,退出为止。如果你的主线程除了等线程结束外,还有其他的事情要做(如处理或等待其他的客户请求),那就不用调用join(),只有在你要等待线程结束的时候才要调用join()。

创建一个Thread的实例,传给它一个可调用的类对象。

与传一个函数很相似的另一个方法是在创建线程的时候,传一个可调用的类的实例供线程启动的时候执行——这是多线程编程的一个更为面向对象的方法。相对于一个或几个函数来说,由于类对象里可以使用类的强大的功能,可以保存更多的信息,这种方法更为灵活。

把ThreadFunc类加入到mtsleep3.py代码中,并做一些其他的小修改后,就得到了例18.5中的mtsleep4.py。运行它,就会得到如下的输出:

18.5 threading模块 - 图7

那么,这次又改了些什么呢?主要是增加了ThreadFunc类和创建Thread对象时会实例化一个可调用类ThreadFunc的类对象。也就是说,我们实例化了两个对象。下面,来仔细地看一看ThreadFunc类吧。

我们想让这个类在调用什么函数方面尽量地通用,并不局限于那个loop()函数。所以,我们加了一些修改,如,这个类保存了函数的参数,函数本身以及函数的名字字符串。构造器init()里做了这些值的赋值工作。

创建新线程的时候,Thread对象会调用我们的ThreadFunc对象,这时会用到一个特殊函数call()。由于我们已经有了要用的参数,所以就不用再传到Thread()的构造器中。由于我们有一个参数的元组,这时要在代码中使用apply()函数。如果你使用的是Pythonl.6或是更高版本,你可以使用11.6.3节中所说的新的调用语法,而不用像第16行那样使用apply()函数:

18.5 threading模块 - 图8

此例中,我们传了一个可调用的类(的实例),而不是仅传一个函数。相对mtsleep3.py中的方法来说,这样做更具面向对象的概念。

例18.5

18.5 threading模块 - 图9

18.5 threading模块 - 图10

从Thread派生出一个子类,创建一个这个子类的实例。

最后一个例子介绍如何子类化Thread类,这与上一个例子中的创建一个可调用的类非常像。使用子类化创建线程(第29~30行)使代码看上去更清晰明了。我们将在例18.6中给出mtsleep5.py的代码,以及代码运行的输出。比较mtsleep5.py和mtsleep4.py的任务则留给读者作为练习。

下面是mtsleep5.py的输出,同样,跟我们的期望一致:

18.5 threading模块 - 图11

在读者比较mtsleep4和mtsleep5两个模块的代码之前,我们想指出最重要的两点改变:

(1)我们的MyThread子类的构造器一定要先调用基类的构造器(第9行);(2)之前的特殊函数call()在子类中,名字要改为run()。

现在,在MyThread类中,加入一些用于调试的输出信息,把代码保存到myThread模块中(见例18.7),并在下面的例子中导入这个类。除了简单地使用apply()函数来运行这些函数之外,我们还把结果保存到实现的self.res属性中,并创建一个新的函数getResult()来得到结果。

18.5.2 斐波那契、阶乘和累加和

例18.8中的mtfacfib.py脚本比较了递归求斐波那契、阶乘和累加和函数的运行。脚本先在单线程中运行这三个函数,然后在多线程中做同样的事,以说明多线程的好处。

我们现在要子类化Thread类,而不是创建它的实例。这样做可以更灵活地定制我们的线程对象,而且在创建线程的时候也更简单。

例18.6

18.5 threading模块 - 图12

例18.7 MyThread子类化Thread (myThread.py)

为了让mtsleep5.py中,Thread的子类更为通用,我们把子类单独放在一个模块中,并加上一个getResult()函数用以返回函数的运行结果。

18.5 threading模块 - 图13

在单线程中运行只要简单地逐个调用这些函数,在函数结束后显示对应的结果。在多线程中,我们不马上显示结果。由于我们想让MyThread类尽可能地通用(能同时适应有输出和没输出的函数),我们会等到要结束时才会调用getResult()函数,并在最后显示每个函数的结果。

由于这些函数运行得很快(斐波那契函数会慢一些),你会看到,我们得在每个函数中加上一个sleep()函数,让函数慢下来,以便于我们能方便地看到多线程能在多大程度上加速程序的运行。不过实际工作中,你一般不会想在程序中加上sleep()函数的。下面是程序的输出:

18.5 threading模块 - 图14

在这个多线程程序中,我们会分别在单线程和多线程环境中,运行三个递归函数。

例18.8

18.5 threading模块 - 图15

18.5 threading模块 - 图16

18.5 threading模块 - 图17

18.5.3 threading模块中的其他函数

除了各种同步对象和线程对象外,threading模块还提供了一些函数。见表18.4。

18.5 threading模块 - 图18

18.5.4 生产者-消费者问题和Queue模块

最后一个例子演示了生产者和消费者的场景。生产者生产货物,然后把货物放到一个队列之类的数据结构中,生产货物所要花费的时间无法预先确定。消费者消耗生产者生产的货物的时间也是不确定的。

Queue模块可以用来进行线程间通讯,让各个线程之间共享数据。现在,我们创建一个队列,让生产者(线程)把新生产的货物放进去供消费者(线程)使用。要达到这个目的,我们要使用到Queue模块的以下属性(见表18.5)。

18.5 threading模块 - 图19

很容易地,我们就能写出例18.9的prodcons.py的代码。

下面是这个脚本的运行输出:

18.5 threading模块 - 图20

如你所见,生产者和消费者不一定是轮流执行的(多亏有了随机数)。实际上,真实生活总是充满了随机性和不确定性。

逐行解释

1 ~ 6行

在这个模块中,我们要使用Queue.Queue对象和我们在例18.7中给出的线程类myThread. MyThread。我们将使用random.randint()函数来随机进行生产和消耗,并从time模块中导入常用的属性。

8 ~ 16行

writeQ()和readQ()函数分别用来把对象放入队列和消耗队列中的一个对象。在这里我们使用字符串‘XXX’来表示队列中的对象。

18 ~ 26行

writer()函数只做一件事,就是一次往队列中放入一个对象,等待一会儿,然后再做指定次数的同样的事,这个次数是由脚本运行时随机生成的。reader()函数做的事比较类似,只是它是用来消耗对象的。

你会注意到,writer睡眠的时间一般会比reader睡眠的时间短。这可以减少reader尝试从空队列中取数据的机会。writer的睡眠时间短,那reader在想要数据的时候总是能拿到数据。

28 ~ 29行

设置有多少个线程要被运行。

例18.9 生产者-消费者问题(prodcons.py)

这个实现中使用了Queue对象和随机地生产(和消耗)货物的方式。生产者和消费者相互独立并且并发地运行。

18.5 threading模块 - 图21

18.5 threading模块 - 图22

31 ~ 47行

最后,就到了main()函数,它与之前的所有脚本的main()函数都很像。先是创建所有的线程,然后运行它们,最后,等两个线程都结束后,得到最后的运行结果。

从本例中,我们可以了解到,一个要完成多项任务的程序,可以考虑每个任务使用一个线程。这样的程序在设计上相对于单线程做所有事的程序来说,更为清晰明了。

本章中,我们看到了单线程的程序在程序性能上的限制。尤其在有相互独立的,运行时间不确定的多个任务的程序里,把多个任务分隔成多个线程同时运行会比顺序运行速度更快。由于Python解释器是单线程的,所以不是所有的程序都能从多线程中得到好处。不过,你已经对Python下的多线程有所了解,在适当的时候,可以利用它来改善程序的性能。