前言

在这个自媒体泛滥的时代,有时候能够碰到一个能让人醍醐灌顶的文章真的不容易,本文源于“一行Python实现并行化--日常多线程操作的新思考” ,这篇文章本来是推荐给别人的,我只是稍稍看了一下,下班之后我突发奇想认真的将本文看完之后,让我有了将这篇文章进行扩充的冲动,于是就有了这篇博客。

原文出处:
一行Python实现并行化–日常多线程操作的新思考

在讨论Python的并行化的话题之前,我们先看看Python的多线程和多进程

多线程

线程是一种对于非顺序依赖的得多个任务进行解耦的技术,多线程可以提高应用的响应效率,当接收用户输入的同时,保持其它任务在后台运行。一个有用的场景就是,将I/O和计算运行在两个线程上。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import threading , zipfile


class AsyncZip(threading.Thread):
def __init__(self, infile, outfile):
threading.Thread.__init__(self)
self._infile = infile
self._outfile = outfile

def run(self):
file = zipfile.ZipFile(self._infile, 'w', zipfile.ZIP_DEFLATED)
file.write(self._infile)
file.close()
print("Finished backgound zip of {}".format(self._infile))


if __name__ == "__main__":
background_task = AsyncZip('text.txt', 'test.zip')
background_task.start()
print("background task started...")
background_task.join()
print("background task finished...")

多线程应用面临的主要问题是,相互协调的多个线程之间需要共享数据或者其它资源。为此,python的threading模块提供了多个线程之间的同步操作原语,包括线程锁、事件、条件变量和信号量。
尽管这些工具非强强大,但是对于一个不熟悉的新手来说,一旦程序上出现逻辑错或者处理不当就会导致一个难以复现的奇怪问题,甚至是死锁。因此,实现多任务协调的首选方式是对资源的所有请求集中到一个线程中,然后使用Queue模块来实现线程之间的通信和协调,这样做的话更加容易设计、程序更加易读、更可靠。

一个传统多线程的例子

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
import time
import Queue
import Threading


class Consumer(threading.Thread)
def __init__(self, queue):
threading.Thread.__init__(self)
self._queue = queue

def run(self):
while True:
msg = self._queue.get()
if isinstance(msg, str) and msg == "quit":
break
print("I'm a Thread and I have received {} !".format(msg))
print("Bye!, see you next time.")


def Producer():
queue = Queue.Queue()
worker = Consumer(queue)
worker.start()
start_time = time.time()
while time.time() - strat_time < 5:
queue.put("something at {}".format(time.time()))
time.sleep(1)
queue.put("quit")
worker.join()


if __name__ == "__main__":
Producer()s

上述代码是一个典型的生产者/消费者的模型处理多线程,这个模型在很多场景下面有很多的应用,但是它也是有一些问题的:首先,我们需要创建一个样板类。其次,我们需要一个队列来传递对象。而且,我们需要在通道两端构建相应的方法来协助其工作如果需要进行双向通信或者对处理的结果进行保存的话还需要引入一个队列。

多Worker的情景

按照之前的思路,我们需要创建一个worker线程池,下面为IBM的一个经典例子,在网页检索的时候进行加速。

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
import time 
import queue
import urllib2
import threading


class Consumer(threading.Thread):
def __init__(self, queue):
threading.Thread.__init__(self)
self._queue = queue

def run(self):
while True:
content = self._queue.get()
if isinstance(content, str) and content == "quit":
break
response = urllib2.urlopen(content)
print("Bye!")


def Producer():
urls = [
"https://www.python.org",
"https://www.yahoo.com",
"https://www.google.com"
# etc.
]
queue = Queue.Queue()
worker_threads = build_worker_pool(queue, 4)
start_time = time.time()
for url in urls:
queue.put(url)
for worker in worker_threads:
queue.put("quit")
for worker in worker_threads:
worker.join()
print('Done! Time taken: {}'.format(time.time() - start_time))


def build_worker_pool(queue, size):
workers_list = []
for _ in range(size):
worker_thread = Consumer(queue)
worker_thread.start()
workers_list.append(worker_thread)
return worker_list


if __name__ == "__main__":
Producer()

这段代码能正确的运行,但仔细看看我们需要做些什么:构造不同的方法、追踪一系列的线程,还有为了解决恼人的死锁问题,我们需要进行一系列的 join 操作。这还只是开始……

尝试map()

python内建了map()reduce()函数,如果你读过Google的那篇大名鼎鼎的论文 MapReduce: Simplified Data Processing on Large Clusters,你就能大概明白map/reduce的概念。

我们先看map。map()函数接收两个参数,一个是函数,一个是序列,map将传入的函数依次作用到序列的每个元素,并把结果作为新的list返回。

举例说明,比如我们有一个函数f(x)=x^2,要把这个函数作用在一个list [1, 2, 3, 4, 5, 6, 7, 8, 9]上,就可以用map()实现如下:

我们用python代码实现:

1
2
3
4
def f(x):
return x * x

res = map(f, [1, 2, 3, 4, 5, 6, 7, 8, 9])

map()这一小巧精致的函数是简捷实现 Python 程序并行化的关键。map 源于 Lisp 这类函数式编程语言。它可以通过一个序列实现两个函数之间的映射。

1
2
urls = ['http://www.yahoo.com', 'http://www.reddit.com']
results = map(urllib2.urlopen, urls)

上面的这两行代码将 urls 这一序列中的每个元素作为参数传递到 urlopen 方法中,并将所有结果保存到 results 这一列表中。其结果大致相当于:

1
2
3
results = []
for url in urls:
results.append(urllib2.urlopen(url))

map 函数一手包办了序列操作、参数传递和结果保存等一系列的操作。为什么这很重要呢?这是因为借助正确的库,map 可以轻松实现并行化操作。

在 Python 中有个两个库包含了 map 函数: multiprocessing 和它鲜为人知的子库 multiprocessing.dummy。dummy 是 multiprocessing 模块的完整克隆,唯一的不同在于 multiprocessing 作用于进程,而 dummy 模块作用于线程(因此也包括了 Python 所有常见的多线程限制),所以替换使用这两个库异常容易。你可以针对 IO 密集型任务和 CPU 密集型任务来选择不同的库。并且官方文档上也进行了表明,这里多说一句,python官网从3.7版本开始已经全面支持简体中文文档,python官方都如此上心了,再学不会python就不好了吧?🤣

尝试dumy

使用下面的两行代码来引用包含并行化 map 函数的库:

1
2
from multiprocessing import Pool
from multiprocessing.dummy import Pool as ThreadPool

实例化 Pool 对象:

1
pool = ThreadPool()

这条简单的语句替代了 build_worker_pool 函数 7 行代码的工作。它生成了一系列的 worker 线程并完成初始化工作、将它们储存在变量中以方便访问。Pool 对象有一些参数,这里我所需要关注的只是它的第一个参数:processes. 这一参数用于设定线程池中的线程数。其默认值为当前机器 CPU 的核数。一般来说,执行 CPU 密集型任务时,调用越多的核速度就越快。但是当处理网络密集型任务时,事情有有些难以预计了,通过实验来确定线程池的大小才是明智的。

1
pool = ThreadPool(4)

线程数过多时,切换线程所消耗的时间甚至会超过实际工作时间。对于不同的工作,通过尝试来找到线程池大小的最优值是个不错的主意。
创建好 Pool 对象后,我们来对上面的程序经典线程程序改写:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import urllib2
from multiprocessing.dummy import Pool as ThreadPool

urls = [
'http://www.python.org',
'http://www.python.org/about/',
'http://www.onlamp.com/pub/a/python/2003/04/17/metaclasses.html',
'http://www.python.org/doc/',
'http://www.python.org/download/',
# etc..
]

if __name__ == "__main__":
pool = ThreadPool(4)
res = pool.map(urllib2.urlopen, urls)
pool.close()
pool.join()

实际起作用的代码只有 4 行,其中只有一行是关键的。map 函数轻而易举的取代了前文中超过 40 行的例子。

一个真实的例子

生成上千张图片的缩略图,这是一个 CPU 密集型的任务,并且十分适合进行并行化。

基础版本

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
import os 
import PIL

from multiprocessing import Pool
from PIL import Image

SIZE = (75,75)
SAVE_DIRECTORY = 'thumbs'

def get_image_paths(folder):
return (os.path.join(folder, f)
for f in os.listdir(folder)
if 'jpeg' in f)

def create_thumbnail(filename):
im = Image.open(filename)
im.thumbnail(SIZE, Image.ANTIALIAS)
base, fname = os.path.split(filename)
save_path = os.path.join(base, SAVE_DIRECTORY, fname)
im.save(save_path)

if __name__ == '__main__':
folder = os.path.abspath(
'11_18_2013_R000_IQM_Big_Sur_Mon__e10d1958e7b766c3e840')
os.mkdir(os.path.join(folder, SAVE_DIRECTORY))

images = get_image_paths(folder)

for image in images:
create_thumbnail(Image)

上边这段代码的主要工作就是将遍历传入的文件夹中的图片文件,一一生成缩略图,并将这些缩略图保存到特定文件夹中。
如果我们使用 map 函数来代替 for 循环:

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
import os 
import PIL

from multiprocessing import Pool
from PIL import Image

SIZE = (75,75)
SAVE_DIRECTORY = 'thumbs'

def get_image_paths(folder):
return (os.path.join(folder, f)
for f in os.listdir(folder)
if 'jpeg' in f)

def create_thumbnail(filename):
im = Image.open(filename)
im.thumbnail(SIZE, Image.ANTIALIAS)
base, fname = os.path.split(filename)
save_path = os.path.join(base, SAVE_DIRECTORY, fname)
im.save(save_path)

if __name__ == '__main__':
folder = os.path.abspath(
'11_18_2013_R000_IQM_Big_Sur_Mon__e10d1958e7b766c3e840')
os.mkdir(os.path.join(folder, SAVE_DIRECTORY))

images = get_image_paths(folder)

pool = Pool()
pool.map(creat_thumbnail, images)
pool.close()
pool.join()

最后

在生产环境中,我们可以为 CPU 密集型任务和 IO 密集型任务分别选择多进程和多线程库来进一步提高执行速度——这也是解决死锁问题的良方。此外,由于 map 函数并不支持手动线程管理,反而使得相关的 debug 工作也变得异常简单。