effective python-编写高质量python代码的59个有效方法-读书笔记 36-38
并发 计算机似乎是在同一时间做着很多不同的事情。这种交错执行程序的方式,造成了一种假象,使我们以为这些程序可以同时运行。
并行 计算机确实是在同一时间作者很多不同的事。具备多个CPU核心的计算机,能够同时执行多个程序。各程序中的指令,都分别运行在每个CPU内核上面,这些程序就能够在同一时刻向前推进。
在同一个程序内部,并发是一种工具,它使程序员可以更加方便地解决特定类型的问题。
并行与并发的关键区别,就在于能不能提速。
用python语言编写并发程序,是比较容易的。通过系统调用、子进程和C语言扩展等机制,也可以用python平行地处理一些事务。
但是,要想使并发式的python代码以真正并行的方式来运行,却相当困难。
所以我们一定要明白:如何才能在这些有着微妙差别的情境之中,最为恰当地利用Python所提供的特性。
第三十六条 用subprocess模块来管理子进程
python提供了一些非常健壮的程序库,用来运行并管理子进程,这使得python能够很好地将命令行实用程序等工具黏合起来。
由python所启动的多个子进程,是可以平行运作的,这使得我们能够在python程序里充分利用电脑中的全部CPU核心,从而尽量提升程序的处理能力。
用subprocess模块运行子进程,是比较简单的。
煞笔window运行不了这一小节的程序,算了。
要点:
1.可以用subprocess模块运行子进程,并管理其输入流与输出流。
2.python解释器能够平行地运行多条子进程,这使得开发者可以充分利用CPU的处理能力。
3.可以给communicate方法传入timeout参数,以避免子进程死锁或失去响应。
第三十七条 可以用线程来执行阻塞式io,但不要用它做平行计算
标准的python实现叫做cpython。cpython分两步来运行python程序。
首先,把文本形式的源代码解析并编译成字节码。然后,用一种基于栈的解释器来运行这份字节码。
执行python程序时,字节码解释器必须保持协调一致的状态。
python采用GIL机制来确保这种协调性。
GIL实际上就是一把互斥锁,用以防止cpython受到占先式多线程切换操作的干扰。
所谓占先式多线程切换,是指某个线程可以通过打断另外一个线程的方式,来获取程序控制权。
假如这种干扰操作的执行时机不恰当,那就会破坏解释器状态。
GIL有一种非常显著的负面影响。
python程序尽管也支持多线程,但由于受到GIL保护,所以同一时刻,只有一条线程可以向前执行。
这就意味着,如果我们想利用多线程做平行计算,并希望借此为python提速,那么结果会非常令人失望。
from time import time def factorize(number): for i in range(1, number + 1): if number % i == 0: yield i numbers = [2139079,1214759,151673,1852285] start = time() for number in numbers: list(factorize(number)) end = time() print('took %.3f seconds'%(end-start))
took 0.659 seconds
from threading import Thread from time import time class FactorizeThread(Thread): def __init__(self, number): super().__init__() self.number = number def run(self): self.factors = list(factorize(self.number)) start = time() threads = [] for number in numbers: thread = FactorizeThread(number) thread.start() threads.append(thread) for thread in threads: thread.join() end = time() print('took %.3f seconds'%(end-start)) took 0.645 seconds
这样的结果说明,标准cpython解释器中的多线程受到了GIL的影响。
既然如此,python为什么还要支持多线程呢?
首先,多线程使得程序看上去好像能够在同一时间做许多事情。
第二个理由,是处理阻塞式的I/O操作,python在执行某些系统调用时,会触发此类操作。
执行系统调用,是指python程序请求计算机的操作系统与外界环境相交互,以满足程序的需求。(读写文件、在网络间通信,以及与显示器设备相交互)
为了响应这种阻塞式的请求,操作系统必须花一些时间,而开发者可以借助线程,把python程序与这些耗时的I/O操作隔离开。
window又没法正常执行书上的代码。select.select()执行报错。
GIL虽然使得python代码无法并行,但它对系统调用却没有任何负面影响。
由于python线程在执行系统调用的时候会释放GIL,并且一直要等到执行完毕才会重新获取它,所以GIL是不会影响系统调用的。
除了线程,还有其他一些方式,也能处理阻塞式I/O操作,例如内置的asyncio模块等。
虽然那些方式都有着非常显著的优点,但它们要求开发者必须花些功夫,将代码重构成另外一种执行模型。
如果既不想大幅度地修改程序,又要平行地执行多个阻塞式I/O操作,那么使用多线程来实现,会比较简单一些。
要点:
1.因为受到全局解释器锁的限制,所以多条python线程不能在多个CPU核心上面平行地执行字节码。
2.尽管受制于GIL,但是python的多线程功能依然很有用,它可以轻松地模拟出同一时刻执行多项任务的效果。
3.通过python线程,我们可以平行地执行多个系统调用,这使得程序能够在执行阻塞式I/O操作的同时,执行一些运算操作。
第三十八条 在线程中使用Lock来防止数据竞争
明白了全局解释器锁机制之后,许多python编程新手可能会认为:自己在编写python代码时,也不需要再使用互斥锁了。
请注意,真相并非如此。
实际上,GIL并不会保护开发者自己所编写的代码。
同一时刻固然只能有一个python线程得以执行,但是,当这个线程正在操作某个数据结构,其他线程可能会打断它,
也就是说,python解释器在执行两个连续的字节码指令时,其他线程可能会在中途突然插进来。
如果开发者尝试从多个线程中同时访问某个对象,那么上述情形就会引发危险的结果。
这种中断现象随时都可能发生,一旦发生,就会破坏程序的状态,从而使相关的数据结构无法保持其一致性。
from threading import Thread, Lock
from time import time
class Counter:
def __init__(self):
self.count = 0
def increment(self, offset):
self.count += offset
def worker(sernsor_index, how_many, counter):
for _ in range(how_many):
counter.increment(1)
def run_threads(func, how_many, counter):
threads = []
for i in range(5):
args = (i, how_many, counter)
thread = Thread(target=func, args=args)
threads.append(thread)
thread.start()
for thread in threads:
thread.join()
how_many = 10**5
counter = Counter()
start = time()
run_threads(worker, how_many, counter)
end = time()
print('Counter should be %d, found %d' %(5*how_many, counter.count))
print('took %.3f seconds'%(end-start))
Counter should be 500000, found 449003 took 0.133 seconds
为了保证所有的线程都能够公平地执行,python解释器会给每个线程分配大致相等的处理器时间。
为了达成这样的分配策略,python系统可能当某个线程正在执行的时候,将其暂停,然后使另外一个线程继续往下执行。
问题就在于,开发者无法准确地获知python系统会在何时暂停这些线程。
有一些操作,看上去好像是原子操作,但python系统依然有可能在线程执行到一半的时候将其暂停。于是就发生了上面那种情况。
为了防止诸如此类的数据竞争行为,python在内置的threading模块里提供了一套健壮的工具,使得开发者可以保护自己的数据结构不受破坏。
其中,最简单、最有用的工具,就是Lock类,该类相当于互斥锁。
我们可以用互斥锁来保护Counter对象,使得多个线程同时访问value值的时候,不会将该值破坏。
同一时刻只有一个线程能够获得这把锁。
范例代码中使用with语句来获取并释放互斥锁,这样写,能够使阅读代码的人更容易看出:线程在拥有互斥锁时,执行的究竟是那一部分代码。
from threading import Thread, Lock from time import time class LockingCounter: def __init__(self): self.lock = Lock() self.count = 0 def increment(self, offset): with self.lock: self.count += offset def worker(sernsor_index, how_many, counter): for _ in range(how_many): counter.increment(1) def run_threads(func, how_many, counter): threads = [] for i in range(5): args = (i, how_many, counter) thread = Thread(target=func, args=args) threads.append(thread) thread.start() for thread in threads: thread.join() how_many = 10**5 counter = LockingCounter() start = time() run_threads(worker, how_many, counter) end = time() print('Counter should be %d, found %d' %(5*how_many, counter.count)) print('took %.3f seconds'%(end-start))
Counter should be 500000, found 500000 took 2.697 seconds
这样的运行结果才是我们想要的答案。
由此可见,Lock对象解决了数据竞争的问题。
(但是,加了时间统计的功能,发现差了二十倍的速度,本来我以为只会差五倍左右)
要点:
1.python确实有全局解释器锁,但是在编写自己的程序时,依然要设法防止多个线程争用同一份数据。
2.如果在不加锁的前提下,允许多条线程修改同一个对象,那么程序的数据结构可能会遭到破坏。
3.在python内置的threading模块中,有个名叫Lock的类,它用标准的方式实现了互斥锁。