Effective Python -- 第 5 章 并发与并行(上)

第 5 章 并发与并行(上)

第 36 条:用 subprocess 模块来管理子进程

Python 提供了一些非常健壮的程序库,用来运行并管理子进程,这使得 Python 语言能够很好地将命令行实用程序(command-line utility)等工具黏合起来。现有的 shell 脚本一般都会越写越复杂,在这种情况下,为了使程序代码更易读懂且更易维护,很自然地就会考虑用 Python 改写。

由 Python 所启动的多个子进程,是可以平行运作的,这使得我们能够在 Python 程序里充分利用电脑中的全部 CPU 核心,从而尽量提升程序的处理能力(throughput,吞吐量)。虽然 Python 解释器本身可能会受限于 CPU,但是开发者依然可以用 Python 顺畅地驱动并协调那些耗费 CPU 的工作任务。

在多年的发展过程中,Python 演化出了许多种运行子进程的方式,其中包括 popen、popen2 和 os.exec* 等。然而,对于当今的 Python 来说,最好用且最简单的子进程管理模块,应该是内置的 subprocess 模块。

用 subprocess 模块运行子进程,是比较简单的。下面这段代码,用 Popen 构造器来启动进程。然后用 communicate 方法读取子进程的输出信息,并等待其终止。

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 解释器。在下面这个范例程序中,可以一边定期查询子进程的状态,一边处理其他事务。

proc = subprocess.Popen(['sleep', '0.3']}
while proc.poll() is None:
    print('Working. ..')
    # Some time-consuming work here
    # ...

print('Exit status', proc.poll())
>>>
working...
Working...
Exit status 0

把子进程从父进程中剥离(decouple,解耦),意味着父进程可以随意运行很多条平行的子进程。为了实现这一点,可以先把所有的子进程都启动起来。

def run_sleep(period):
    proc = subprocess.Popen(['sleep', str(period)])
    return proc

start = time()
procs = []
for _ in range(10):
    proc = run_sleep(0.1)
    procs.append(proc)

然后,通过 communicate 方法,等待这些子进程完成其 I/O 工作并终结。

for proc in procs:
    proc.communicate()
end = time()
print('Finished in %.3f seconds' % (end - start))
>>>
Finished in 0.117 seconds

开发者也可以从 Python 程序向子进程输送数据,然后获取子进程的输出信息。这使得可以利用其他程序来平行地执行任务。例如,要用命令行式的 openssl 工具加密一些数据。下面这段代码,能够以相关的命令行参数及 I/O 管道,轻松地创建出完成此功能所需的子进程。

def run_openssl(data):
    env = os.environ.copy()
    env['password'] = b'\xe24U\n\xd0Q13S\x11'
    proc = subprocess.Popen(
        ['openssl', 'enc', '-des3', '-pass', 'env:password'],
        env=env,
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE)
    proc.stdin.write(data)
    proc.stdin.flush()  # Ensure the child gets input
    return proc

然后,把一些随机生成的字节数据,传给加密函数。请注意,在实际工作中,传入的应该是用户输入信息、文件句柄、网络套接字等内容:

procs = []
for _ in range(3):
    data = os.urandom(10)
    proc = run_openssl(data)
    procs.append(proc)

接下来,这些子进程就可以平行地运作并处理它们的输入信息了。此时,主程序可以等待这些子进程运行完毕,然后获取它们最终的输出结果:

for proc in procs:
    out, err = proc.communicate()
    print(out[-10:])
>>>
b'o4,G\×91\×95\xfe\xa0\xaa\xb7'
b'\x0b\x01\\\xb1\xb7\xfb\xb2C\xe1b'
b'ds\xc5\xf4;j\x1f\xd0c-'

此外,还可以像 UNIX 管道那样,用平行的子进程来搭建平行的链条,所谓搭建链条(chain),就是把第一个子进程的输出,与第二个子进程的输入联系起来,并以此方式继续拼接下去。下面这个函数,可以启动一个子进程,而该进程会用命令行式的 md5 工具来处理输入流中的数据:

def run_md5(input_stdin):
    proc = subprocess.Popen(
        ['md5'],
        stdin=input_stdin,
        stdout=subprocess.PIPE)
    return proc

现在,启动一套 openssl 进程,以便加密某些数据,同时启动另一套 md5 进程,以便根据加密后的输出内容来计算其哈希码(hash,杂凑码)。

input_procs = []
hash_procs = []
for _ in range(3):
    data = os.urandom(10)
    proc = run_openssl(data)
    input_procs.append(proc)
    hash_proc = run_md5(proc.stdout)
    hash_procs.append(hash_proc)

启动起来之后,相关的子进程之间就会自动进行 I/O 处理。主程序只需等待这些子进程执行完毕,并打印最终的输出内容即可。

for proc in input_procs:
    proc.communicate()
for proc in hash_procs:
    out, err = proc.communicate()
    print(out.strip())
>>>
b'7a1822875dcf9650a5a71e5e41e77bf3'
b'd41d8cd98f00b204e9800998ecf8427e'
b'1720f581cfdc448b6273048d42621100'

如果你担心子进程一直不终止,或担心它的输出管道及输出管道由于某些原因发生了阻塞,那么可以给 communicate 方法传入 timeout 参数。该子进程若在指定时间段内没有给出响应,communicate 方法则会抛出异常,可以在处理异常的时候,终止出现意外的子进程。

proc = run_sleep(10)
try:
    proc.communicate(timeout=0.1)
except subprocess.TimeoutExpired:
    proc.terminate()
    proc.wait()

print('Exit status', proc.poll())
>>>
Exit status -15

不幸的是,timeout 参数仅在 Python 3.3 及后续版本中有效。对于早前的 Python 版本来说,需要使用内置的 select 模块来处理 proc.stdin、proc.stdout 和 proc.stderr,以确保 I/O 操作的超时机制能够生效。

总结

  • 可以用 subprocess 模块运行子进程,并管理其输入流与输出流。
  • Python 解释器能够平行地运行多条子进程,这使得开发者可以充分利用 CPU 的处理能力。
  • 可以给 communicate 方法传入 timeout 参数,以避免子进程死锁或失去响应(hanging,挂起)。

第 37 条:可以用线程来执行阻塞式 I/O,但不要用它做平行计算

标准的 Python 实现叫做 CPython。CPython 分两步来运行 Python 程序。首先,把文本形式的源代码解析并编译成字节码。然后,用一种基于栈的解释器来运行这份字节码。执行 Python 程序时,字节码解释器必须保持协调一致的状态。Python 采用 GIL(global interpreter lock,全局解释器锁)机制来确保这种协调性。

GIL 实际上就是一把互斥锁(mutual-exclusion lock,又称为 mutex,互斥体),用以防止 CPython 受到占先式多线程切换(preemptive multithreading)操作的干扰。所谓占先式多线程切换,是指某个线程可以通过打断另外一个线程的方式,来获取程序控制权。假如这种干扰操作的执行时机不恰当,那就会破坏解释器的状态。而有了 GIL 之后,这些干扰操作就不会发生了,GIL 可保证每条字节码指令均能够正确地与 CPython 实现及其 C 语言扩展模块协同运作。

GIL 有一种非常显著的负面影响。用 C++ 或 Java 等语言写程序时,可以同时执行多条线程,以充分利用计算机所配备的多个 CPU 核心。Python 程序尽管也支持多线程,但由于受到 GIL 保护,所以同一时刻,只有一条线程可以向前执行。这就意味着,如果我们想利用多线程做平行计算(parallel computation),并希望借此为 Python 程序提速,那么结果会非常令人失望。

例如,要用 Python 执行一项计算量很大的任务。为了模拟此任务,笔者编写了一种非常原始的因数分解算法。

def factorize(number):
    for i in range(1, number + 1):
        if number % i == 0:
            yield i

如果逐个地分解许多数字,就会耗费比较长的时间。

numbers = [2139079121475915166371852285]
start = time()
for number in numbers:
    list(factorize(number))
end = time()
print('Took %.3f seconds' % (end - start))
>>>
Took 1.040 seconds

假如使用其他语言编写程序,那就可以采用多线程来进行计算,因为那样做能够利用计算机所配备的全部 CPU 核心,但是对 Python 来说,却未必如此。不妨先试试看。下面定义的这个 Python 线程,可以执行与刚才那段范例代码相同的运算:

from threading import Thread

class FactorizeThread(Thread):
    def __init__(self, number):
        super().__init__()
        self.number = number

def run(self):
    self.factors = list(factorize(self.number))

然后,为了实现平行计算,我们为 numbers 列表中的每个数字,都启动一条线程。

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 1.061 seconds

令人惊讶的是,这样做所耗费的时间,竞然比逐个执行 factorize 所耗的还要长。由于每个数字都有专门的线程负责分解,所以假如改用其他编程语言来实现,那么扣除创建线程和协调线程所需的开销之后,程序的执行速度在理论上应该接近原来的 4 倍。运行范例代码所用的计算机,拥有两个 CPU 核心,所以程序执行速度应该变为原来的 2 倍。本来打算利用多个 CPU 核心来提升程序的速度,但却没有料到多线程的 Python 程序执行得比单线程还要慢。这样的结果说明,标准 CPython 解释器中的多线程程序受到了 GIL 的影响。

通过其他一些方式,确实可以令 CPython 解释器利用 CPU 的多个内核,但是那些方式所使用的并不是标准的 Thread 类,而且还需要开发者编写较多的代码。明白了这些限制之后,你可能会问:那既然如此,Python 为什么还要支持多线程呢?下面有两个很好的理由。

首先,多线程使得程序看上去好像能够在同一时间做许多事情。如果要自己实现这种效果,并手工管理任务之间的切换,那就显得比较困难。而借助多线程,则能够令 Python 程序自动以一种看似平行的方式,来执行这些函数。之所以能如此,是因为 CPython 在执行 Python 线程的时候,可以保证一定程度的公平。不过,由于受到 GIL 限制,所以同一时刻实际上只能有一个线程得到执行。

Python 支持多线程的第二条理由,是处理阻塞式的 I/O 操作,Python 在执行某些系统调用时,会触发此类操作。执行系统调用,是指 Python 程序请求计算机的操作系统与外界环境相交互,以满足程序的需求。读写文件、在网络间通信,以及与显示器等设备相交互等,都属于阻塞式的 I/O 操作。为了响应这种阻塞式的请求,操作系统必须花一些时间,而开发者可以借助线程,把 Python 程序与这些耗时的 I/O 操作隔离开。

例如,要通过串行端口(serial port,简称串口)发送信号,以便远程控制一架直升飞机。采用一个速度较慢的系统调用(也就是 select)来模拟这项活动。该函数请求操作系统阻塞 0.1 秒,然后把控制权还给程序,这种效果与通过同步串口来发送信号是类似的。

import select
def slow_systemcall():
    select.select([], [], [], 0.1)

如果逐个执行上面这个系统调用,那么程序所耗的总时间,就会随着调用的次数而增加。

start = time()
for _ in range(5):
    slow_systemcall()
end = time()
print('Took %.3f seconds' % (end - start))
>>>
Took 0.503 seconds

上面这种写法的问题在于:主程序在运行 slow_systemcall 函数的时候,不能继续向下执行,程序的主线程会卡在 select 系统调用那里。这种现象在实际的编程工作中是非常可怕的。因为发送信号的同时,程序必须算出直升飞机接下来要移动到的地点,否则飞机可能就会撞毁。如果要同时执行阻塞式 I/O 操作与计算操作,那就应该考虑把系统调用放到其他线程里面。

下面这段代码,把多个 slow_systemcall 调用分别放到多条线程中执行,这样写,使得程序既能够与多个串口通信(或是通过多个串口来控制许多架直升飞机),又能够同时在主线程里执行所需的计算。

start = time()
threads = []
for _ in range(5):
    thread = Thread(target=slow_systemcall)
    thread.start()
    threads.append(thread)

线程启动好之后,我们先算出直升机接下来要移动到的地点,然后等待执行系统调用的线程都运行完毕。

def compute_helicopter_location(index):
    # ...

for i in range(5):
    compute_helicopter_location(i)
for thread in threads:
    thread.join()
end = time()
print('Took %.3f seconds' % (end - start))
>>>
Took 0.102 seconds

与早前那种逐个执行系统调用的方案相比,这种平行方案的执行速度,接近于原来的 5 倍。这说明,尽管受制于 GIL,但是用多个 Python 线程来执行系统调用的时候,这些系统调用可以平行地执行。GIL 虽然使得 Python 代码无法并行,但它对系统调用却没有任何负面影响。由于 Python 线程在执行系统调用的时候会释放 GIL,并且一直要等到执行完毕才会重新获取它,所以 GIL 是不会影响系统调用的。

除了线程,还有其他一些方式,也能处理阻塞式的 I/O 操作,例如,内置的 asyncio 模块等。虽然那些方式都有着非常显著的优点,但它们要求开发者必须花些功夫,将代码重构成另外一种执行模型。如果既不想大幅度地修改程序,又要平行地执行多个阻塞式 I/O 操作,那么使用多线程来实现,会比较简单一些。

总结

  • 因为受到全局解释器锁(GIL)的限制,所以多条 Python 线程不能在多个 CPU 核心上面平行地执行字节码。
  • 尽管受制于GIL,但是 Python 的多线程功能依然很有用,它可以轻松地模拟出同一时刻执行多项任务的效果。
  • 通过 Python 线程,我们可以平行地执行多个系统调用,这使得程序能够在执行阻塞式 I/O 操作的同时,执行一些运算操作。

第 38 条:在线程中使用 Lock 来防止数据竞争

明白了全局解释器锁(GIL)机制之后,许多 Python 编程新手可能会认为:自己在编写 Python 代码时,也不需要再使用互斥锁(也称为 mutex,互斥体)了。他们觉得:既然 GIL 使得 Python 线程无法平行地运行在多个 CPU 核心上面,那么它必然也会对程序中的数据结构起到锁定作用,不是吗?用列表或字典这样的数据类型做一些测试之后,甚至会认为上面这种说法很有道理。

但是请注意,真相并非如此。实际上,GIL 并不会保护开发者自己所编写的代码。同一时刻固然只能有一个 Python 线程得以运行,但是,当这个线程正在操作某个数据结构时,其他线程可能会打断它,也就是说,Python 解释器在执行两个连续的字节码指令时,其他线程可能会在中途突然插进来。如果开发者尝试从多个线程中同时访问某个对象,那么上述情形就会引发危险的结果。这种中断现象随时都可能发生,一旦发生,就会破坏程序的状态,从而使相关的数据结构无法保持其一致性。

例如,要编写一个程序,平行地统计许多事物。现在假设该程序要从一整套传感器网络中对光照级别进行采样,那么采集到的样本总数,就会随着程序的运行不断增多,于是,新建名为 Counter 的类,专门用来表示样本数量。

class Counter(object):
    def __init__(self):
        self.count = 0

    def increment(self, offset):
        self.count += offset

在查询传感器读数的过程中,会发生阻塞式 I/O 操作,所以,要给每个传感器分配它自己的工作线程(workcr thrcad)。每采集到一次读数,工作线程就会给 Counter 对象的 value 值加 1,然后继续采集,直至完成全部的采样操作。

def worker(sensor_index, how_many, counter):
    for _ in range(how_many):
        # Read from the sensor
        # ...
        counter.increment(1)

下面定义的这个 run_threads 函数,会为每个传感器启动一条工作线程,然后等待它们完成各自的采样工作:

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()

然后,平行地执行这 5 条线程。可以觉得:这个程序的结果,应该是非常明确的。

how_many = 10**5
counter = Counter()
run_threads(worker, how_many, counter)
print('Counter should be %d, found %d' % (5 * how_many, counter.count))

>>>
Counter should be 500000, found 278328

但是、看到输出信息之后,却发现,它与正确的结果相差很远。这么简单的程序,怎么会出这么大的错呢?由于 Python 解释器在同一时刻只能运行一个线程,所以这种错误就更令人费解了。

为了保证所有的线程都能够公平地执行,Python 解释器会给每个线程分配大致相等的处理器时间。而为了达成这样的分配策略,Python 系统可能当某个线程正在执行的时候,将其暂停(suspend),然后使另外一个线程继续往下执行。问题就在于,开发者无法准确地获知 Python 系统会在何时暂停这些线程。有一些操作,看上去好像是原子操作(atomic operation),但 Python 系统依然有可能在线程执行到一半的时候将其暂停。于是,就发生了上面那种情况。

Counter 对象的 increment 方法看上去很简单。

counter.count += offset

但是,在对象的属性上面使用 += 操作符,实际上会令 Python 于幕后执行三项独立的操作。上面那条语句,可以拆分成下面这三条语句:

value = getattr(counter, 'count')
result = value + offset
setattr(counter, 'count', result)

为了实现自增,Python 线程必须依次执行上述三个操作,而在任意两个操作之间,都有可能发生线程切换。这种交错执行的方式,可能会令线程把旧的 value 设置给 Counter,从而使程序的运行结果出现问题。用 A 和 B 这两个线程,来演示这种情况:

# Running in Thread A
value_a = getattr(counter, 'count')
# Context switch to Thread B
value_b = getattr(counter, 'count')
result_b = value_b + 1
setattr(counter, 'count", result_b)
# Context switch back to Thread A
result_a = value_a + 1
setattr(counter, 'count', result_a)

在上例中,线程 A 执行到一半的时候,线程 B 插了进来,等线程 B 执行完整个递增操作之后,线程 A 又继续执行,于是,线程 A 就把线程 B 刚才对计数器所做的递增效果,完全抹去了。传感器采样程序所统计到的样本总数之所以会出错,正是这个原因。

为了防止诸如此类的数据竞争(data race,数据争用)行为,Python 在内置的 threading 模块里提供了一套健壮的工具,使得开发者可以保护自己的数据结构不受破坏。其中,最简单、最有用的工具,就是 Lock 类,该类相当于互斥锁(也叫做互斥体)。

可以用互斥锁来保护 Counter 对象,使得多个线程同时访问 value 值的时候,不会将该值破坏。同一时刻,只有一个线程能够获得这把锁。下面这段范例代码,用 with 语句来获取并释放互斥锁,这样写,能够使阅读代码的人更容易看出:线程在拥有互斥锁时,执行的究竟是哪一部分代码。

class LockingCounter(object):
    def __init__(self):
        self.lock = Lock()
        self.count = 0

    def increment(self, offset):
        with self.Tock:
            self.count += offset

接下来,还是和往常一样,启动工作线程,只不过这次改用 LockingCounter 来做计数器。

counter = LockingCounter()
run_threads(worker, how_many, counter)
print('Counter shou1d be %d, found %d' % (5 * how_many, counter.count))
>>>
Counter should be 500000, found 500000

这样的运行结果,才是想要的答案。由此可见,Lock 对象解决了数据竞争问题。

总结

  • Python 确实有全局解释器锁,但是在编写自己的程序时,依然要设法防止多个线程争用同一份数据。
  • 如果在不加锁的前提下,允许多条线程修改同一个对象,那么程序的数据结构可能会遭到破坏。
  • 在 Python 内置的 threading 模块中,有个名叫 Lock 的类,它用标准的方式实现了互斥锁。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值