文章目录
6.1 线程
6.1.1 多线程适用于阻塞式IO场景,不适用于并行计算场景
6.1.2 建议使用Queue来协调各线程之间的工作
6.2 协程
6.3 并行
6.1 线程
6.1.1 多线程适用于阻塞式IO场景,不适用于并行计算场景
Python的标准实现是CPython。
CPython执行Python代码分为2个步骤:首先,将文本源码解释编译为字节码,然后再用一个解释器去解释运行字节码。字节码解释器是有状态的,需要维护该状态的一致性,因此使用了GIL(Global Interpreter Lock,全局解释器锁)。
GIL的存在,使得CPython在执行多线程代码的时候,同一时刻只有一个线程在运行,无法利用多CPU提高运算效率。但是这个特点也带来了一个好处:CPython运行多线程的时候,内部对象缺省就是线程安全的。这个特性,被非常多的Python库开发者所依赖,直到CPython的开发者想要去除GIL的时候,发现已经有大量的代码库重度依赖
这个GIL带来的内部对象缺省就是线程安全的特性,变成一个无法解决的问题了。
虽然多线程在并行计算场景下无法带来好处,但是在阻塞式IO场景下,却仍然可以起到提高效率的作用。这是因为阻塞式IO场景下,线程在执行IO操作时并不需要占用CPU时间,此时阻塞IO的线程可以被挂起的同时继续执行IO操作,而让出CPU时间给其他线程执行非IO操作。这样一来,多线程并行IO操作就可以起到提高运行效率的作用了。
综上,Python的标准实现CPython,由于GIL的存在,同一个时刻只能运行一个线程,无法充分利用多CPU提升运算效率,因此Python的多线程适用于阻塞式IO的场景,不适用于并行计算的场景。
下面举一个对计算量有要求的求一个数的因数分解的代码实例,来说明Python多线程不适用于并行计算的场景:
# -*- coding:utf-8 -*-
from time import time
from threading import Thread
def factorize(number):
for i in range(1, number + 1):
if number % i == 0:
yield i
class FactorizeThread(Thread):
def __init__(self, number):
Thread.__init__(self)
self.number = number
def run(self):
self.factors = list(factorize(self.number))
def test(numbers):
start = time()
for number in numbers:
list(factorize(number))
end = time()
print('Took %.3f seconds' % (end - start))
def test_thread(numbers):
start = time()
threads = []
for number in numbers:
thread = FactorizeThread(number)
thread.start()
threads.append(thread)
for t in threads:
t.join()
end = time()
print('Mutilthread Took %.3f seconds' % (end - start))
if __name__ == "__main__":
numbers = [2139079, 1214759, 1516637, 1852285]
test(numbers)
test_thread(numbers)
# 代码输出:
# Took 0.319 seconds
# Mutilthread Took 0.539 seconds
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
以上代码运行结果只是一个参考值,具体数据跟运行环境相关。但是可以看到单线程方式比多线程方式的计算速度要快。由于CPython运行多线程代码时因为GIL的原因导致每个时刻只有一个线程在运行,因此多线程并行计算并不能带来时间上的收益,反而因为调度线程而导致总时间花费更长。
对于IO阻塞式场景,多线程的作用在于发生IO阻塞操作时可以调度其他线程执行非IO操作,因此在这个场景下,多线程是可以节省时间的。可以用以下的代码来验证:
# -*- coding:utf-8 -*-
from time import time
from threading import Thread
import os
def slow_systemcall(n):
for x in range(100):
open("test_%s" % n, "a").write(os.urandom(10) * 100000)
def test_io(N):
start = time()
for _ in range(N):
slow_systemcall(_)
end = time()
print('Took %.3f seconds' % (end - start))
def test_io_thread(N):
start = time()
threads = []
for _ in range(N):
thread = Thread(target=slow_systemcall, args=("t_%s" % _,))
thread.start()
threads.append(thread)
for thread in threads:
thread.join()
end = time()
print('Took %.3f seconds' % (end - start))
if __name__ == "__main__":
N = 5
test_io(N)
test_io_thread(N)
# 代码输出:
# Took 5.179 seconds
# Multithread Took 1.451 seconds
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
可以看到单线程花费时间与多线程花费时间之比接近1:4,考虑线程调度的时间,这个跟一般语言的多线程起的作用比较相似。这是因为当Python执行IO操作时,实际上是执行了系统调用,此时线程会释放GIL,直到系统调用结束时,再申请获取GIL,也就是在IO操作期间,线程确实是并行执行的。
Python的另外一个实现JPython就没有GIL,但是它并不是最常见的Python实现。
6.1.2 建议使用Queue来协调各线程之间的工作
如果Python程序同时要执行许多事务,那么开发者经常需要协调这些事务。而在各种协调方式中,较为高效的一种,则是采用函数管线。
管线的工作原理,与制造业中的组装生产线(assembly line)相似。管线分为许多首尾相连的阶段,每个阶段都由一种具体的函数来负责。程序总是把待处理的新部件添加到管线的开端。每一种函数都可以在它所负责的那个阶段内,并发地处理位于该阶段的部件。等负责本阶段的那个函数把某个部件处理好之后,该部件就会传送到管线中的下一个阶段,以此类推,直到全部阶段都经历一遍。涉及阻塞式I/O操作或子进程的工作任务,尤其适合用此办法处理,这样的任务很容易分配到多个Python线程或进程中。
例如,要构建一个照片处理系统,该系统从数码相机里面持续获取照片、调整其尺寸,并将其添加到网络相册中。这样的程序,可以采用三个阶段的管线来做。第一个阶段获取新图片,第二个阶段把下载好的图片传给缩放函数,第三个阶段把缩放后的图片交给上传函数。利用内置模块Queue中的Queue类来实现,可以变得容易且健壮。示例
代码如下:
from Queue import Queue
from threading import Thread
def download(item):
print('download item')
return item
def resize(item):
print('resize item')
return item
def upload(item):
print('upload item')
return item
class ClosableQueue(Queue):
SENTINEL = object()
def close(self):
self.put(self.SENTINEL)
def __iter__(self):
while True:
item = self.get()
try:
if item is self.SENTINEL:
return # Cause the thread to exit
yield item
finally:
self.task_done()
class StoppableWorker(Thread):
def __init__(self, func, in_queue, out_queue):
super(StoppableWorker, self).__init__()
self.in_queue = in_queue
self.out_queue = out_queue
self.func = func
def run(self):
for item in self.in_queue:
result = self.func(item)
self.out_queue.put(result)
if __name__ == '__main__':
download_queue = ClosableQueue()
resize_queue = ClosableQueue()
upload_queue = ClosableQueue()
done_queue = ClosableQueue()
threads = [
StoppableWorker(download, download_queue, resize_queue),
StoppableWorker(resize, resize_queue, upload_queue),
StoppableWorker(upload, upload_queue, done_queue),
]
for thread in threads:
thread.start()
for _ in range(1000):
download_queue.put(object())
download_queue.close()
download_queue.join()
resize_queue.close()
resize_queue.join()
upload_queue.close()
upload_queue.join()
print('%s items finished' % done_queue.qsize())
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
要点:
管线是一种优秀的任务处理方式,它可以把处理流程划分为若干阶段,并使用多条python线程来同事执行这些任务;
构建并发式的管线时,要注意许多问题,其中包括:如何防止某个阶段陷入持续等待的状态之中、如何停止工作线程、以及如何防止内存膨胀等;
Queue类所提供的机制可以彻底解决上述问题,它具备阻塞式的队列操作、能够指定缓冲区尺寸,而且还支持join方法,这使得开发者可以构建出健壮的管线;
6.2 协程
建议使用协程来处理并发场景
Python程序员可以使用线程来运行多个函数,使这些函数看上去好像是在统一时间得到执行,然而,线程有其显著的缺点:
多线程的运行协调起来比单线程过程式困难,需要依赖Lock来保证自己的多线程逻辑正确。
每个在执行的线程,大约需要8MB内存,在线程处理逻辑较少而数量较多的工程模型中开销较大。
线程启动、切换线程上下文的开销较大。
Python的协程(coroutine)可以避免上述问题,它使得Python程序看上去好像是在同时运行多个函数,运行逻辑的调度由程序员自己决定。协程的实现方式,实际上是对生成器的一种扩展。启动生成器协程所需要的开销,与调用函数相仿。处于活跃状态的协程,在其耗尽之前,只会占用到不到1KB的内存。
示例代码: 下面这段代码展示了Python利用yield表达式、生成器来实现的原生协程写法。 grep函数功能为筛选包含关键字的输入。 在定义生成器之后,需要使用next()启动生成器,此时生成器函数,会运行到yield表达式处等待输入。在这之后我们通过生成器的send方法向生成器传递输入,生成器就可以接着yield表达式处向下处理,处理完成后依靠while语句再次等待在yield表达式处。
>>> def grep(pattern): # 生成器函数
... print("Searching for", pattern)
... while True:
... line = (yield) # yield表达式,生成器函数接收send的输入
... if pattern in line:
... print(line)
...
>>> generator = grep("Python") # 定义生成器
>>> next(generator) # 使用next启动生成器之后,会运行到yield表达式
Searching for Python
>>> generator.send("I love Python.") # 给生成器函数发送数据,继续yield处的运行
I love Python.
>>> generator.send("I love C++.")
>>> generator.send("I love Java.")
>>> generator.send("I love Python too.")
I love Python too.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
如果在以上代码的基础上,连续推进多个独立的生成器,即可模拟出Python线程的并发行为,令程序看上去好像是在同时运行多个函数,同时其消耗相比多线程程序开销要小。
6.3 并行
建议使用concurrent.futures实现并行计算
Python程序可以将独立的计算任务分配到多个CPU核上运行,提升并行计算的能力。Python的GIL使得无法使用线程实现真正的并行计算。Python程序的真正并行计算建议采用子进程的方式来实现,具体实现如下:
对于并行计算的任务与主进程之间传递的数据比较少,且任务之间不需要共享状态和变量时,采用concurrent.furures的ProcessPoolExecutor类的简单实用方式即可实现并行计算;
对于并行计算的场景不满足1)的状态时,可以采用multiprocessing模块提供的共享内存、进程锁、队列、代理等高级功能实现并行计算。因为使用复杂,所以如果不是特性场景,不建议使用这种方式;
使用concurrent.furures的ProcessPoolExecutor类实现并行计算的示例代码如def calc_process():
start = time.time()
pool = ProcessPoolExecutor(max_workers=4)
results = list(pool.map(gcd, numbers))
end = time.time()
print('process calc, Took %.3f seconds' % (end - start))
print(results)
————————————————