参考文章:https://www.peterbe.com/plog/fastest-way-to-unzip-a-zip-file-in-python Peterbe
前言
根据原文作者的文章内容,我做了一个详细的原理分析及相关知识拓展,来解释为什么这样能最快解压zip
情景设计
服务场景假设:一个 zip
文件被上传到一个Web 服务中,然后 Python
需要解压这个 zip
文件然后分析和处理其中的每个文件。这个特殊的应用查看每个文件各自的名称和大小,并和已经上传到 AWS S3
上的文件进行比较,如果文件(和 AWS S3
上的相比)有所不同或者文件本身更新,那么就将它上传到 AWS S3
。
挑战在于这些 zip
文件太大了。它们的平均大小是 560MB 但是其中一些大于 1GB。这些文件中大多数是文本文件,但是其中同样也有一些巨大的二进制文件。不同寻常的是,每个 zip 文件包含 100 个文件但是其中 1-3 个文件却占据了多达 95% 的 zip
文件大小。
最开始我尝试在内存中解压文件,并且每次只处理一个文件。在各种内存爆炸和 EC2 耗尽内存的情况下,这个方法壮烈失败了。我觉得这个原因是这样的。最开始你有 1GB
文件在内存中,然后你现在解压每个文件,在内存中大约就要占用 2-3GB
。所以,在很多次测试之后,解决方案是将这些 zip 文件复制到磁盘上(在临时目录 /tmp 中),然后遍历这些文件,这里就使用到了tempfile.TemporaryFile。
tempfile.TemporaryFile创建临时文件
如何你的应用程序需要一个临时文件来存储数据,但不需要同其他程序共享,那么用TemporaryFile函数创建临时文件是最好的选择。其他的应用程序是无法找到或打开这个文件的,因为它并没有引用文件系统表。用这个函数创建的临时文件,关闭后会自动删除。
这个例子说明了普通创建文件的方法与TemporaryFile()的不同之处
注意:用TemporaryFile()创建的文件没有文件名
搞定了内存限制问题姐这回到我们的正题,如何最快解压?
原始函数
首先是下面这些模拟对 zip 文件中文件实际操作的普通函数:
这个函数花费时间 40% 运行 extractall,60% 的时间在遍历各个文件并读取其长度。
zipfile库
class zipfile.ZipFile(file[, mode[, compression[, allowZip64]]])
参数file表示文件的路径或类文件对象(file-like object);
参数mode指示打开zip文件的模式:
- 默认值为’r’,表示读已经存在的zip文件
- 'w’表示新建一个zip文档或覆盖一个已经存在的zip文档
- 'a’表示将数据附加到一个现存的zip文档中
参数compression表示在写zip文档时使用的压缩方法,它的值可以是zipfile. ZIP_STORED 或zipfile. ZIP_DEFLATED
注意:如果要操作的zip文件大小超过2G,应该将allowZip64设置为True。
尝试使用线程
第一步尝试是使用线程。先创建一个 zipfile.ZipFile
的实例,展开其中的每个文件名,然后为每一个文件开始一个线程。每个线程都给它一个函数来做“实质工作”(在这个基准测试中,就是遍历每个文件然后获取它的名称)。实际业务中的函数进行的工作是复杂的 S3
、Redis
和 PostgreSQL
操作,但是在我的基准测试中我只需要制作一个可以找出文件长度的函数就好了。线程池函数:
先来聊聊GIL
何为GIL
?GIL
,全局解释器锁。
要理解GIL
的含义,我们需要从Python
的基础讲起。像C++
这样的语言是编译型语言,所谓编译型语言,是指程序输入到编译器,编译器再根据语言的语 法进行解析,然后翻译成语言独立的中间表示,最终链接成具有高度优化的机器码的可执行程序。编译器之所以可以深层次的对代码进行优化,是因为它可以看到整个程序(或者一大块独立的部分)。这使得它可以对不同的语言指令之间的交互进行推理,从而给出更有效的优化手段。
与此相反,Python
是解释型语言。程序被输入到解释器来运行。解释器在程序执行之前对其并不了解;它所知道的只是Python
的规则,以及在执行过程 中怎样去动态的应用这些规则。它也有一些优化,但是这基本上只是另一个级别的优化。由于解释器没法很好的对程序进行推导,Python
的大部分优化其实是 解释器自身的优化。
现在我们来看一下问题的症结所在。要想利用多核系统,Python
必须支持多线程运行。作为解释型语言,Python的解释器必须做到既安全又高效。我们都知道多线程编程会遇到的问题,解释器要留意的是避免在不同的线程操作内部共享的数据,同时它还要保证在管理用户线程时保证总是有最大化的计算资源。
那么,不同线程同时访问时,数据的保护机制是怎样的呢?答案是解释器全局锁。从名字上看能告诉我们很多东西,很显然,这是一个加在解释器上的全局(从解释器的角度看)锁(从互斥或者类似角度看)。这种方式当然很安全,但是它有一层隐含的意思(Python
初学者需要了解这个):对于任何Python
程序,不管有多少的处理器,任何时候都总是只有一个线程在执行。
”为什么我全新的多线程Python程序运行得比其只有一个线程的时候还要慢?“许多人在问这个问题时还是非常犯晕的,因为显然一个具有两个线程的程序要比其只有一个线程时要快(假设该程序确实是可并行的)。事实上,这个问题被问得如此频繁以至于Python的专家们精心制作了一个标准答案:”不要使用多线程,请使用多进程”。
所以,对于计算密集型的,我还是建议不要使用python的多线程而是使用多进程方式,而对于IO密集型的,还是劝你使用多进程方式,因为使用多线程方式出了问题,最后都不知道问题出在了哪里,这是多么让人沮丧的一件事情!
进一步阅读理解可参考,
深入理解 GIL:如何写出高性能及线程安全的 Python 代码
再来聊聊concurrent.futures
聊完了GIL
,我们再来聊聊concurrent.futures
python
因为其全局解释器锁GIL
而无法通过线程实现真正的平行计算。这个论断我们不展开,但是有个概念我们要说明,IO密集型
vs. 计算密集型
。
- IO密集型:读取文件,读取网络套接字频繁。
- 计算密集型:大量消耗CPU的数学与逻辑运算,也就是我们这里说的平行计算。
而concurrent.futures
模块,可以利用multiprocessing
实现真正的平行计算。
核心原理是:concurrent.futures
会以子进程的形式,平行的运行多个python
解释器,从而令python
程序可以利用多核CPU
来提升执行速度。由于子进程与主解释器相分离,所以他们的全局解释器锁也是相互独立的。每个子进程都能够完整的使用一个CPU
内核。
这里用一个简单的例子做一个拓展:
求最大公约数
这个函数是一个计算密集型的函数
不使用多线程/多进程
使用多线程ThreadPoolExecutor
使用多进程ProcessPoolExecutor
多进程程序,比其他两个版本都快。这是因为,ProcessPoolExecutor
类会利用multiprocessing
模块所提供的底层机制,完成下列操作:
- 把
numbers
列表中的每一项输入数据都传给map
。 - 用
pickle
模块对数据进行序列化,将其变成二进制形式。 - 通过本地套接字,将序列化之后的数据从煮解释器所在的进程,发送到子解释器所在的进程。
- 在子进程中,用
pickle
对二进制数据进行反序列化,将其还原成python
对象。 - 引入包含
gcd
函数的python
模块。 - 各个子进程并行的对各自的输入数据进行计算。
- 对运行的结果进行序列化操作,将其转变成字节。
- 将这些字节通过
socket
复制到主进程之中。 - 主进程对这些字节执行反序列化操作,将其还原成
python
对象。 - 最后,把每个子进程所求出的计算结果合并到一份列表之中,并返回给调用者。
multiprocessing
开销比较大,原因就在于:主进程和子进程之间通信,必须进行序列化和反序列化的操作。
反过来说不可以pickle序列化是无法使用多进程的
改用ProcessPoolExecutor多进程
最自然的想法是尝试使用多进程在多个 CPU 上分配工作。但是这样做有缺点,那就是你不能传递一个非可 pickle 序列化的对象(LCTT 译注:意为只有可 pickle 序列化的对象可以被传递),所以你只能发送文件名到之后的函数中:
此博客源码文件
https://github.com/q1271964185/blogcode/tree/master/79525936