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的类,它用标准的方式实现了互斥锁。

 

posted @ 2019-06-23 22:50  铁树小寒  阅读(348)  评论(0编辑  收藏  举报