part14:Python并发编程(进程与线程概念,threading模块,继承Thread类创建线程,线程生命周期:新建、就绪、运行、阻塞、死亡,控制线程:join线程、后台线程、线程睡眠,线程同步:线程安全、同步锁、死锁,线程通信:Condition、队列Queue、Event,线程池,线程局部变量,Timer,sched,多进程,)
单线程与多线程区别:
单线程的程序只有一个顺序执行流,多线程的程序可以包含多个顺序执行流,这些顺序执行流之间互不干扰。可以简单理解为:单线程的程序就像只雇一个服务员的餐厅,他必须做完一件事件后才可以做下一件事件;而多线程的程序则如同雇佣多个服务员的餐厅,他们可以同时做多件事情。
Python 语言提供了非常优秀的多线程支持,可以通过简单的方式来启动多线程。与 Python 多线程相关的知识,包括 创建、启动线程,控制线程,以及多线程的同步操作。如何利用 Python 内建支持的线程池来提高多线程的性能。
一、线程概述
操作系统同时有多个任务运行,一个任务就是一个程序,每一个程序就是一个进程。一个程序运行时,内部可能有多个顺序执行流,每一个顺序执行流就是一个线程。
1、线程和进程
操作系统中运行的任务通常对应一个进程(Process),进程具有一定的独立功能,进程是系统进行资源分配和调度的一个独立单位。进程有下面三个特征:
- 独立性:进程是系统中独立存在的实体,拥有独立的资源,拥有私有的地址空间。没有经过进程本身允许,一个用户进程不可以直接访问其他进程的地址空间。
- 动态性:进程与程序的区别在于,程序只是一个静态的指令集合,而进程是一个正在系统中活动的指令集合。在进程中加入了时间的概念。进程具有自己的生命周期和各种不同的状态,在程序中没有这些概念。
- 并发性:多个进程可以在单个处理器上并发执行,多个进程之间不会互相影响。
并发(Concurrency)和并行(Parallel)是两个概念,并行指在同一时刻有多条指令在多个处理器上同时执行;并发指在同一时刻只能有一条指令执行,但多个进程指令被快速轮换执行,使得在宏观上具有多个进程同时执行的效果。
操作系统支持多进程的并发执行,但因为硬件和操作系统的不同而采用不同的策略。常用的策略有:
- 共用式的多任务操作策略,例如 Windows 3.1 和 Mac OS 9操作系统采用这种策略;
- 抢占式的多任务操作策略,其效率更高,目前操作系统大多采用这种策略,例如 Windows NT、Windows 2000 以及 UNIX/Linux 等操作系统。
多线程扩展了多进程,使得同一个进程可以同时并发处理多个任务。线程(Thread)也称作轻量级进程(Lightweight Process),线程是进程的执行单元。就像进程在操作系统中的地位一样,线程在程序(进程)中是独立的、并发的执行流。当进程被初始化后,主线程就被创建了。对于多数应用程序,通常要求有一个主线程,但也可在进程内创建多个顺序执行流,这些顺序执行流就是线程,每一个线程都是独立的。
线程是进程的组成部分,一个进程可以拥有多个线程,一个线程必须有一个父进程。线程可以拥有自己的堆栈、自己的程序计数器和自己的局部变量,但不拥有系统资源,它与父进程的其他线程共享该进程所拥有的全部资源,因为多个线程共享父进程里的全部资源,因此编程更加方便;但必须更加小心,因为需要确保线程不会妨碍同一进程中的其他线程。
线程可以完成一定的任务,可以与其他线程共享父进程中的共享变量及部分环境,相互之间协同来完成进程所要完成的任务。
线程是独立运行的,它并不知道进程中是否还有其他线程存在。线程的运行是抢占式的,也就是说,当前运行的线程在任何时候都可能被挂起,以便另外一个线程可以运行。
一个线程可以创建和撤销另一个线程,同一个进程中的多个线程之间可以并发运行。
简言之,一个程序运行后至少有一个进程,在一个进程中可以包含多个线程,但至少要包含一个主线程。归纳起来,也就是:操作系统可以同时执行多个任务,每一个任务就是一个进程;进程可以同时执行多个任务,每一个任务就是一个线程。
2、多线程的优势
同一个进程下的多个线程共享进程的内存、文件句柄和其他进程应用的状态。进程在执行过程中拥有独立的内存单元,而多个线程共享内存,从而极大提高了程序的运行效率。
线程比进程具有更高的性能,这是由于同一个进程中的线程都有共性,多个线程共享同一个进程的虚拟空间。线程共享的环境包括进程代码段、进程的公有数据等,利用这些共享的数据,线程之间很容易实现通信。
操作系统在创建进程时,必须为该进程分配独立的内存空间,并分配大量的相关资源,但创建线程则简单得多。因此,使用多线程来实现并发比使用多进程的性能要高得多。
所以,使用多线程编程有下面几个优点:
- 进程之间不能共享内存,但线程之间共享内存非常容易。
- 操作系统在创建进程时,需要为该进程重新分配系统资源,但创建线程的代价则小得多。因此,使用多线程来实现多任务并发执行比使用多进程的效率高。
- Python 语言内置了多线程功能支持,而不是单纯的作为底层操作系统的调度方式,从而简化了 Python 的多线程编程。
二、线程的创建和启动
Python 提供了 _thread 和 threading 两个模块来支持多线程。其中 _thread 提供低级别的、原始的线程支持,以及一个简单的锁,通常编程中不建议使用 _thread 模块;而 threading 模块提供了功能丰富的多线程支持。
Python 主要通过两种方式来创建线程:
- 使用 threading 模块的 Thread 类的构造器创建线程。
- 继承 threading 模块的 Thread 类创建线程类。
1、调用 Thread 类的构造器创建线程
threading.Thread 类的构造器如下:
__init__(self, group=None, target=None, name=None, args=(), kwargs=None, *, daemon=None)
这个构造器的参数说明如下:
- group:指定该线程所属的线程组。目前该参数还未实现,因此它只能设为 None。
- target:指定该线程要调用的目标方法。
- args:指定一个元组,以位置参数的形式为 target 指定的函数传入参数。元组的第一个元素传给 targe 函数的第一个参数,元组的第二个元素传给 target 函数的第二个参数......,依此类推。
- kwargs:指定一个字典,以关键字参数的形式为 target 指定的函数传入参数。
- daemon:指定所构建的线程是否为后代线程。
使用 Thread 类构造器创建并启动多线程的步骤如下:
- 调用 Thread 类的构造器创建线程对象,在创建线程对象时,target 参数指定的函数将作为线程执行体。
- 调用线程对象的 start() 方法启动该线程。
下面代码示例使用 Thread 类构造器创建线程对象。
import threading
# 定义一个普通 action 方法,该方法准备作为线程执行体
def action(max):
for i in range(max):
# 调用 threading 模块的 current_thread() 函数获取当前线程
# 调用线程对象的 getName() 方法获取当前线程的名字
print(threading.current_thread().getName() + " " + str(i))
# 下面是主程序,也就是主线程的线程执行体
for i in range(100):
# 调用 threading 模块的 current_thread() 函数获取当前线程
print(threading.current_thread().getName() + " " + str(i))
if i == 20:
# 创建并启动第一个线程
t1 = threading.Thread(target=action, args=(100,))
t1.start()
# 创建并启动第二个线程
t2 = threading.Thread(target=action, args=(100,))
t2.start()
print('主线程执行完成!')
上面代码中主程序(主线程)包含一个循环,当循环变量 i 等于 20 时创建并启动两个新(子)线程,该子线程的 target 为 action,也就是将 action 函数作为线程执行体,接下来调用 start() 方法来启动子线程。运行这段代码的效果如下图1所示:
从输出可以看出,这段程序创建了三个线程,即程序显式创建的两个子线程和主线程。主线程的线程执行体就是程序中的主程序,没有放任何函数中的代码。
在进行多线程编程时,不要忘记代码运行时默认的主线程,主程序部分(没有放在任何函数中的代码)就是主线程的线程执行体。
从上图1还可以看出,这三个线程的执行没有先后顺序,它们以并发方式执行:Thread-1 执行一段时间,然后可能 Thread-2 或 MainThread 获得CPU执行一段时间,接下来又换其他线程执行,这就是典型的线程并发执行,CPU 以快速轮换的方式在多个线程之间切换。从而给用户一种错觉:多个线程似乎同时在执行。
在上面的示例代码中:
- 使用 threading.current_thread() 函数返回当前正在执行的线程对象。
- getName() 是 Thread 类的实例方法,该方法返回调用它的线程名称。
- 此外,还可通过 setName(name) 方法为线程设置名字。
- 上面两个方法都可通过线程的 name 属性来代替。默认的主线程名称是 MainThread,用户启动的子线程名称依次为 Thread-1、Thread-2、Thread-3、...、Thread-n等。
2、继承 Thread 类创建线程类
- 第1步:定义 Thread 类的子类,并重写该类的 run() 方法。run() 方法的方法体就代表了线程需要完成的任务,因此把 run() 方法称为线程执行体。
- 第2步:创建 Thread 子类的实例,即创建线程对象。
- 第3步:调用线程对象的 start() 方法来启动线程。
代码示例如下:
import threading
# 通过继承 threading.Thread 类来创建线程类
class MyThread(threading.Thread):
def __init__(self):
threading.Thread.__init__(self)
self.i = 0
# 重写run() 方法作为线程执行体
def run(self):
while self.i < 100:
# 调用threading 模块的 current_thread() 函数获取当前线程
# 调用线程对象的 getName() 方法获取当前线程的名字
print(threading.current_thread().getName() + " " + str(self.i))
self.i += 1
# 下面是主程序,也就是主线程的线程执行体
for j in range(100):
# 调用 threading 模块的 current_thread() 函数获取当前线程
print(threading.current_thread().getName() + " " + str(j))
if j == 20:
# 创建并启动第一个线程
my1 = MyThread()
my1.start()
# 创建并启动第二个线程
my2 = MyThread()
my2.start()
print('主线程执行完毕!')
推荐使用第一种方式来创建线程(即调用 Thread 类),相对来说编程简单,并且线程直接包装 target 函数,具有更清晰的逻辑结构。
三、线程的生命周期
线程的生命周期要经过新建(New)、就绪(Ready)、运行(Running)、阻塞(Blocked)和死亡(Dead)5种状态。当线程启动后,不能一直“霸占”着CPU独自运行,所以 CPU 需要在多个线程之间切换,于是线程状态也会多次在运行、就绪之间转换。
1、新建和就绪状态
在创建 Thread 对象或 Thread 子类的对象后,该线程就处于新建状态,和其它的 Python 对象一样,此时的线程对象并没有表现出任何线程的动态特征,线程体也不会被执行。
在线程对象调用 start() 方法后,该线程处于就绪状态,Python 解释器会为其创建方法调用栈和程序计数器,处于这种状态中的线程并没有开始运行,只是表示该线程可以运行了。至于该线程何时开始运行,取决于 Python 解释器中线程调度器的调度。
需要注意的是,如果调用线程对象的 run() 方法,则系统会把线程当成一个普通对象,而 run() 方法也是一个普通方法,而不是线程执行体。例如下面代码中的最后两行就调用了线程的run()方法,此时整个程序就只有一个线程:主线程。
import threading, time
# 定义准备作为线程执行体的 action 函数
def action(max):
for i in range(max):
# 当直接调用 run() 方法时,Thread 的 name 属性返回的是该对象的名字
# 而不是当前线程的名字
# 使用 threading.current_thread().name 总是获取当前线程的名字
print(threading.current_thread().name + " " + str(i))
for i in range(50):
# 调用 threading 模块的 current_thread() 函数获取当前线程
print(threading.current_thread().name + " " + str(i))
if i == 20:
# 直接调用线程对象的 run() 方法
# 系统会把线程对象当成普通对象,把 run() 方法当成普通方法
# 所以下面两行代码并不会启动两个线程,而是依次执行两个 run() 方法
threading.Thread(target=action, args=(50,)).run()
threading.Thread(target=action, args=(50,)).run()
运行这段代码,输出的结果中只有一个线程,就是主线程(MainThread)。另外,直接调用线程对象的 run() 方法时,不能直接通过 name 属性(或getName()方法)来获取当前执行线程的名字,需要通过 threading.current_thread() 函数先获取当前线程,再调用线程对象的 name 属性来获取线程的名字。
所以,启动线程的正确方法是调用 Thread 对象的 start() 方法,而不是直接调用 run() 方法,否则程序就成了单线程。
此外,在调用线程对象的 run() 方法后,该线程已经不再处于新建状态,不要再次调用线程对象的 start() 方法。如果对同一个线程重复调用 start() 方法,将引发 RuntimeError 异常。
2、运行和阻塞状态
如果处于就绪状态的线程获得了 CPU,开始执行 run() 方法的线程执行体,则该线程处于运行状态。如果计算机只有一个CPU,那么在任何时刻只有一个线程处于运行状态。如果有多个处理器,则会有多个线程并行(Parallel)执行;当线程数大于处理器数时,依然会存在多个线程在同一个CPU上轮换的情况。
当一个线程开始运行后,它不可能一直处于运行状态,线程在运行过程中需要被中断,使得其他线程获得执行的机会,线程调度的细节由底层平台所采用的策略决定。对于抢占式调度策略的系统而言,系统会给每一个可执行的线程一个小时间段来处理任务;当该时间段用完后,系统就会剥夺该线程所占用的资源,让其他线程获得执行的机会。在选择下一个线程时,系统会考虑线程的优先级。
现在的电脑端桌面系统和服务器操作系统都采用抢占式调度策略。一些小型设备如手机等可能采用协作式调度策略,在这样的系统中,只有当一个线程调用了它的 sleep() 或 yield() 方法后才会放弃其所占用的资源,也就是必须由该线程主动放弃其所占用的资源。
当发生下面情况时,线程就会发生阻塞状态:
- 线程调用 sleep() 方法主动放弃所占用的处理器资源。
- 线程调用了一个阻塞式 I/O 方法,在该方法返回之前,该线程被阻塞。
- 线程试图获得一个锁对象,但该锁对象正被其他线程所持有。
- 线程在等待某个通知(Notify)。
正在执行的线程被阻塞后,其他线程就可获得执行的机会。被阻塞的线程在合适的时候重新进入就绪状态,注意是就绪状态,不是运行状态。也就是说,被阻塞的线程阻塞解除后,必须重新等待线程调度器再次调度它。
当发生下面的特定情况时可以解除阻塞,该线程重新进入就绪状态。
- 调用 sleep() 方法的线程经过了指定的时间。
- 线程调用的阻塞式 I/O 方法已经返回。
- 线程成功获得了试图获取的锁对象。
- 线程正在等待某个通知时,其他线程发出了一个通知。
从上图可看出,处于阻塞状态的线程只能进入就绪状态,不能直接进入运行状态。就绪和运行状态之间的转换由系统线程调度所决定,当处于就绪状态的线程获得处理器资源时,该线程就进入运行状态;当处于运行状态的线程失去处理器资源时,该线程进入就绪状态。
3、线程死亡
线程会以如下三种方式结束,结束后就处于死亡状态。
- run() 方法或代表线程执行体的 target 函数执行完成,线程正常结束。
- 线程抛出一个未捕获的 Exception 或 Error。
当主线程结束时,子线程不受影响,子线程一旦启动后,它拥有和主线程相同的地位,它不会受主线程的影响。线程的 is_alive()可以测试某个线程是否已经死亡,当线程处于就绪、运行、阻塞三种状态时,该方法将返回 True;当线程处于新建、死亡两种状态时,该方法就返回 False。
另外,不要对已经死亡的线程调用 start() 方法使它重新启动,该线程不可再次作为线程运行。
下面代码对处于死亡状态的线程再次调用 start() 方法。
import threading
# 定义 action 函数准备作为线程执行体使用
def action(max):
for i in range(max):
print(threading.current_thread().name + " " +str(i))
# 创建线程对象
sd = threading.Thread(target=action, args=(100,))
for i in range(300):
# 调用 threading.current_thread() 函数获取当前线程
print(threading.current_thread().name + " " + str(i))
if i == 20:
# 启动线程
sd.start()
# 判断启动后线程的 is_alive() 值,输出 True
print(sd.is_alive())
# 当线程处于新建、死亡两种状态时,is_alive() 方法返回 False
# 当 i > 20 时,该线程肯定已经启动过了,如果 sd.is_alive() 为 False
# 那么就处于死亡状态了
if i > 20 and not(sd.is_alive()):
# 试图再次启动该线程
sd.start()
运行上面这段代码,引发异常信息 RuntimeError: threads can only be started once。这表明处于死亡状态的线程无法再次运行。
对处于新建状态的线程两次 start() 方法也是错误的,也会引发 RuntimeError 异常。
Python的线程不支持优先级,不支持线程组,线程不支持 destroy()、stop()、suspend()、resume()和interrupt() 方法。
四、控制线程
Python 的线程支持有一些便捷工具方法,可以用来很好的控制线程的执行。
1、join线程
join() 方法让一个线程等待另一个线程完成的方法。当在程序执行流中调用其他线程的 join() 方法时,调用线程将被阻塞,直到被join() 方法加入当 join 线程执行完成。
join() 方法通常由使用线程的程序调用,以将大问题划分成许多小问题,并为每个小问题分配一个线程。当所有的小问题得到处理后,再调用主线程来进一步操作。
示例如下:
import threading
# 定义 action 函数准备作为线程执行体使用
def action(max):
for i in range(max):
print(threading.current_thread().name + " " + str(i))
# 当前程序就是主线程,下面这行代码启动第1个子线程
threading.Thread(target=action, args=(100, ), name='新线程').start()
for i in range(100):
# 当 i 等于 20时,启动第2个子线程
if i == 20:
jt = threading.Thread(target=action, args=(100,), name="被Join的线程")
jt.start()
# 主线程调用了 jt 线程的 join() 方法
# 所以主线程必须等 jt 线程执行结束才会向下执行
# 此时实际只有 jt 线程和第1个子线程并发执行
jt.join()
print(threading.current_thread().name + " " + str(i))
运行效果如下图所示:
join(timeout=None) 方法有一个 timeout 参数,该参数指定最长等待时长秒数。如果在 timeout 秒内被 join 的线程还没有执行结束,则不再等待。
2、 后台线程
-
后台线程(Daemon Thread)也叫“守护线程”或“精灵线程”,它运行在后台,任务是为其它线程提供服务。Python 解释器的垃圾回收线程就是典型的后台线程。
-
后台线程特征:如果所有的前台线程都死亡,后台线程会自动死亡。
-
调用 Thread 对象的 daemon 属性可以将指定线程设置成后台线程。
示例如下:
import threading
# 定义后台线程的线程执行体与普通线程体没有任何区别
def action(max):
for i in range(max):
print(threading.current_thread().name + " " + str(i))
t = threading.Thread(target=action, args=(100,), name='后台线程')
# 将此线程设置为后台线程
# 也可在创建 Thread 对象时通过 daemon 参数将其设置为后台线程
t.daemon = True
# 启动后台线程
t.start()
for i in range(10):
print(threading.current_thread().name + " " + str(i))
# 程序执行到这里,前台线程(主线程)结束
# 后台线程也会随之结束,没有执行完也会结束
在上面代码中,主线程默认是前台线程,t 线程默认也是前台线程。前台线程创建的子线程默认是前台线程;后台线程创建的子线程默认是后台线程。所以,创建后台线程有两种方式:
- 主动将线程的 daemon 属性设置为 True。
- 后台线程启动的线程默认是后台线程。
注意:当前台线程死亡后,Python 解释器会通知后台线程死亡,但是从它接收指令到做出响应需要一定的时间。要将某个线程设置后为后台线程,必须在该线程启动之前进行设置,也就是将 daemon 属性设为 True,必须在 start() 方法调用之前进行,否则会引发 RuntimeError 异常。
3、 线程睡眠:sleep
正在执行的线程调用 time 模块的 sleep(secs) 函数,可让该线程暂停 secs秒,并进入阻塞状态。即使系统中没有其他可执行的线程,处于 sleep() 中的线程也不会执行。
五、线程同步
1、线程安全问题
当有多个线程操作同一个时数据时,很容易“偶然”出现线程安全问题。一个典型的问题就是银行取钱问题,银行取钱可以分为下面几个步骤:
- 用户输入账户、密码,系统判断用户的账户、密码是否匹配;
- 用户输入取款金额
- 系统判断账户余额是否大于取款金额;
- 如果余额大于取款金额,则取款成功;如果余额小于取款金额,则取款失败。
下面用代码模块取钱的后三个步骤,先定义一个账户类,该类封装了账户编号和余额两个成员变量。
# 该代码所在的文件名称是:Account.py
class Account():
# 定义构造器
def __init__(self, account_no, balance):
self.account_no = account_no
self.balance = balance
下面代码定义一个模拟取钱函数,根据执行账户、取钱数量进行取钱操作。在代码中仅创建一个账户,并启动两个线程从该账户中取钱。代码如下:
import threading, time
from Account import *
# 定义一个函数来模拟取钱操作
def draw(account, draw_amount):
# 账户余额大于取钱数目
if account.balance >= draw_amount:
# 吐出钞票
print(threading.current_thread().name + "取钱成功!吐出钞票:" + str(draw_amount))
# time.sleep(0.001)
# 修改金额
account.balance -= draw_amount
print("\t余额为:" + str(account.balance))
else:
print(threading.current_thread().name + "取钱失败!余额不足!")
# 创建一个账户
acct = Account('123456', 1000)
# 使用两个线程模拟从同一个账户中取钱
threading.Thread(name='he', target=draw, args=(acct, 800)).start()
threading.Thread(name='she', target=draw, args=(acct, 800)).start()
当把 time.sleep(0.001) 这行代码取消注释后,运行上面这段代码总是错误的结果。如果注释掉这行代码,可能运行上万次或者十万次会出现错误的结果。出现错误的原因是因为线程调度的不确定性,这种运行结果并不是银行所期望的。运行这段代码只要出现一次错误,那就是编程错误引起的。
2、 同步锁(Lock)
上面的代码运行可能会出现错误结果,是因为 run() 方法的方法体不具有线程安全性。为了让线程更安全,threading 模块引入了锁(Lock),threading 模块提供了 Lock 和 RLock 两个类,这两个类都用如下两个方法来加锁和释放锁。
- acquire(blocking=True, timeout=-1):请求对 Lock 或 RLock 加锁,timeout 参数指定加锁多少秒。
- release():释放锁。
Lock 和RLock 的区别如下:
- threading.Lock:是一个基本的锁对象,每次只能锁定一次,其余的锁请求,需等待锁释放后才能获取。
- threading.RLock:是可重入锁(Reentrant Lock)。对于可重入锁,在同一个线程中可以对它进行多次锁定,也可以多次释放。如果使用 RLock,那么 acquire() 和 release() 方法必须成对出现。如果调用 n 次 acquire() 加锁,则必须调用 n 次 release() 才能释放锁。
- RLock 对象会维持一个计数器来追踪 acquire() 方法的嵌套使用。
在实现线程安全的控制中,比较常用的是 RLock。通常使用 RLock 的代码格式如下:
class X:
# 定义需要保证线程安全的方法
def m():
# 加锁
self.lock.acquire()
try:
# 需要保证线程安全的代码
# ... 方法体
# 使用 finally 块来保证释放锁
finally:
# 修改完成,释放锁
self.lock.release()
上面代码中使用 finally 块来确保在必要时释放锁。
使用 Lock 对象可以实现线程安全的类,线程安全的类具有如下特征:
- 该类的对象可以被多个线程 安全 的访问。
- 每个线程在调用该对象的任意方法后,都将得到正确的结果。
- 每个线程在调用该对象的任意方法后,该对象都依然保持合理的状态。
下面代码对 Account 类进行修改,增加线程安全。修改后的代码如下:
import threading, time
class Account:
# 定义构造器
def __init__(self, account_no, balance):
# 封装账户编号和账户余额两个变量
self.account_no = account_no
self._balance = balance
self.rlock = threading.RLock()
# 因为账户余额不允许随便修改,所以只为 self._balance 提供 getter 方法
def getBalance(self):
return self._balance
# 提供一个线程安全的 draw() 方法来完成取钱操作
def draw(self, draw_amount):
# 加锁
self.rlock.acquire()
try:
# 账户余额大于取钱数目
if self._balance > draw_amount:
# 吐出钞票
print(threading.current_thread().name + "取钱成功!吐出钞票:" + str(draw_amount))
time.sleep(0.001)
# 修改金额
self._balance -= draw_amount
print("\t余额为:" + str(self._balance))
else:
print(threading.current_thread().name + "取钱失败!余额不足!")
finally:
# 修改完成,释放锁。最后使用 finally 块来确保释放锁
self.rlock.release()
下面代码执行取钱操作:
import threading
from Account import *
# 创建一个账户
acct = Account('123456', 1000)
# 使用两个线程模拟从同一个账户中取钱
threading.Thread(name='he', target=acct.draw, args=(800,)).start()
threading.Thread(name='she', target=acct.draw, args=(800,)).start()
现在重复执行上面这段代码,也不会出现线程安全的问题。
在面向对象中有一种流行的设计方式: Domain Driven Design (领域驱动设计,ODD ) , 这种方式认为每个类都应该是完备的领域对象。
可变类的线程安全是以降低程序的运行效率作为代价的,为了减少线程安全所带来的负面影响,可以采用如下策略。
- 不要对线程安全类的所有方法都进行同步,只对那些会改变竞争资源(竞争资源也就是共享资源)的方法进行同步。例如,上面Account 类中的 account_no 实例变量就无须同步,所以程序只对 draw() 方法进行了同步控制。
- 如果可变类有两种运行环境:单线程环境和多线程环境, 则应该为该可变类提供两种版本,即线程不安全版本和线程安全版本。在单线程环境中使用钱程不安全版本以保证性能,在多线程环境中使用线程安全版本。
3、死锁
当两个线程相互等待对方释放同步监视器时就会发生死锁。Python 解释器没有监测, 也没有采取措施来处理死锁情况, 所以在进行多线程编程时应该采取措施避免出现死锁。一旦出现死锁,整个程序既不会发生任何异常, 也不会给出任何提示,只是所有线程都处于阻塞状态,无法继续。
死锁是很容易发生的,尤其是在系统中出现多个同步监视器的情况下,下面代码就会出现死锁。
import threading
import time
class A:
def __init__(self):
self.rlock = threading.RLock()
def foo(self, b):
try:
self.rlock.acquire()
print("当前线程名:" + threading.current_thread().name + " 进入了A实例的foo()方法")
time.sleep(0.2)
print("当前线程名:" + threading.current_thread().name + " 企图调用B实例的 last() 方法")
b.last()
finally:
self.rlock.release()
def last(self):
try:
self.rlock.acquire()
print("进入了A类的last()方法内部")
finally:
self.rlock.release()
class B:
def __init__(self):
self.rlock = threading.RLock()
def bar(self, a):
try:
self.rlock.acquire()
print("当前线程名:" + threading.current_thread().name + " 进入了B实例的bar()方法")
time.sleep(0.2)
print("当前线程名:" + threading.current_thread().name + " 企图调用A实例的last()方法")
a.last()
finally:
self.rlock.release()
def last(self):
try:
self.rlock.acquire()
print("进入了B类的last()方法内部")
finally:
self.rlock.release()
a = A()
b = B()
def init():
threading.current_thread().name = '主线程'
# 调用 a 对象的 foo() 方法
a.foo(b)
print("进入了主线程之后")
def action():
threading.current_thread().name = '副线程'
# 调用b对象的 bar() 方法
b.bar(a)
print("进入了副线程之后")
# 以action 为 target 启动新线程
threading.Thread(target=action).start()
# 调用 init() 函数
init()
运行上面这段代码,输出如下:
当前线程名:副线程 进入了B实例的bar()方法
当前线程名:主线程 进入了A实例的foo()方法
当前线程名:副线程 企图调用A实例的last()方法
当前线程名:主线程 企图调用B实例的 last() 方法
(在这里僵持住,只有手动停止)
死锁不应该出现在代码中,要尽量避免出现死锁。下面几种常见方式可用来解决死锁问题:
- 避免多次锁定:尽量避免同一个线程对多个 Lock 进行锁定。
- 具有相同的加锁顺序:如果多个线程需要对多个 Lock 进行锁定,则应该保证它们以相同的顺序请求加锁。
- 使用定时锁:在调用 acquire() 方法加锁时可指定 timeout 参数,让超过 timeout 秒后自动释放对 Lock 的锁定,这样可以解开死锁。
- 死锁检测:一种依靠算法机制来实现的死锁预防机制,主要针对那些不可能按实现顺序加锁,也不能使用定时锁的场景的。
六、线程通信
Python 通过线程通信来保证线程协调运行。
1、使用 Condition 实现线程通信
Condition 可让已经得到 Lock 对象却无法继续执行的线程释放 Lock 对象,Condition 对象也可唤醒其他处于等待状态的线程。
Condition 对象与 Lock 对象组合使用,可为每个对象提供多个等待集(wait-set)。Condition 对象总是需要有对应的 Lock 对象,从构造函数 __init__(self, lock=None)
可看出,在创建 Condition 时可通过 lock 参数传入要绑定的 Lock 对象;如果不指定lock 参数,在创建 Condition 时它会自动创建一个与之绑定的 Lock 对象。
Condition 类提供有如下几个方法:
- acquire([timeout]) / release ():调用Condition 关联的 Lock 的 acquire() 或 release() 方法。
- wait([timeout]) :导致当前线程进入Condition 的等待池等待通知并释放锁,直到其他线程调用该Condition 的 notify() 或 notify_all() 方法来唤醒该线程。在调用该 wait() 方法时可传入一个 timeout 参数,指定该线程最多等待多少秒。
- notify():唤醒在该 Condition 等待池中的单个线程并通知它,收到通知的线程将自动调用 acquire() 方法尝试加锁。如果所有线程都在该Condition 等待池中等待,则会选择唤醒其中一个线程,选择是任意性的。
- notify_ all() :唤醒在该Condition 等待池中等待的所有线程并通知它们。
还是以前面的存钱和取钱程序为例,这次使用一个旗标来标识账户中是否已有存款,旗标为 False 表明账户没有存款,存款线程可向下执行;当存入钱后旗标设为 True,并调用 Condition 的 notify() 或 notify_all() 方法唤醒其他线程;当存钱线程进入线程体后,如果旗标为 True,就调用 Condition 的 wait() 方法让线程等待。
当旗标为 True 时,表明账户中有存入钱,取钱线程可以向下执行,钱取出后将旗标设为 False,并调用 Condition 的 notify() 或 notify_all() 方法唤醒其他线程;当取钱线程进入线程体后,如果旗标为 False,就调用 wait() 方法让该线程等待。
这次修改后的 Account 类有 draw() 和 deposit() 两个方法,对应取钱和存钱操作。由于这两个方法需要并发修改 Account 类的 self._balance 成员变量的值,所以它们都使用 Lock 来控制线程安全。另外,这两个方法还使用 Condition 的 wait() 和 notify_all() 来控制线程通信。
下面代码所在的文件名是 Account.py,代码如下:
import threading
class Account:
# 定义构造器
def __init__(self, account_no, balance):
# 封装账户编号和账户余额两个成员变量
self.account_no = account_no
self._balance = balance
self.cond = threading.Condition()
# 定义代表是否已经存钱的旗标
self._flag = False
# 因为账户余额不允许随便修改,所以只为 self._balance 提供 getter 方法
def getBalance(self):
return self._balance
# 提供一个线程安全的 draw() 方法来完成取钱操作
def draw(self, draw_amount):
# 加锁,相当于调用 Condition 绑定的 Lock 的 acquire()
self.cond.acquire()
try:
# 如果 self._flag 为 False,表明账户中还没有人存钱进去,取钱方法被阻塞
if not self._flag:
self.cond.wait()
else:
# 执行取钱操作
print(threading.current_thread().name + " 取钱:" + str(draw_amount))
self._balance -= draw_amount
print("账户余额为:" + str(self._balance))
# 将表明账户中是否已有存款的旗标设为 False
self._flag = False
# 唤醒其他线程
self.cond.notify_all()
# 使用 finally 块来释放锁
finally:
self.cond.release()
# 线程案例的存钱函数
def deposit(self, deposit_amount):
# 加锁,相当于调用 Condition 绑定的 Lock 的 acquire()
self.cond.acquire()
try:
# 如果 self._flag 为 True,表明账户中有人存钱进去,存款方法被阻塞
if self._flag:
self.cond.wait()
else:
# 执行存款操作
print(threading.current_thread().name + " 存款:" + str(deposit_amount))
self._balance += deposit_amount
print('账户余额为:' + str(self._balance))
# 将表明账户中是否已有存款的旗标设为 True
self._flag = True
# 唤醒其他线程
self.cond.notify_all()
# 使用 finally 块来释放锁
finally:
self.cond.release()
主程序代码如下,主程序文件名是 draw_deposity.py:
import threading
import Account
# 定义一个函数,模拟重复 max 次执行取钱操作
def draw_many(account, draw_amount, max):
for i in range(max):
account.draw(draw_amount)
# 定义一个函数,模拟重复 max 次执行存款操作
def deposit_many(account, deposit_amount, max):
for i in range(max):
account.deposit(deposit_amount)
# 创建一个账户,初始余额为 0
acct = Account.Account("123456", 0)
# 创建并启动一个 ”取钱“ 线程
threading.Thread(name="取钱者", target=draw_many, args=(acct, 800, 100)).start()
# 创建并启动一个”存款“线程
threading.Thread(name='存款者甲', target=deposit_many, args=(acct, 800, 100)).start()
threading.Thread(name='存款者乙', target=deposit_many, args=(acct, 800, 100)).start()
threading.Thread(name='存款者丙', target=deposit_many, args=(acct, 800, 100)).start()
执行这段 draw_deposity.py 文件代码,由于有3个存款者线程随机向账户中存钱,只有一个取钱者线程执行取钱操作。只有当取钱者取钱后,存钱者线程才可以存钱;反之亦然。
程序运行到最后会被阻塞无法继续向下执行。这是因为3个存款者线程共有300次存钱操作,但1个取钱者只有100次取钱操作,所以程序最后会被阻塞。
要注意的是,阻塞并不是死锁。这里由于取钱者线程已经执行结束,而存钱者线程只是在等待其他线程来取钱而已,并不是等待其他线程释放同步监视器。不要把死锁和阻塞等同起来。
2、使用队列(Queue)控制线程通信
queue 模块提供的阻塞队列可用于实现线程通信。该模块主要提供三个类,分别代表三种队列,主要区别在于进队列、出队列的不同。这三个队列类如下:
- queue. Queue(maxsize= 0):代表FIFO (先进先出)的常规队列, maxsize 可以限制队列的大小。达到上限 maxsize 值,就会加锁,再次加入元素时就会被阻塞,直到队列中的元素被消费。maxsize 为0或负数,则该队列的大小无限制。
- queue.LifoQueue(maxsize = 0):代表 LIFO(后进先出)的队列,与上一个的区别是出队列的顺序不同。
- PriorityQueue(maxsize = 0):代表优先级队列,优先级最小的元素先出队列。
这三个队列类的属性和方法基本相同, 它们都提供了如下属性和方法。
- Queue.qsize() : 返回队列的实际大小,也就是该队列中包含几个元素。
- Queue.empty() :判断队列是否为空。
- Queue. full() : 判断队列是否己满。
- Queue.put(item, block=True,timeout = None) :向队列中放入元素。如果队列己满,且block参数为True (阻塞),当前线程被阻塞, timeout 指定阻塞时间,如果将timeout 设置为None,则代表一直阻塞, 直到该队列的元素被消费:如果队列己满,且block 参数为False (不阻塞〉,则直接引发queue.FULL 异常。
- Queue.put_nowait(item):向队列中放入元素,不阻塞。相当于在上一个方法中将 block 参数设置为False 。
- Queue.get(item, block=True, timeout=None):从队列中取出元素(消费元素) 。如果队列己满, 且block 参数为True (阻塞), 当前线程被阻塞, timeout 指定阻塞时间,如果将 timeout 设置为 None ,则代表一直阻塞,直到有元素被放入队列中; 如果队列己空,且block 参数为False (不阻塞),则直接引发queue.EMPTY 异常。
- Queue.get_nowait(item):从队列中取出元素,不阻塞。相当于在上一个方法中将block 参数设置为False。
下面代码测试 Queue 的 put() 和 get() 方法。
import queue
# 定义一个长度为 2 的阻塞队列
bq = queue.Queue(2)
bq.put('Python')
bq.put('JavaScript')
print('hello world') # 在阻塞之前可以正常输出
bq.put('HMTL') # 阻塞线程,下面的代码得不到执行
print(bq.get())
print(bq.get())
print(bq.get()) # 队列已为空,下面的代码同样会被阻塞
print('hello python')
在上面代码中,get() 方法取出队列中的元素,如果队列为空时,同样会阻塞线程。
下面代码利用 Queue 来实现线程通信。代码如下:
import threading
import time
import queue
def product(bq):
str_tuple = ("Python", "JavaScript", "HTML")
for i in range(99999):
print(threading.current_thread().name + "准备生产元组元素!")
time.sleep(0.2)
# 尝试放入元素,如果队列已满,则线程被阻塞
bq.put(str_tuple[ i % 3])
print(threading.current_thread().name + "生产元组元素完成!")
def consume(bq):
while True:
print(threading.current_thread().name + "消费者准备消费元组元素!")
time.sleep(0.2)
# 尝试取出元素,如果队列为空,则线程被阻塞
t = bq.get()
print(threading.current_thread().name + "消费者消费[ %s ]元素完成" % t)
# 创建一个容量为 1 的 Queue
bq = queue.Queue(maxsize=1)
# 启动三个生产者线程
threading.Thread(target=product, args=(bq, )).start()
threading.Thread(target=product, args=(bq, )).start()
threading.Thread(target=product, args=(bq, )).start()
# 启动一个消费者线程
threading.Thread(target=consume, args=(bq, )).start()
上面代码中,有三个生产者线程都向 Queue 中放入元素,但只要其中一个生产者线程向该队列中放入元素后,其他生产者线程就必须等待,等待消费者线程取出 Queue 队列中的元素。
3、使用 Event 控制线程通信
Event 是一种非常简单的线程通信机制: 一个线程发出一个Event , 另一个线程可通过该 Event 被触发。
Event 管理着一个内部旗标,通过 set() 方法将该旗标设置为 True,也可调用 clear() 方法将该旗标设置为False。可以调用 wait() 方法来阻塞当前线程,直到 Event 的内部旗标被设置为 True。Event 提供的方法有:
- is_set():返回 Event 的内部旗标是否为 True。
- set():将 Event 的内部旗标设置为 True,并唤醒所有处于等待状态的线程。
- clear():将 Event 内部旗标设置为 False,通常接下来会调用 wait() 方法来阻塞当前线程。
- wait(timeout = None):阻塞当前线程。
Event 的简单用法示例如下:
import threading
import time
event = threading.Event()
def cal(name):
# 等待事件,进入等待阻塞状态
print('%s 启动' % threading.currentThread().getName())
print('%s 准备开始计算状态' % name)
event.wait() # 线程进入阻塞状态
# 收到事件后进入运行状态
print('%s 收到通知了。' % threading.currentThread().getName())
print('%s 正式开始计算!' % name)
# 创建并启动两个线程,它们都会在 event.wait() 代码处等待
threading.Thread(target=cal, args=('甲', )).start()
threading.Thread(target=cal, args=('乙' )).start()
time.sleep(2)
print('-' * 30)
# 发出事件
print('主线程发出事件')
event.set() # 子线程结束阻塞状态
上面代码中,event.wait() 行的代码会让子线程进入阻塞状态,即使主线程在 time.sleep(2) 代码处被阻塞,两个子线程也不会向下执行。
直到主线程最后调用 event.set() 方法将 Event 的内部旗标设置为 True,并唤醒所有等待的线程,子线程才能向下执行。上面代码的运行结果如下所示:
Thread-1 启动
甲 准备开始计算状态
Thread-2 启动
乙 准备开始计算状态
------------------------------
主线程发出事件
Thread-2 收到通知了。
乙 正式开始计算!
Thread-1 收到通知了。
甲 正式开始计算!
Event 有点类似于 Condition 和旗标的结合体,但 Event 本身并不带 Lock 对象,因此,如果要实现线程同步,还需要额外的 Lock 对象。下面用 Event 改写 Account.py 文件,代码如下:
import threading
class Account:
# 定义构造器
def __init__(self, account_no, balance):
# 封装账户编号和账户余额两个成员变量
self.account_no = account_no
self._balance = balance
self.lock = threading.Lock()
self.event = threading.Event()
# 因为账户余额不允许随便修改,所以只为 self._balance提供 getter 方法
def getBalance(self):
return self._balance
# 提供一个线程安全的 draw() 方法来完成取钱操作
def draw(self, draw_amount):
# 加锁
self.lock.acquire()
# 如果 Event 的内部旗标为 True,则表明账户中已有人存钱进去
if self.event.is_set():
# 执行取钱操作
print(threading.current_thread().name + " 取钱:" + str(draw_amount))
self._balance -= draw_amount
print("账户余额为:" + str(self._balance))
# 将 Event 的内部旗标设置为 False
self.event.clear()
# 释放锁
self.lock.release()
# 阻塞当前线程
self.event.wait()
else:
# 释放锁
self.lock.release()
# 阻塞当前线程
self.event.wait()
def deposit(self, deposti_amount):
# 加锁
self.lock.acquire()
# 如果 Event 的内部旗标为 False,则表明账户中还没有人存钱进去
if not self.event.is_set():
# 执行存钱操作
print(threading.current_thread().name + " 存钱:" + str(deposti_amount))
self._balance += deposti_amount
print("账户余额为:" + str(self._balance))
# 将 Event 的内部旗标设置为 True
self.event.set()
# 释放锁
self.lock.release()
# 阻塞当前线程
self.event.wait()
else:
# 释放锁
self.lock.release()
# 阻塞当前线程
self.event.wait()
七、线程池
启动一个新线程成本较高,因为涉及到与操作系统的交互。所以需要使用线程池来提升性能,在需要创建大量生存期很短的线程时,更应该使用线程池。
线程池在启动时就创建大量的空闲线程,只要将一个函数提交给线程池,线程池就会启动一个空闲的线程来执行它。当该函数执行结束后,该线程并不会死亡,而是再次返回到线程池中变成空闲状态,等待执行下一个函数。
线程池可有效控制系统中并发线程的数量。当系统中包含有大量的并发线程时,会导致系统性能急剧下降,甚至导致 Python 解释器崩溃。线程池的最大线程数参数可以控制系统中并发线程的数量不超过此数。
1、使用线程池
线程池基类是 concurrent.futures 模块中的 Executor,Executor有两个子类,是 ThreadPoolExecutor 和 ProcessPoolExecutor ,分别用于创建线程池和进行池。只要将相应的 task(任务) 函数提交给线程池/进行池,剩下的就交由线程池/进程池来完成。
Executor 提供的常用方法有:
- submit(fn,*args,**kwargs):将 fn 函数提交给线程池。args 是传给 fn 函数的参数,kwargs 是以关键字参数形式为 fn 函数传入参数。
- map(func, *iterables, timeout=None, chunksize=1) :这个函数类似于全局函数 map(func, iterables),不同处在于这个函数会启动多个线程,以异步方式立即对 iterables 执行 map 处理。
- shutdown(wait=True):关闭线程池。
将 task 函数提交( submit )给线程池后, submit 方法会返回一个Future 对象, Future 类主要用于获取线程任务函数的返回值。由于线程任务会在新线程中以异步方式执行,因此,线程执行的函数相当于一个“将来完成”的任务,所以Python 使用 Future 来代表。Future 类提供了下面这些方法:
- cancel():取消该 Future 代表的线程任务。如果该任务正在执行,不可取消,则该方法返回False,否则返回True。
- canelled():返回 Future 代表的线程任务是否被成功取消。
- running():如果该 Future 代表的线程任务正在执行、不可取消,该方法返回 True。
- done():如果该 Future 代表的线程任务被成功取消或执行完成,则该方法返回 True。
- result(timeout=None):获取该 Future 代表的线程任务最后返回的结果。如果 Future 代表的线程任务还未完成,该方法将会阻塞当前线程,timeout 参数指定阻塞多少秒。
- exception(timeout=None):获取该 Future 代表的线程任务所引发的异常。如果该任务成功完成,没有异常,则该方法返回 None。
- add_done_callback(fn):为该 Future 代表的线程任务注册一个 回调函数,当该任务成功完成时,程序会自动触发该 fn 函数。
线程池调用 shutdown() 方法后,将启动线程池的关闭序列,此时线程池不在接收新任务,但会将所有已提交的任务执行完成。当线程池中的所有任务执行完成后,该线程池中的所有线程都会死亡。
线程池的使用步骤如下:
- 调用 ThreadPoolExecutor 类的构造器创建一个线程池。
- 定义一个普通函数作为线程任务。
- 调用 ThreadPoolExecutor 对象的 submit() 方法来提交线程任务。
- 当不再提交任何任务时,调用 ThreadPoolExecutor 对象的 shutdown() 方法关闭线程池。
线程池的使用示例如下所示:
from concurrent.futures import ThreadPoolExecutor
import threading
import time
# 定义一个准备作为线程任务的函数
def action(max):
my_sum = 0
for i in range(max):
print(threading.current_thread().name + ' ' + str(i))
my_sum += i
return my_sum
# 创建一个包含两个线程的线程池
pool = ThreadPoolExecutor(max_workers=2)
# 向线程池中提交一个任务,50 会作为 action() 函数的参数
future1 = pool.submit(action, 50)
# 向线程池再提交一个任务,100 会作为 action() 函数的参数
future2 = pool.submit(action, 100)
# 判断 future1 代表的任务是否结束
print(future1.done())
time.sleep(3)
# 判断 future2 代表的任务是否结束
print(future2.done())
# 查看 future1 代表的任务返回的结果
print(future1.result())
# 查看 future2 代表的任务返回的结果
print(future2.result())
# 关闭线程池
pool.shutdown()
要注意的是,在使用Future的result()方法来获取结果时,该方法会阻塞当前线程,如果没有指定 timeout 参数,当前线程将一直属于阻塞状态,直到 Future 代表的任务返回。
2、获取执行结果 add_done_callback()方法
Future 的 result() 方法返回结果时会阻塞当前主线程。Future 提供的另一个 add_done_callback()方法,通过添加回调函数方式,该回调函数形如 fn(future)。当线程执行完成后,会自动触发回调函数,并将对应的 Future 对象作为参数传给该回调函数。
Future 模块的 add_done_callback()方法使用示例如下:
from concurrent.futures import ThreadPoolExecutor
import threading
import time
# 定义一个准备作为线程任务的函数
def action(max):
my_sum = 0
for i in range(max):
print(threading.current_thread().name + ' ' + str(i))
my_sum += i
return my_sum
# 创建一个包含两个线程的线程池
with ThreadPoolExecutor(max_workers=2) as pool:
# 向线程池中提交一个任务,50作为 action() 函数的参数
future1 = pool.submit(action, 50)
# 向线程池中再提交一个任务,100作为action() 函数的参数
future2 = pool.submit(action, 100)
def get_result(future):
print(future.result())
# 为 future1 添加线程完成的回调函数
future1.add_done_callback(get_result)
# 为 future2 添加线程完成的回调函数
future2.add_done_callback(get_result)
print('-' * 30) # 测试主线程是否被阻塞
上面这段代码的最后一行,用于测试主线程是否会被阻塞,从输出可以看出,主线程没被阻塞,并且两个新的子线程并发执行,当线程任务执行完成后,get_result() 函数被触发,输出线程任务的返回值。
另外,由于线程池实现了上下文管理协议( Context Manage Protocol ),因此,可以使用 with 语句来管理线程池,这样即可避免手动关闭线程池,如上面的代码所示。
此外, Exectuor 还提供了一个 map(func , *iterables, timeout=None, chunksize=1 )方法,该方法的功能类似于全局函数 map(), 区别在于线程池的 map() 方法会为 iterables 的每个元素启动一个线程,以并发方式来执行 func 函数。这种方式相当于启动 len(iterables)个线程,井收集每个线程的执行结果。示例如下:
from concurrent.futures import ThreadPoolExecutor
import threading
import time
# 定义一个准备作为线程任务的函数
def action(max):
my_sum = 0
for i in range(max):
print(threading.current_thread().name + ' ' + str(i))
my_sum += i
return my_sum
# 创建一个包含4个线程的线程池
with ThreadPoolExecutor(max_workers=4) as pool:
# 使用线程执行 map 计算
# 后面的元组有 3 个元素,因此程序启动 3 个线程来执行 action 函数
results = pool.map(action, (50, 100, 150))
print('-' * 20)
for r in results:
print(r)
八、线程相关类
1、线程局部变量 local() 函数
线程局部变量(Thread Local Variable)的作用,是为每一个使用该变量的线程都提供一个变量的副本,使每一个线程都可以独立地改变自己的副本,而不会和其他线程的副本冲突。从线程的角度看,就好像每一个线程都完全拥有该变量一样。
threading 模块的 local() 函数可以返回一个线程局部变量,可以简捷的隔离多线程访问的竞争资源,简化多线程并发访问的编程处理。
线程局部变量的作用示例如下:
import threading
from concurrent.futures import ThreadPoolExecutor
# 定义线程局部变量
mydata = threading.local()
# 定义准备作为线程执行体使用的函数
def action(max):
for i in range(max):
try:
mydata.x += i
except:
mydata.x = i
# 访问 mydata 的 x 的值
print('%s mydata.x 的值为:%d' % (threading.current_thread().name, mydata.x))
# 使用线程池启动两个子线程
with ThreadPoolExecutor(max_workers=2) as pool:
pool.submit(action, 10)
pool.submit(action, 10)
上面这段代码中,两个线程体都使用了 mydata.x 变量记录累加值,由于 mydata 是 threading.local 变量,因此每个线程体都会创建一个该变量的副本。所以两个线程体最后的运行结果都是将 mydata.x 累加到 45。
线程锁实现多个线程对共享资源的安全访问,需要考虑什么时候对共享资源进行读写,什么时候需要锁定该资源,什么时候释放该资源等。这些并没有将资源复制多份,是采用安全机制来控制对这份资源的访问而已。
线程局部变量并不能替代同步机制,两者面向的问题领域不同。
通常做法是:如果多个线程之间需要共享资源, 以实现线程通信,则使用同步机制:如果仅仅需要隔离多个线程之间的共享冲突,则可以使用线程局部变量。
2、定时器 Timer子类
Thread 类的 Timer 子类可控制指定函数在特定时间内执行一次。示例如下:
from threading import Timer
def hello():
print('hello world')
# 指定每隔 3s 执行一次 hello 函数
Timer(3, hello).start()
注意:Timer 只能控制函数在指定时间内执行一次,如果要使用 Timer 控制函数多次重复执行,需要再执行一次调度。
Timer 对象的 cancel() 函数可以取消 Timer 调度。示例如下,下面代码每 1s 输出一次当前时间。
from threading import Timer
import time
# 定义总共输出几次的计数器
count = 0
def print_time():
print("当前时间:%s" % time.ctime())
global t, count
count += 1
# 如果 count 小于10,开始下一次调度
if count < 10:
t = Timer(1, print_time)
t.start()
# 指定 1s 后执行 print_time 函数
t = Timer(1, print_time)
t.start()
3、任务调度 sched 模块
sched 模块可用于执行更复杂的任务调度,该模块主要提供了 scheduler 类,该类代表一个任务调度器。构造函数语法如下:
sched.scheduler(timefunc=time.monotonic, delayfunc=time.sleep)
- timefunc : 指定生成时间戳的时间函数,默认使用 time.monotonic 来生成时间戳。
- delayfunc :指定阻塞程序的函数,默认使用 time.sleep 函数来阻塞程序。
sched.scheduler 调度器有下面这些常用属性和方法:
- scheduler.enterabs(time, priority, action, argument=(), kwargs={}):指定在time 时间点执行action 函数, argument 和 kwargs 都用于向action 函数传入参数,其中argument 使用位置参数的形式传入参数: kwargs 使用关键宇参数的形式传入参数。该方法返回一个event ,它可作为 cancel() 方法的参数用于取消该调度。priority 参数指定该任务的优先级,当在同一个时间点有多个任务需要执行时,优先级高(值越小代表优先级越高)的任务会优先执行。
- scheduler.enter( delay, priority, action, argument=(), kwargs={}):该方法与上一个方法基本相同,只是delay 参数用于指定多少秒之后执行action 任务。
- scheduler.cancel( event):取消任务。如果传入的event 参数不是当前调度队列中的 event,将会引发ValueError 异常。
- scheduler.empty() : 判断当前该调度器的调度队列是否为空。
- scheduler.run(blocking=True): 运行所有需要调度的任务。如果调用该方法的 blocking 参数为True , 该方法将会阻塞线程,直到所有被调度的任务都执行完成。
- scheduler.queue : 该只读属性返回该调度器的调度队列。
sched.scheduler 的简单使用示例如下:
import sched, time
import threading
# 定义线程调度器,创建调度器对象
s = sched.scheduler()
# 定义被调度的函数
def print_time(name='default'):
print("%s 的时间:%s" % (name, time.ctime()))
print('主线程:', time.ctime())
# 指定 10s 后执行 print_time 函数,优先级为1
s.enter(10, 1, print_time)
# 指定Ss 后执行print_time 函数,优先级为2
s.enter(5, 2, print_time, argument=('位置参数',))
# 指定Ss 后执行print_time 函数,优先级为1
s.enter(5, 1, print_time, kwargs={'name': '关键参数'})
# 执行调试的任务
s.run()
print('主线程:', time.ctime())
面这段代码的输出结果如下:
主线程: Tue Apr 21 16:44:25 2020
关键参数 的时间:Tue Apr 21 16:44:30 2020
位置参数 的时间:Tue Apr 21 16:44:30 2020
default 的时间:Tue Apr 21 16:44:35 2020
主线程: Tue Apr 21 16:44:35 2020
从上面的输出可以看出,同样是在 5s 后执行的任务,优先级越高的会先执行。
九、多进程
1、使用 os.fork() 创建新进程
os 模块的 fork() 方法可以创建一个子进程。
- fork() 方法的作用:会启动两个进程(一个父进程,一个是fork出来的子进程)来执行从 os.fork() 开始的所有代码。
- fork() 方法不需要参数,它有一个返回值,该返回值表明是哪个进程在执行。
- fork() 方法返回 0,表明是 fork 出来的子进程在执行。
- fork() 方法返回非 0,表明是父进程在执行,该方法返回 fork() 出来的子进程的进程 ID。
代码示例如下:
import os
print('父进程(%s)开始执行' % os.getpid())
# 形如 fork 一个子进程
# 从这行代码开始,下面的代码都会被两进程执行
pid = os.fork()
print('进程进入:%s' % os.getpid())
# 如果 pid 为 0,则表明是子进程
if pid == 0:
print('子进程,其ID为(%s),父进程ID为(%s)' % (os.getpid(), os.getppid()))
else:
print('我(%s)创建的子进程ID为(%s)。' % (os.getpid(), pid))
print('进程结束:%s' % os.getpid())
上面这段代码需要在 Linux 系统上运行,因为 Windows 不支持 fork() 方法。运行结果如下:
父进程(2441)开始执行
进程进入:2441
我(2441)创建的子进程ID为(2442)。
进程结束:2441
进程进入:2442
子进程,其ID为(2442),父进程ID为(2441)
上面通过 fork() 方法创建一个子进程,接着判断 fork() 方法的返回值来确定代码是否正在执行子进程,也就是把需要并发执行的任务放在 if pid == 0: 的条件执行中,这样就可以启动多个子进程来执行并发任务。
2、使用 multiprocessing.Process 创建新进程
multiprocessing 模块的 Process 创建的新进程可以在 Windows 下使用,创建新进程有两种方式:
- 以指定函数作为 target ,创建 Process 对象即可创建新进程。
- 继承 Process 类,并重写它的 run() 方法来创建进程类,程序创建 Process 子类的实例作为进程。
Process 类的方法和属性如下:
- run():重写该方法可实现进程的执行体。
- start(): 该方法用于启动进程。
- join([timeout]): 该方法类似于线程的 join() 方法,当前进程必须等待被 join 的进程执行完成才能向下执行。
- name : 该属性用于设置或访问进程的名字。
- is_alive():判断进程是否还活着。
- daemon : 该属性用于判断或世置进程的后台状态。
- pid : 返回进程的 ID 。
- authkey :返回进程的授权 key 。
- terminate(): 中断该进程。
第1种方式:以指定函数作为 target 创建新进程,示例如下。
import multiprocessing
import os
# 定义一个普通的 action 函数,该函数准备作为进程执行体
def action(max):
for i in range(max):
print("(%s)子进程(父进程:(%s)):%d" % (os.getpid(), os.getppid(), i))
if __name__ == '__main__':
# 下面是主程序(也就是主进程)
for i in range(100):
print("(%s)主进程:%d" % (os.getpid(), i))
if i == 20:
# 创建并启动第一个进程
mp1 = multiprocessing.Process(target=action, args=(100, ))
mp1.start()
# 创建并启动第二个进程
mp2 = multiprocessing.Process(target=action, args=(100, ))
mp2.start()
mp2.join()
print('主进程执行完成')
在通过 multiprocessing.Process 来创建并启动进程时,必须先判断 if __name__ == '__main__':
,否则可能引发异常。
由于在创建第二个子进程时调用了 mp2.join(),因此主进程必须等 mp2 进程完成后才能向下执行。
第2种方式:继承 Process 类创建子进程
步骤如下:
- 第1步:定义继承Process 的子类, 重写其run() 方法准备作为进程执行体。
- 第2步:创建 Process 子类的实例。
- 第3步:调用 Process 子类的实例的 start() 方法来启动进程。
示例如下:
import multiprocessing
import os
class MyProcess(multiprocessing.Process):
def __init__(self, max):
self.max = max;
super().__init__()
# 重写run 方法作为线程执行体
def run(self):
for i in range(self.max):
print("(%s)子进程(父进程:(%s)):%d" % (os.getpid(), os.getppid(), i))
if __name__ == '__main__':
# 下面是主程序,也就是主进程
for i in range(100):
print("(%s)主进程:%d" % (os.getpid(), i))
if i == 20:
# 创建并启动第一个进程
mp1 = MyProcess(100)
mp1.start()
# 创建并启动第二个进程
mp2 = MyProcess(100)
mp2.start()
mp2.join()
print('主进程执行完成')
小结:如果要使用多进程编程,推荐使用第1种方式,逻辑结构清晰,编程相对简单。
3、Context 和启动进程的方式
Python 支持三种启动进程的方式:
- spawn:父进程会启动一个全新的 Python 解释器进程。在这种方式下,子进程只能继承那些处理 run() 方法所必需的资源。典型的,那些不必要的文件描述器和 handle 都不会被继承。使用这种方式启动进程,比使用 fork 或 forkserver 效率要低得多。这种方式也是 Windows 平台默认启动进程的方式。
- fork : 父进程使用 os.fork() 来启动一个 Python 解释器进程。在这种方式下, 子进程会继承父进程的所有资源, 因此子进程基本等效于父进程。这种方式只在类 UNIX 平台上有效,也是默认的启动进程方式。
- forkserver : 这种方式将会启动一个服务器进程。在以后的时间内, 再次请求启动新进程时,父进程都会连接到该服务器进程, 请求由服务器进程来 fork 新进程。通过这种方式启动的进程不需要从父进程继承资源。这种方式只在类 UN IX 平台上有效。
multiprocessing 模块的 set_start_method() 函数可用于设置进程的启动方式,但是必须要放在所有与多进程有关的代码之前。示例如下:
import multiprocessing
import os
def foo(q):
print('被启动的新进程:(%s)' % os.getpid())
q.put('python')
if __name__ == '__main__':
# 设置使用 fork 方式启动进程
multiprocessing.set_start_method('fork')
q = multiprocessing.Queue()
# 创建进程
mp = multiprocessing.Process(target=foo, args=(q, ))
# 启动进程
mp.start()
# 获取队列中的消息
print(q.get())
mp.join()
上面这段代码中 set_start_method() 方法指定了使用 fork 方式启动进程,因此这段代码中类 UNIX 系统上可以运行。在新进程中向队列 q 放入一个数据,在主进程中取出列队 q 中的数据,并输出该数据。
另一种设置进程启动的方式是,利用 get_context() 方法来获取 Context 对象,调用该方法时可传入 spawn、fork、forkserver 字符串。Context 拥有和 multiprocessing 相同的 API。示例如下:
import multiprocessing
import os
def foo(q):
print('被启动的新进程:(%s)' % os.getpid())
q.put('python')
if __name__ == '__main__':
# 设置使用 fork 方式启动进程,并获取 Context 对象
ctx = multiprocessing.get_context('fork')
# 接下来就可以使用 Context 对象来代替 mutliprocessing 模块
q = ctx.Queue()
# 创建进程
mp = ctx.Process(target=foo, args=(q, ))
# 启动进程
mp.start()
# 获取队列中的消息
print(q.get())
mp.join()
在 Windows 平台上运行这段代码,可将 fork 改为 spawn即可。
4、使用进程池管理进程
线程有线程池,进程也有进程池。
multiprocessing 模块的 Pool() 函数创建进程池,实际上是 multiprocessing.pool.Pool 类。
进程池常用方法如下:
- apply(func[, args[, kwds]]):将 func 函数提交给进程池。args 是位置参数,kwds 是关键字参数。该方法会被阻塞直到 func 函数执行完成。
- apply_async(func[, args[, kwds[, callback[, error_callback]]]]):apply() 方法的异步版本,该方法不会被阻塞。其中callback 是func 函数完成后的回调函数,error_callback 是 func 函数出错后的回调函数。
- map(func, iterable[, chunksize]):类似全局的 map() 函数,不过此处的函数使用新进程对 iterable 的每一个元素执行 func 函数。
- map_async(func, iterable[, chunksize[, callback[, error_callback]]]):上一个方法的异步版,此方法不会被阻塞。callback 是 func 函数完成后的回调函数,error_callback 是 func 函数出错后的回调函数。
- imap(func, iterable[, chunksize]):map() 方法的延迟版本。
- imap_unordered(func, iterable[, chunksize]):功能类似于 imap() 方法,不保证生成的结果(包含多个元素)与原 iterable 中的元素顺序一致。
- starmap(func, iterable[, chunksize]):与 map() 方法类似。但要求 iterable 的元素也是 iterable 对象,将每一个元素解包后作为 func 函数的参数。
- close():关闭进程池。进程池不再接收新任务,执行完当前进程池中的所有任务后再关闭自己。
- terminate():立即中止进程池。
- join():等待所有进程完成。
使用 apply_async() 方法启动进程示例如下:
import multiprocessing
import time, os
def action(name='default'):
print('(%s)进程正在执行,参数为:%s' % (os.getpid(), name))
time.sleep(2)
if __name__ == '__main__':
# 创建包含4个进程的进程池
pool = multiprocessing.Pool(processes=4)
# 将 action 分为 3次提交给进程池
pool.apply_async(action)
pool.apply_async(action, args=('位置参数',))
pool.apply_async(action, kwds={'name': '关键字参数'})
pool.close()
pool.join()
运行上面这段代码,输出如下:
(10800)进程正在执行,参数为:default
(7688)进程正在执行,参数为:位置参数
(8332)进程正在执行,参数为:关键字参数
与线程池一样,进程池也实现了上下文管理协议,因此可以使用 with 子句管理进程池,避免主动关闭进程池。下面使用 map() 方法启动进程,并使用 with 子句。
import multiprocessing
import time, os
# 定义一个准备作为进程任务的函数
def action(max):
my_sum = 0
for i in range(max):
print('(%s)进程正在执行:%d' % (os.getpid(), i))
my_sum += i
return my_sum
if __name__ == '__main__':
# 创建一个包含 4 个进程的进程池
with multiprocessing.Pool(processes=4) as pool:
# 使用进程执行 map 计算
# 后面元组有 3 个元素,因此程序启动 3 个进程来执行 action 函数
results = pool.map(action, (50, 100, 150))
print('-' * 20)
for r in results:
print(r)
注意:map() 方法会阻塞进程,直到 func 函数执行完成。另外使用多线程比使用多进程有更好的性能。
5、进程通信
进程通信有两种机制:
- Queue:一个进程向 Queue 中放入数据,另一个进程读取数据。
- Pipe:进程管道。调用 Pipe() 函数生成两个连接端,分别交给通信的两个进程,进程可从连接端读取数据,也可从连接端写入数据。
5.1、使用Queue实现进程通信
multiprocessing.Queue 与 queue.Queue 对比:
- 相同处:都提供了 qsize()、empty()、full()、put()、put_nowait()、get()、get_nowait()等方法。
- 不同处:multiprocessing.Queue 为进程提供服务,queue.Queue 为线程提供服务。
下面示例使用 Queue 实现进程之间通信。
import multiprocessing
def f(q, s):
print('(%s) 进程开始放入数据。。。' % multiprocessing.current_process().pid)
q.put(s)
if __name__ == '__main__':
# 创建进程通信的 Queue
q = multiprocessing.Queue()
# 创建子进程
p = multiprocessing.Process(target=f, args=(q, 'hello world'))
# 启动子进程
p.start()
print('(%s) 进程开始取出数据。。。' % multiprocessing.current_process().pid)
# 取出数据
print(q.get())
p.join()
运行代码输出结果如下:
(15400) 进程开始取出数据。。。
(9460) 进程开始放入数据。。。
hello world
从输出可以看出,ID 号 15400 是子进程,ID 号 9460 是父进程。这两个进程之间实现了进程通信。
5.2、使用 Pipe 实现进程通信
multiprocessing.Pipe() 函数创建一个管道,返回两个 PipeConnection 对象,代表管道的两个连接端,分别用于连接通信的两个进程。PipeConnection 对象的常用方法如下:
- send(obj):发送一个 obj 同给管道的另一端,另一端使用 recv() 方法接收。需要说明的是,该 obj 必须是可 picklable 的( Python 的序列化机制),如果该对象序列化之后超过32MB,则很可能会引发 ValueError 异常。
- recv() :接收另一端通过 send() 方法发送过来的数据。
- fileno() : 关于连接所使用的文件描述器。
- close() :关闭连接。
- poll([timeout]) :返回连接中是否还有数据可以读取。
- send_bytes(buffer[, offset[, size ]]) : 发送字节数据。如果没有指定 offset 、size 参数,则默认发送 buffer 字节串的全部数据:如果指定了。offset 和 size 参数,则只发送 buffer 字节串中从 offset 开始、长度为size 的字节数据。通过该方法发送的数据,应该使用 recv_ bytes() 或 recv_bytes_into 方法接收。
- recv_bytes([ maxlength ]): 接收通过 send_bytes() 方法发送的数据, maxlength 指定最多接收的字节数。该方法返回接收到的字节数据。
- recv_bytes_into (buffer[, offset]) :功能与 recv_bytes() 方法类似,只是该方法将接收到的数据放在 buffer 中。
示例如下:
import multiprocessing
def f(conn, s):
print('(%s) 进程开始发送数据。。。' % multiprocessing.current_process().pid)
# 使用 conn 发送数据
conn.send(s)
if __name__ == '__main__':
# 创建 Pipe,该函数返回两个 PipeConnection 对象
parent_conn, child_conn = multiprocessing.Pipe()
# 创建子进程
p = multiprocessing.Process(target=f, args=(child_conn, 'hello world'))
# 启动子进程
p.start()
print('(%s) 进程开始接收数据。。。' % multiprocessing.current_process().pid)
# 通过 conn 读取数据
print(parent_conn.recv())
p.join()
通过 PipeConnection 从管道读取数据,实现两个进程之间的通信。输出如下:
(16900) 进程开始接收数据。。。
(10424) 进程开始发送数据。。。
hello world
十、小结
- Python并发编程相关内容;
- Python 多线程编程支持,以及多进程编程。
- 线程基本概念,线程和进程的区别与联系。
- 创建和启动多个线程,线程生命周期。
- 控制线程的几个方法,线程同步的意义和必要性;Lock 实现线程同步的方法。
- 线程通信的三种方式:使用 Condition 对象、阻塞队列 和 Event 实现线程通信。
- 使用线程池管理线程,线程创建成本较大,实际开发应复用线程,使用线程池是不错的选择。
- 与线程相关的工具类,如线程局部变量、定时器和任务调度。
- 创建新进程两种方式:os.fork() 方法 和 Process 类。
- 使用进程池管理进程的方式和实现进程通信的两种方法。
- 多进程实现并发的开销比多线程的开销大,尽量使用多线程实现并发。
练习
1、用3个线程打印递增数字
控制线程 1 打印 1,2,3,4,5(每行都打印线程名和一个数字),线程2 打印 6,7,8,9,10,线程 3 打印 11,12,13,14,15;接下来再由线程 1 打印 16,17,18,19,20......,以此类推,直到打印 75。
from concurrent.futures import ThreadPoolExecutor
import threading
class PrintUtil:
def __init__(self):
# 正在打印的值
self.number = 0
# 控制应由哪个线程执行该任务
self.state = 1
self.cond = threading.Condition()
def print(self, thread_num):
try:
self.cond.acquire()
while self.state != thread_num:
self.cond.wait()
# 打印5个连续数值
for i in range(5):
self.number += 1
print("thread%d : %d" % (thread_num, self.number))
# 每打印5个数字,state 加 1,控制轮到下一个线程来执行打印
self.state = self.state % 3 + 1
self.cond.notify_all()
finally:
self.cond.release()
def action(pu, thread_num):
# 控制每个线程要执行 PrintUtil 对象的 print() 方法5次
for i in range(5):
pu.print(thread_num)
pu = PrintUtil()
# 创建一个包含 3 条线程的线程池
with ThreadPoolExecutor(max_workers=3) as pool:
# 启动 3 个线程
for i in range(3):
# 使用线程池启动3个线程
pool.submit(action, pu, i+1)
2、写两个线程
其中一个线程打印 1-52;另一个线程打印 A-Z,打印顺序是 12A34B56C.......5152Z。主要练习多线程通信知识。使用队列实现多线程通信。
import threading, queue
bq = queue.Queue(1)
def action1(bq):
for i in range(1, 53, 2):
bq.put(i)
print(i, end="")
print(i + 1, end="")
def action2(bq):
for i in range(26):
bq.get() # 从队列中获取不到元素,就一直阻塞线程
print(chr(65 + i), end="")
# 创建并启动第一个线程
t1 = threading.Thread(target=action1, args=(bq, ))
t1.start()
# 创建并启动第二个线程
t2 = threading.Thread(target=action2, args=(bq, ))
t2.start()
3、用4个线程分别向4个文件写入内容
有4个线程 1,2,3,4。线程1的功能是输出1,线程2的功能是输出2,依此类推。现在有4个文件 A,B,C,D,初始都为空。让4个文件最后呈现出如下内容:
A: 1 2 3 4 1 2...
B: 2 3 4 1 2 3...
C: 3 4 1 2 3 4...
D: 4 1 2 3 4 1...
代码如下:
from concurrent.futures import ThreadPoolExecutor
import threading
import time, os
from pathlib import Path
class WriteUtil:
def __init__(self):
self.current_thread_num = 1
self.write_count = 0
def write_num(self, value):
# 生成文件位置
with open(self.current_file_name() + ".txt", 'a+') as f:
f.write(value + " ")
print("ThreadNum=%d is executing. %c is written into file: %s.txt \n" % (
self.current_thread_num, value, self.current_file_name()))
self.write_count += 1
self.current_thread_num = int(value)
self.next_thread_num()
def current_file_name(self):
''' 判断接下来要写哪个文件 '''
tmp = self.write_count % 4
name_map = {0: 'A', 1: 'B', 2: 'C', 3: 'D'}
return name_map[tmp]
def next_thread_num(self):
if self.write_count % 4 == 0:
if self.current_thread_num < 3:
self.current_thread_num += 2
else:
self.current_thread_num = (self.current_thread_num + 2) % 4
else:
if self.current_thread_num == 4:
self.current_thread_num = 1
else:
self.current_thread_num += 1
wu = WriteUtil()
wu.cond = threading.Condition()
# 提前删除4个文件
for c in ('A', 'B', 'C', 'D'):
if Path(c + '.txt').exists():
os.remove(c + '.txt')
def action(value):
try:
for i in range(10):
try:
wu.cond.acquire()
# 保证要写入的值必须与当前线程的num相同
while int(value) != wu.current_thread_num:
wu.cond.wait()
wu.write_num(value)
wu.cond.notify_all()
finally:
wu.cond.release()
except Exception as e:
print('异常%s' % e)
# 创建一个包含4条线程的线程池
pool = ThreadPoolExecutor(max_workers=4)
# 使用线程池启动4条线程
pool.submit(action, '1')
pool.submit(action, '2')
pool.submit(action, '3')
pool.submit(action, '4')
# 关闭线程池
pool.shutdown()