Effective python(六):并发与并行
一,使用subprocess模块管理子进程,并控制输入流和输出流
1,Popen构造器启动进程,communicate方法读取子进程输出信息
proc=subprocess.Popen(['echo','Hello'],stdout=subprocess.PIPE)
out,err=proc.communicate()
print(out.decode('utf-8'))
2,子进程会独立于父进程而运行,这里父进程指的是Python解释器
3,可以给communicate方法传入timeout参数,避免进程死锁或者失去响应,一段时间未响应自动抛出异常
二,使用线程来执行阻塞式I/O比asyncio模块简单
1,阻塞式I/O响应比较慢,应该利用线程将其分出去执行,避免程序卡死在某时间间隔内,在其响应间隔内,CPU可以执行一些计算的操作
三,使用Lock来防止数据竞争
1,线程中的暂停操作有可能导致数值混乱,即使是最简单的自增操作,也会拆分成三步,中间有一步赋值转换,如果在赋值转换的过程中,将线程打断,那么就有可能将其他线程的新值赋进去,然后转换回来的时候又赋了一遍旧值,导致数据丢失
2,Python内置的threading模块的Lock类,相当于互斥锁,通过互斥锁来保护数值,使得同一时刻多个线程中只能有一个访问该值
with lock():
count+=1
四,使用Queue来协调各线程之间的工作
1,函数管线,每一个阶段处理好任务后将返回值传输到下一个阶段,涉及阻塞式I/O的任务很适合,这样的任务很容易分配到多个线程或进程中去
2,举例:例如照片处理系统,先从相机中获取照片(一),再调整其尺寸进行处理(二),最后上传到网络相册中(三),分别把三个阶段的函数写好,然后通过拼接为一条并发处理的管线,这种方式可以通过生产-消费队列建模
3,自己实现管线有很多缺陷,例如每个阶段执行速度有差别,某个阶段处理过慢,导致某个阶段一直没有任务可以处理,还可能某个阶段处理过快,导致队列容量不断增大,耗尽内存,进而崩溃
4,使用Queue类来弥补自己编写队列的缺陷
queue=Queue(1) #参数表示队列中最大任务数量
然后使用queue.get()
,queue.put()
来放入和获取任务
5,还可以通过Queue类中的task_done方法来追踪任务工作进度
五,考虑用协程来并发运行多个函数
1,线程的缺点:
- 复杂的多线程代码会令项目变得难以维护
- 线程开销比较大,会拖慢程序执行速度本身
- 线程需要占用大量内存,每个执行的线程大约8MB,十几个线程可以承受,但是成千上万的函数都用线程执行就会出现问题
2,Python的协程可以避免上述问题,协程的实现实际上是对生成器的扩展,启动生成器协程所需开销与调用函数相仿,处于活跃状态的协程大约只占不到1KB内存
3,协程的工作原理:每当生成器函数执行到yield表达式时,消耗生成器的代码会通过send方法发送给生成器一个值,生成器会将其视为yield表达式的执行结果,同时yield右侧的变量,为弹出值,作为send方法的返回值
#统计最小值
def minimize():
current=yield
while True:
value=yield current
current=min(value,current)
it=minimize()
# 在调用send前,先调用next()
next(it)# 将生成器推进到第一条yield表达式位置
print(it.send(10))
print(it.send(5))
4,协程与线程类似,是独立的函数,消耗由外部传入的数据,并产生响应的输出结果,与线程不同的是,协程会在每个yield表达式处暂停,等到外界再次调用send方法后,才会执行到下一个yield表达式
5,可以利用生成器所产生的输出值,去推进其它生成器函数,连接多个生成器函数,即可模拟出Python线程的并发行为,令程序看上去像是同时运行多个函数
6,在生成器函数中添加return后,可以使用yield from表达式来调用,最后的返回值会作为yield from的结果
六,考虑用concurrent.futures实现真正的平行计算(多核处理)
1,使用内置的concurrent.futures模块来利用另一个multiprocessing的内置模块,该做法会以子进程的形式,平行运行多个解释器,利用多核CPU提升执行速度,由于子进程与主解释器分离,所以他们的GIL全局解释锁也是相互独立的
pool=ProcessPoolExecutor(max_workers=2)# 最大工作数应与CPU核心数相同
#将函数映射到数据列表上去
results=list(pool.map(func,alist))
2,ProcessPoolExecutor类利用由multiprocessing模块所提供的底层机制,逐步完成下列操作
- 把alist每项数据传给map
- 用pickle模块对数据序列化,变成二进制形式
- 通过本地套接字(local socket),将序列化后的数据从主解释器进程发送到子解释器进程
- 子进程pickle对数据库反序列化,还原成python对象
- 引入包含func函数的模块
- 各子进程平行的对各自的输入数据,运行func函数
- 对运行结果进行序列化
- 将序列化结果通过socket复制到主进程中
- 主进程反序列化,还原python对象
- 主进程把每个子进程所求出的计算结果合并到一份列表并返回
3,multiprocessing开销较高,原因在序列化与反序列化操作,对于运行函数不需要与程序其他部分共享状态,且主进程与子进程只需要传递一小部分数据,就能完成大量运算的任务来说,这套方案比较合适。如果待执行的运算不符合上述特征,那么所产生的开销可能无法通过平行化来提升程序运算速度,这种情况下,可以求助multiprocessing的高级机制,如共享内存,跨进程锁定,队列,代理等,不过这些特性用起来非常复杂,建议少碰
4,补充,引发性能瓶颈和调用率较高且对性能要求较高的部分使用C语言扩展来编写,但是工作量较大,也可能引发BUG