前言
《编写高质量python代码的59个有效方法》这本书分类逐条地介绍了编写python代码的有效思路和方法,对理解python和提高编程效率有一定的帮助。本笔记简要整理其中的重要方法。
承接上文https://www.jianshu.com/p/15a6050220e6
https://www.jianshu.com/p/1f6a2b3b502e
元类与属性 https://www.jianshu.com/p/1b1f3a0e87aa
本篇介绍关于并发及并行
5. 并发及并行
并发(Concurrency):操作系统在各程序之间迅速切换,使其都有机会运行在一个CPU上。(并非真正意义上的,同一时间做很多不同的任务)
并行(Parallelism): 多核计算机可以同一时间做很多不同的任务。
并发与并行的关键区别在于,能不能提速,并行是可以做到提速的。Python写并发是比较基础的,而做到真正的并行是比较复杂的。
subprocess模块管理子进程
python内置的subprocess模块可以有效的运行并管理进程,使得Python语言能够很好地将命令行实用程序等工具结合起来。
由Python启动的多个子进程是可以平行运作的:
import subprocess
proc=subprocess.Popen(['echo','Hello from the child!'],
stdout=subprocess.PIPE)
out,err=proc.communicate()
print(out.decode('utf-8')) ##Hello from the child!
Python解释器通过Popen构造器启动echo这一子进程,通过communicate方法读取子进程的输出信息,并等待其中止。 子进程独立于父进程运行,可以定期查询子进程 状态,并处理其他事务,如下图所示:
proc2=subprocess.Popen(['sleep','0.1'],
stdout=subprocess.PIPE)
while proc2.poll() is None:
print('woring...')
print('Exit status:',proc2.poll())
代码其中涉及到了管道PIPE等重要的进程概念,PIPE是进程进行通信的重要方式;Communicate方法也是从PIPE中取标准的输出信息等,
Subprocess.Pipe
Communicate
可以用线程进行阻塞式I/O,不能做平行计算
标准的Python实现称为CPython,CPython分两步运行Python程序:1.把源代码解析编译乘字节码,.pyc; 2. 基于栈的解释器运行字节码。
执行Python程序式,字节码解释器必须保持协调一直的状态,Python采用GIL(全局解释器锁机制)来保证协同。
GIL本质上是把互斥锁,以防止Cpython收到抢先式多线程切换,这种切换可能破坏解释器状态。GIL可保证每条字节码指令能够正确地与Cpython实现及其C语言扩展模块协同运作。
GIL的最严重的负面影响是:Python在同一时刻只有一条线程可以执行,也就是说Python不能像C++/Java等一样使用多线程编程。
以原始的因数分解为例,单线程的写法如下:运行耗时1.87s
import time
def factorize(number):
for i in range(1,number+1):
if number%i==0:
yield i
start=time.time()
for number in [2139079,12144759,151232,12324232]:
list(factorize(number))
end=time.time()
print('Took %.3f seconds'%(end-start))
同时还采用Python多线程Threading来进行计算,其耗费时间竟然达到2.412s.(同一机器运行)。理论上,在扣去进程创建等开销后,程序运行速度应该是原来的4倍,然而事实上多线程的程序执行比单线程还慢。这说明Python的多线程是比较有局限的。
class MyThread(Thread):
def __init__(self,number):
super().__init__()
self.number=number
def run(self):
self.factors=list(factorize(self.number))
start=time.time()
threads=[]
for number in [2139079,12144759,151232,12324232]:
thread=MyThread(number)
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
end=time.time()
print('Took %.3f seconds'%(end-start))
而为何这种情况下,Python还要提供Threading等多线程库呢?主要有两个原因:
以下面的例子进行分析,执行系统该调用select,该函数请求系统阻塞0.1s,然后把控制器换给程序。
- 多线程使得程序看起来能够在同一时间做多个任务,如果要自己实现这种效果,并手工管理任务之间的切换,比较困难
- 处理阻塞式I/O操作,可以借助线程将Python与耗时的I/O操作隔离开。执行系统调用时,可能会触发此类操作,如读写文件、网络间通信以及与显示器等设备进行交互等,这些都属于阻塞式的I/O操作
这样的写法使得主程序阻塞在select系统调用种,这时需要考虑把系统调用放到其他线程中:
如下所示:将系统调用放到多条线程中执行,使得程序既能够与多个串口通信,也能执行在主线程中所需的计算。
在线程中使用Lock来防止数据竞争
尽管Python受制于GIL,无法进行真正的多线程,但在线程程序编写中还是会存在资源争夺的问题,需要进行一定的设计,保证程序的正确运行。同一时间虽然只有一个Python线程在运行,但是当这个线程操作数据时,其他线程可能会打断它。这种中断现象随时可能发生,会破坏程序的状态,影响数据的一致性。
Python在threading中提供了锁工具,保护数据不受破坏,Lock类,该类相当于互斥锁。用锁来包含可能被抢占的资源/数据,同一时刻只能有一个线程获得该锁。
用Queue 来协调各线程之间的工作
Python中常用Pipeline来协调多个事务,Pipeline的工作原理与组装生产线相似,分为多个首尾相连的阶段(Phase),每个阶段由一个具体的函数负责。程序总是把待处理的新部件添加到管线的开端。每一种函数都可以在其所负责的那个阶段内,并发地处理位于该阶段的部件。涉及阻塞式I/O操作或子进程的工作任务,适合用此办法处理。
可以通过自编队列来实现管线,然而往往比较困难。推荐使用Queue类来弥补自编队列的缺陷。
from queue import Queue
queue=Queue()
def consumer():
print('Consumer waiting')
queue.get() #取队列中的任务
print('Consumer done')
thread=Thread(target=consumer)
thread.start() # 启动线程
print('Producer putting')
queue.put(object()) # 任务放入Queue任务队列
thread.join() # 等待任务线程结束
print('Producer done')
此外,可以限定队列中待处理的最大任务数量,使得相邻的两个阶段,可以通过该队列平滑地衔接起来。
这个地方的代码和例子比较复杂,感兴趣的请仔细看原书。
考虑用协程来并发地运行多个函数
线程存在三个显著的缺点:
- 为了数据安全,必须使用特殊的工具协调线程,比较复杂;
- 线程需要占用大量内存,每个正在执行的线程,约占8MB内存。当线程量较大时会给计算机带来较大压力
- 线程启动时开销比较大 如果程序不停创建新线程来同时执行多个函数,并等待这些线程结束,那么使用线程所引发的开销,会拖慢整个程序速度。
Python的协程(coroutine)概念可以避免整个问题,使得程序看上去像是在同时运行多个函数。协程的实现方式,实际上是对生成器的一种扩展。开销比较低(与函数调用相近),占用内存较低。
def my_coroutine():
while True:
recv=yield # 接受回传值
print('Received:',recv)
it = my_coroutine()
next(it) # 初次执行生成器,让生成器进入到第一条yield表达式中
it.send('First')
it.send('Second')
# output:
#Received: First
#Received: Second
如上例所示:工作原理如下:当生成器函数执行到yield表达式时,执行相应的代码,通过send方法给生成器回传一个值。生成器收到该值后,会将其视为yield表达式的执行结果。 如上面代码注释所示,在调用send方法前,需要先调用一次next函数,以便将生成器推进到第一条yield表达式,之后就能将生成器(yield)和send操作结合起来,使得生成器能够根据外界所输入的数据,用一套标准流程产生对应的输出值。
再看下面这个更有趣的例子,这个生成器协程可以统计目前输入的最小值。
def minimize():
current=yield ## 执行第一次send时触发,将第一个输入的值当作目前的最小值,以便于后续对比
print('curr')
while True:
print('value')
value = yield current
current=min(value,current)
it = minimize()
next(it)
it.send(10)
it.send(4)
it.send(22)
it.send(2)
#output:
#curr
#value
#value
#value
#value
# 2
协程也是独立的函数,可以消耗由外部环境所传入的输入数据,并产生相应的输出结果。与线程不同的是:协程会在每个yield表达式暂停,等到外界再次调用send方法之后,才会继续执行到下一个yield
考虑使用concurrent.futures 实现真正的平行计算
当我们需要提升程序执行效率,节省执行时间时,可以考虑如何实现真正的平行计算,可以通过内置的concurrent.futures模块,来利用multiprocessing内置模块。这种做法会以子进程的形式,平行运行多个解释器,使得程序能够利用多核CPU,由于子进程与主解释器相分离,所以其GIL相互独立,每个子进程都可以完整利用一个内核。子进程同时与主进程存在联系,通过这条联系渠道,子进程可以接收主进程发过来的指令,并把计算结果返回给主进程。
这两个例子都是通过concurrent,futures来利用Multiprocessing模块,作者认为需要尽量避开multiprocessing里复杂的特性,初级的情况下优先用concurrent.futures来调用multiprocessing中线程或者进程来简单加速,更复杂的情况再使用multiprocessing.