Python 第十一章 并发编程
1.线程概述
操作系统可以同时运行多个任务,一个任务通常是一个程序,每一个运行中的程序就是一个进程。当一个程序运行时,内部可能包含多个顺序执行流,每一个顺序执行流就是一个线程。
并发(concurrency):同一时刻有多条指令在多个处理器上同时执行。
并行(parallel):同一时刻只能有一条指令执行,但多个进程指令被快速轮换执行,使得在宏观上具有多个进程同时执行的效果。
大部分操作系统都支持多进程并发执行,可以同时执行多个任务。对于一个CPU而言,在某个时间点它只能执行一个程序。也就是只能运行一个进程,CPU不断地在这些进程之间轮换执行。
多线程是同一个进程可以同时并发处理多个任务。线程是进程的执行单元。线程可以完成一定的任务,可以与其他进程共享父进程中的共享变量及部分环境,相互之间协同来完成进程要完成的任务。线程是独立运行的,他并不知道进程中是否还有其他线程存在,它的运行是抢占式的。一个程序运行后至少有一个进程,在一个进程中至少有一个主线程。
2.线程的创建与启动
调用Thread类的构造器创建线程很简单,直接调用threading.Thread类的如下构造器创建线程。
_init_(self,group=None,target=None,name=None,args=(),kwargs=None,*,daemon=None)
- group:指定该线程所属的线程组。
- target:指定该线程要调度的目标方法。
- args:指定一个元组,以位置参数的形式为target指定的函数传入参数。元组的第一个元素传给target函数的第一个参数,元组的第二个元素传给target函数的第二个参数。。。
- kwargs:指定一个字典以关键字参数的形式为target指定的函数传入参数。
- daemon:指定所构建的线程是否为后台线程。
- 通过Thread类的构造器创建线程对象。在创建线程对象时,target参数指定的函数将作为线程执行体。
- 调用线程对象的start方法启动该线程。
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时创建并启动2个新线程。
在进行多线程编程时,不要忘记Python程序运行时默认的主线程,主程序部分(没有放在任何函数中的代码)就是主线程的线程执行体。
说穿了很简单,多线程就是让多个函数能并发执行,让普通用户感觉到多个函数同时执行。
3.继承Thread类创建线程类
通过继承Thread类来创建并启动线程的步骤如下:
- 定义Thread的子类并重写该类的run方法。run方法的方法体就代表了线程需要完成的任务,因此把run方法称为线程执行体。
- 创建Thread子类的实例,即创建线程对象。
- 调用线程对象的start方法启动线程。
import threading
# 通过继承threading.Thread类来创建线程类
class FkThread(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 i in range(100):
# 调用threading模块current_thread()函数获取当前线程
print(threading.current_thread().getName() + " " + str(i))
if i == 20:
# 创建并启动第一个线程
ft1 = FkThread()
ft1.start()
# 创建并启动第二个线程
ft2 = FkThread()
ft2.start()
print('主线程执行完成!')
4.线程的生命周期
线程要经过新建、就绪、运行、阻塞、死亡,也会多次在运行和就绪之间切换。
4.1新建和就绪状态
当程序创建了一个Thread对象或Thread子类的对象之后该线程就处于新建状态;当线程对象调用start方法之后该线程就处于就绪状态,并没有开始运行。
import threading
# 定义准备作为线程执行体的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(100):
# 调用Thread的currentThread()方法获取当前线程
print(threading.current_thread().name + " " + str(i))
if i == 20:
# 直接调用线程对象的run()方法
# 系统会把线程对象当成普通对象,把run()方法当成普通方法
# 所以下面两行代码并不会启动两个线程,而是依次执行两个run()方法
threading.Thread(target=action,args=(100,)).run()
threading.Thread(target=action,args=(100,)).run()
4.2运行和阻塞状态
如果处于就绪状态的线程获得了CPU,开始执行run()方法的线程执行体,则该线程处于运行状态。如果计算机只有一个CPU,那么任何时候只有一个线程处于运行状态。在一个具有多处理器的机器上,将会有多个线程并行执行;当线程数大于处理器数时,依然会存在多个线程在同一个CPU上轮换的情况。
当发生如下情况,线程将会进入阻塞状态。
- 线程调用sleep方法主动放弃其所占用的处理器资源。
- 线程调用了一个阻塞式I/O方法,在该方法返回之前,该线程被阻塞。
- 线程试图获得一个锁对象,但该锁对象正被其他线程所持有。
- 线程在等待某个通知。
当发生如下情况,线程将会解除阻塞状态,重新进入就绪状态,等待调度器再次调用它。
- 调用sleep方法的线程经过了指定的时间。
- 线程调用的阻塞式I/O方法已经返回。
- 线程成功获得了试图获取的锁对象。
- 线程正在等待某个通知时,其他线程发出了一个通知。
4.3线程死亡
会以以下三种方式结束,结束后就处于死亡状态。
- run方法或代表线程执行体的target函数执行完成,线程正常结束。
- 线程抛出一个未捕获的异常。
为了测试线程是否已经死亡,可以调用线程对象的is_alive方法,当线程处于就绪、运行、阻塞三种状态返回True,线程处于新建死亡两种状态时返回False。
下面尝试对处于死亡状态的线程再次调用start方法,会报错:
import threading
# 定义action函数准备作为线程执行体使用
def action(max):
for i in range(100):
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()
5控制线程
5.1join线程
Thread提供了让一个线程等待另一个线程完成的方法,join方法。当在某个程序执行流中调用其他线程的join方法时,调用线程将被阻塞,直到join方法的线程完成。
join方法通常由使用线程的程序调用,以将大问题划分成许多小问题,并将每个小问题分配一个线程。当所有小问题处理完再调用主线程进一步操作。
import threading
# 定义action函数准备作为线程执行体使用
def action(max):
for i in range(max):
print(threading.current_thread().name + " " + str(i))
# 启动子线程
threading.Thread(target=action, args=(100,), name="新线程").start()
for i in range(100):
if i == 20:
jt = threading.Thread(target=action, args=(100,), name="被Join的线程")
jt.start()
# 主线程调用了jt线程的join()方法
# 主线程必须等jt执行结束才会向下执行
jt.join()
print(threading.current_thread().name + " " + str(i))
上面一共有3个线程,主程序开始就启动了名为新线程的子线程,会和主线程并发执行。当主线程的循环变量等于20,启动了名为被join的线程的线程,该线程不会和主线程并发执行,会和新线程这个子线程并发执行。直到被join的线程执行完主线程才会执行。
join(timeout=None)方法可以指定一个timeout参数,该参数指定被join的线程的时间最长为多少秒。
5.2后台进程
有一种线程他是在后台运行的,它的任务是为其他进程提供服务,这种线程被称为后台线程。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线程应该执行到99时才会结束,但在主线程结束后,后台线程也结束了。
5.3线程睡眠sleep
如果要让在执行的线程暂停一段时间并进入阻塞状态可以用time模块的sleep函数。
import time
for i in range(10):
print("当前时间: %s" % time.ctime())
# 调用sleep()函数让当前线程暂停1s
time.sleep(1)
6.线程同步
6.1线程安全问题
银行取钱问题:
- 用户输入账户、密码,系统判断用户的账户密码是否匹配
- 用户输入取款金额
- 系统判断账户余额是否大于取款金额
- 如果余额大于取款金额,则取款成功,否则失败
按照上述流程编写程序,并使用两个线程模拟两个人使用同一账户并发取钱操作。此处忽略检查账户和密码的操作。先定义一个账户类,该账户类封装了账户编号和余额两个成员变量。
class Account:
# 定义构造器
def __init__(self,account_no,balance):
# 封装账户编号和余额两个成员变量
self.account_no = account_no
self.balance = balance
接下来程序会定义一个模拟取钱的函数,该函数根据执行账户、取钱数量进行取钱操作,取钱的逻辑是当余额不足时无法提取现金,足时吐出钞票,余额减少。
import threading
import time
import Account
# 定义一个函数来模拟取钱操作
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.Account("1234567" , 1000)
# 模拟两个线程对同一个账户取钱
threading.Thread(name='甲', target=draw , args=(acct , 800)).start()
threading.Thread(name='乙', target=draw , args=(acct , 800)).start()
甲取钱成功!吐出钞票:800
乙取钱成功!吐出钞票:800
余额为: 200 余额为: -600
有可能发生第12行的偶然错误结果,因为线程调度的不确定性。
6.2同步锁
之所以出现上面的错误结果是因为run方法的方法体不具有线程安全性——程序中有两个并发线程在修改Account对象;而且系统恰好在time.sleep处执行线程切换,切换到另一个修改Account对象的线程,所以就出现问题。
为解决这个问题,threading模块引入了锁(lock)。模块提供了lock和rlock两个类,他们都提供了如下两个方法来加锁和释放锁。
acquire(blocking=True,timeout=-1):请求对Lock或Rlock加锁,timeout指定加锁多少秒。
release():释放锁
Lock和RLock的区别如下:
threading.Lock:它是一个基本的锁对象,每次只能锁定一次,其余的锁请求需等待锁释放后才能获取。
threading.RLock:它代表可重入锁(Reentrant Lock)。对于可重入锁,在同一个线程中可以对它进行多次锁定,也可以多次释放。如果使用RLock,那么acquare()和release()方法必须成对出现。如果调用了n次acquire加锁,则必须调用n次release才能释放。
所以,RLock锁具有可重入性,即同一个线程可以对已被加锁的RLock锁再次加锁,RLock对象会维持一个计数器来追踪acquire方法的嵌套调用,线程在每次调用acquire加锁后,都必须显示调用release释放锁。所以,一段被锁保护的方法可以调用另一个被相同锁保护的方法。
Lock是控制多个线程对共享资源进行访问的工具。通常,锁提供了对共享资源的独占访问,每次只能有一个线程对Lock对象加锁,线程在开始访问共享资源之前应先请求获得Lock对象。当对共享资源访问完成后,程序释放对Lock对象的锁定。
在实现线程安全的控制中,常用的是RLock,格式如下:
class X:
# 定义需要保证线程安全的方法
def m():
# 加锁
self.lock.acquire()
try:
# 需要保证线程安全的代码
# 。。。方法体
# 使用finally块保证释放锁
finally:
self.lock.release()
使用RLock对象来控制线程安全,当加锁和释放锁出现在不同的作用范围内时,通常建议使用finally块来确保在必要时释放锁。
import threading
import time
class Account:
# 定义构造器
def __init__(self, account_no, balance):
# 封装账户编号、账户余额的两个成员变量
self.account_no = account_no
self._balance = balance
self.lock = threading.RLock()
# 因为账户余额不允许随便修改,所以只为self._balance提供getter方法
def getBalance(self):
return self._balance
# 提供一个线程安全的draw()方法来完成取钱操作
def draw(self, draw_amount):
# 加锁
self.lock.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:
# 修改完成,释放锁
self.lock.release()
第十行定义了一个RLock对象,在程序实现draw方法时,进入该方法开始执行后立即请求对RLock对象加锁,当执行完draw方法的取钱逻辑之后使用finally块确保释放锁。
并发线程在任意时刻只有一个线程可以进入修改共享资源的代码区(也被称为临界区),所以在同一时刻最多只有一个线程处于临界区内,从而保证线程安全。
下面程序创建并启动了两个取钱线程:
import threading
import Account
# 定义一个函数来模拟取钱操作
def draw(account, draw_amount):
# 直接调用account对象的draw()方法来执行取钱操作
account.draw(draw_amount)
# 创建一个账户
acct = Account.Account("1234567" , 1000)
# 模拟两个线程对同一个账户取钱
threading.Thread(name='甲', target=draw , args=(acct , 800)).start()
threading.Thread(name='乙', target=draw , args=(acct , 800)).start()
甲取钱成功!吐出钞票:800
余额为: 200
乙取钱失败!余额不足!
6.3死锁
当两个线程相互等待对方释放同步监视器时就会发生死锁。一旦出现死锁,整个程序不会发生异常也不会给出提示,只是所有线程都处于阻塞状态无法继续。
7.线程通信
7.1使用condition实现线程通信
7.2使用队列(Queue)控制线程通信
queue模块提供了几个阻塞队列,主要用于线程通信。
queue.Queue(maxsize=0):代表先进先出的常规队列。maxsize可以限制队列的大小。如果队列的大小达到队列的上限就会加锁,再次加入元素就会被阻塞。将maxsize设置为0或复数,队列大小就是无限制的。
queue.LifoQueue(maxsize=0):后进先出。
PriorityQueue(maxsize=0):代表优先级队列,优先级最小的先出队列。
以上三个类都提供以下属性和方法:
Queue.qsize():返回队列的实际大小。
Queue.empty():判断队列是否为空。
Queue.full():是否已满。
Queue.put(item,block=True,timeout=None):向队列中放入元素。如果队列已满,且block参数为True(阻塞),当前线程被阻塞,timeout指定阻塞时间,如果将timeout设置为None,则代表一直阻塞,直到该队列的元素被消费;如果队列已满且block参数为False,则直接引发异常。
Queue.put_nowait(item):向队列中放入元素,不阻塞。
Queue.get(item,block=True,timeout=None):从队列中取出元素。如果队列已满且block参数为True,当前线程被阻塞,timeout指定阻塞时间,如果将timeout设置为None,则代表一直阻塞,直到该队列的元素被消费;如果队列已空且block参数为False,则直接引发异常。
Queue.get_nowait(item):从队列中取出元素,不阻塞。
import queue
# 定义一个长度为2的阻塞队列
bq = queue.Queue(2)
bq.put("Python")
bq.put("Python")
print("1111111111")
bq.put("Python") # ① 阻塞线程
print("2222222222")
使用put方法尝试放入第三个元素将会阻塞线程;与此类似,在queue已空的情况下,使用get方法尝试取出元素将会阻塞线程。
使用Queue实现线程通信:
import threading
import time
import queue
def product(bq):
str_tuple = ("Python", "Kotlin", "Swift")
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)
# 启动3个生产者线程
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队列中取出元素。本程序中队列的大小为1,因此生产者线程无法连续放入元素,必须等待消费者线程取出一个元素后,其中的一个生产者线程才能放入一个元素。
7.3使用Event控制线程通信
8.线程池
线程池在系统启动时即创建大量空闲线程,程序只要将一个函数提交给线程池,线程池就会启动一个空闲的线程来执行它。当该函数执行结束后,该线程并不会死亡,而是再次返回到线程池中变成空闲状态等待执行下一个函数。
线程池的基类是concurrent.futures模块中的Executor,它提供了两个子类,即ThreadPoolExecutor和ProcessPoolExecutor。前者用于创建线程池,后者创建进程池。
如果使用线程池/进程池来管理并发编程,只要将对应的task函数提交给线程池/进程池,剩下的事情就由他们来搞定。
Exectuor提供了如下方法:
- submit(fn,*args,**kwargs):将fn函数提交给线程池。*args代表传给fn函数的参数,*kwargs代表以关键字参数的形式为fn函数传入参数。
- map(func,*iterables,timeout=None,chunksize=1):该函数类似于全局函数map,只是该函数会启动多个线程,以异步方式立即对iterables执行map处理。
- shutdown(wait=True):关闭线程池。
程序将task函数提交(submit)给线程池后,submit方法会返回一个Future对象,Future类主要用于获取线程任务函数的返回值。由于线程任务会在新线程中以异步方式执行,因此线程执行的函数相当于一个将来完成的任务,所以使用Future来代表。
Future提供了如下方法:
- cancel():取消该Future代表的线程任务。如果该任务正在执行,不可取消,则该方法返回False;否则程序会取消该任务并返回True。
- cancelled():返回Future代表的线程任务是否被取消成功。
- running():如果该Future代表的线程任务正在执行、不可被取消,该方法返回True。
- done():如果该Future代表的线程任务被成功取消或执行完成,返回True。
- result(timeout=None):获取该Future代表的线程任务最后返回的结果。如果Future代表的线程任务还未完成,该方法会阻塞当前线程。
- exception(timeout=None):获取该Future代表的线程任务所引发的异常。如果任务成功完成返回None。
- add_done_callback(fn):为该Future代表的线程任务注册一个回调函数,当该任务成功完成时会自动触发该fn函数。
在用完一个线程池后应该调用该线程池的shutdown方法,该方法将启动线程池的关闭序列。调用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
# 创建一个包含2条线程的线程池
pool = ThreadPoolExecutor(max_workers=2)
# 向线程池提交一个task, 50会作为action()函数的参数
future1 = pool.submit(action, 50)
# 向线程池再提交一个task, 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()
第13行创建了一个包含两个线程的线程池,接下来只要将action函数提交(submit)给线程池,线程池就会负责启动线程来执行action函数。
当程序把action函数提交给线程池时,submit方法会返回该任务所对应的Future对象,程序会立即判断future1的done方法,该方法返回False,表面该任务还未完成。接下来主程序暂停3秒,然后判断future2的done方法,如果此时该任务已经完成,那么该方法将会返回True。
9.多进程
9.1使用multiprocessing.Process创建新进程
Python在multiprocessing模块下提供了Process来创建新进程。有两种方式创建新进程:
- 以指定函数作为target,创建Process对象即可创建。
- 继承Process类,并重写它的run方法来创建进程类,程序创建Process子类的实例作为进程。
Process类也有如下类似方法和属性:
- run():重写该方法可实现进程的执行体。
- start():该方法用于启动进程。
- join([timeout]):当前进程必须等待被join的进程执行完成才能向下执行。
- name:用于设置或访问进程的名字。
- is_alive():判断进程是否还活着。
- daemon:该属性用于判断或设置进程的后台状态。
- pid:返回进程的ID。
- authkey:返回进程的授权key。
- terminate():中断进程。
下面是以指定函数作为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进程完成后才能向下执行。
9.2进程通信
python为进程通信提供了两种机制
- Queue:一个进程向Queue中放入数据,另一个进程从Queue中读取数据。
- Pipe:代表两个进程的管道
import multiprocessing
def f(q):
print('(%s) 进程开始放入数据...' % multiprocessing.current_process().pid)
q.put('Python')
if __name__ == '__main__':
# 创建进程通信的Queue
q = multiprocessing.Queue()
# 创建子进程
p = multiprocessing.Process(target=f, args=(q,))
# 启动子进程
p.start()
print('(%s) 进程开始取出数据...' % multiprocessing.current_process().pid)
# 取出数据
print(q.get()) # Python
p.join()