在群里经常听到这样的争执,有人是虚心请教问题,有人就大放厥词因为这个说python辣鸡。而争论的核心无非就是,python的多线程在同一时刻只会有一条线程跑在CPU里面,其他线程都在睡觉。这是真的吗?
是真的。这个就是因为传说中的GIL(全局解释锁)的存在。不明白这个词的可以去百度一下,我解释不好(大家都是程序猿你懂的,我写错一个词就要挨喷了,就算我没写错对方理解错了,我也一样要挨喷)。有了这样一个看似bug的存在,就导致了上面说的情况:同一时刻内,python的线程只有一条在CPU里面运行。
所以python的多线程就没用咯?当然不是。这要看程序是什么样的。如果是一个计算为主的程序(专业一点称为CPU密集型程序),这一点确实是比较吃亏的,每个线程运行一遍,就相当于单线程再跑,甚至比单线程还要慢——CPU切换线程的上下文也是要有开销的。但是,如果是一个磁盘或网络为主的程序(IO密集型)就不同了。一个线程处在IO等待的时候,另一个线程还可以在CPU里面跑,有时候CPU闲着没事干,所有的线程都在等着IO,这时候他们就是同时的了,而单线程的话此时还是在一个一个等待的。我们都知道IO的速度比起CPU来是慢到令人发指的,python的多线程就在这时候发挥作用了。比方说多线程网络传输,多线程往不同的目录写文件,等等。
话说回来,CPU密集型的程序用python来做,本身就不合适。跟C,Go,Java的速度比,实在性能差到没法说。你当然可以写个C扩展来实现真正的多线程,用python来调用,那样速度是快。我们之所以用python来做,只是因为开发效率超高,可以快速实现。
最后补充几点:
- python中要想利用好CPU,还是用多进程来做吧。或者,可以使用协程。multiprocessing和gevent在召唤你。
- GIL不是bug,Guido也不是水平有限才留下这么个东西。龟叔曾经说过,尝试不用GIL而用其他的方式来做线程安全,结果python语言整体效率又下降了一倍,权衡利弊,GIL是最好的选择——不是去不掉,而是故意留着的。
- 想让python计算速度快起来,又不想写C?用pypy吧,这才是真正的大杀器。
map 函数一手包办了序列操作、参数传递和结果保存等一系列的操作。
为什么这很重要呢?这是因为借助正确的库,map 可以轻松实现并行化操作。
在 Python 中有个两个库包含了 map 函数: multiprocessing 和它鲜为人知的子库 multiprocessing.dummy.
这里多扯两句: multiprocessing.dummy? mltiprocessing 库的线程版克隆?这是虾米?即便在 multiprocessing 库的官方文档里关于这一子库也只有一句相关描述。而这句描述译成人话基本就是说:”嘛,有这么个东西,你知道就成.”相信我,这个库被严重低估了!
dummy 是 multiprocessing 模块的完整克隆,唯一的不同在于 multiprocessing 作用于进程,而 dummy 模块作用于线程(因此也包括了 Python 所有常见的多线程限制)。
所以替换使用这两个库异常容易。你可以针对 IO 密集型任务和 CPU 密集型任务来选择不同的库。2
动手尝试
使用下面的两行代码来引用包含并行化 map 函数的库:
-
from multiprocessing import Pool
-
from multiprocessing.dummy import Pool as ThreadPool
实例化 Pool 对象:
pool = ThreadPool()
这条简单的语句替代了 example2.py 中 build_worker_pool 函数 7 行代码的工作。它生成了一系列的 worker 线程并完成初始化工作、将它们储存在变量中以方便访问。
Pool 对象有一些参数,这里我所需要关注的只是它的第一个参数:processes. 这一参数用于设定线程池中的线程数。其默认值为当前机器 CPU 的核数。
一般来说,执行 CPU 密集型任务时,调用越多的核速度就越快。但是当处理网络密集型任务时,事情有有些难以预计了,通过实验来确定线程池的大小才是明智的。
pool = ThreadPool(4) # Sets the pool size to 4
线程数过多时,切换线程所消耗的时间甚至会超过实际工作时间。对于不同的工作,通过尝试来找到线程池大小的最优值是个不错的主意。
创建好 Pool 对象后,并行化的程序便呼之欲出了。我们来看看改写后的 example2.py
-
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/',
-
'http://www.python.org/getit/',
-
'http://www.python.org/community/',
-
'https://wiki.python.org/moin/',
-
'http://planet.python.org/',
-
'https://wiki.python.org/moin/LocalUserGroups',
-
'http://www.python.org/psf/',
-
'http://docs.python.org/devguide/',
-
'http://www.python.org/community/awards/'
-
# etc..
-
]
-
-
# Make the Pool of workers
-
pool = ThreadPool( 4)
-
# Open the urls in their own threads
-
# and return the results
-
results = pool.map(urllib2.urlopen, urls)
-
#close the pool and wait for the work to finish
-
pool.close()
-
pool.join()
实际起作用的代码只有 4 行,其中只有一行是关键的。map 函数轻而易举的取代了前文中超过 40 行的例子。为了更有趣一些,我统计了不同方法、不同线程池大小的耗时情况。
-
# results = []
-
# for url in urls:
-
# result = urllib2.urlopen(url)
-
# results.append(result)
-
-
# # ------- VERSUS ------- #
-
-
# # ------- 4 Pool ------- #
-
# pool = ThreadPool(4)
-
# results = pool.map(urllib2.urlopen, urls)
-
-
# # ------- 8 Pool ------- #
-
-
# pool = ThreadPool(8)
-
# results = pool.map(urllib2.urlopen, urls)
-
-
# # ------- 13 Pool ------- #
-
-
# pool = ThreadPool(13)
-
# results = pool.map(urllib2.urlopen, urls)
-
-
结果:
-
-
# Single thread: 14.4 Seconds
-
# 4 Pool: 3.1 Seconds
-
# 8 Pool: 1.4 Seconds
-
# 13 Pool: 1.3 Seconds
很棒的结果不是吗?这一结果也说明了为什么要通过实验来确定线程池的大小。在我的机器上当线程池大小大于 9 带来的收益就十分有限了。
另一个真实的例子
生成上千张图片的缩略图
这是一个 CPU 密集型的任务,并且十分适合进行并行化。
基础单进程版本
-
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)
上边这段代码的主要工作就是将遍历传入的文件夹中的图片文件,一一生成缩略图,并将这些缩略图保存到特定文件夹中。
这我的机器上,用这一程序处理 6000 张图片需要花费 27.9 秒。
如果我们使用 map 函数来代替 for 循环:
-
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()
5.6 秒!
虽然只改动了几行代码,我们却明显提高了程序的执行速度。在生产环境中,我们可以为 CPU 密集型任务和 IO 密集型任务分别选择多进程和多线程库来进一步提高执行速度——这也是解决死锁问题的良方。此外,由于 map 函数并不支持手动线程管理,反而使得相关的 debug 工作也变得异常简单。
到这里,我们就实现了(基本)通过一行 Python 实现并行化。