说到Python中最复杂、最核心的内容,非多线程(进程)莫属了。初学者很可能会将多线程和多进程混起来,而其实Python中两者适合处理的任务不同。另外由于多进程的实现具有很强的系统相关性,很多使用了多进程的代码可能换个系统就不能正常跑了。今天我们就要搞清楚究竟两者有什么区别,到底是什么原因造成了多进程代码的在不同系统上的差异性,以及在各种情况下应该采取的解决方案。上篇主要会解释理论部分,也就是前面的两点,最后的解决方案会在下篇中介绍。
简介:多线程与多进程
看到多线程和多进程,不熟悉的人可能会认为两者是同一个东西,其实两者大有不同。其实看他们原本的英语表达就知道了,多线程是multi threading,而多进程是 multi processing。线程与进程的区别我们在操作系统中已经学习过了,简而言之,进程可以包含多个线程,而线程是进程的一个实体。开线程的代价比开进程的代价小,而且便于通信,但是多进程更稳定一些,毕竟一个线程crash了,整个进程都会挂,而多进程之间通常是独立的。有人看到这可能有疑问,如果我处理好线程安全性的问题,那直接用多线程不就好了。
事情远没有这么简单,由于Python的内存操作并不是线程安全的[1],Python(此处指大家广泛使用的Python发行版CPython)的设计者对于多线程的操作加了一把锁。这把锁被称为GIL(Global Interpreter Lock)。当然对于不涉及到线程安全的一些操作来说不受影响,主要是那些IO密集型的操作,以及Numpy中的一些运算。也就是说,如果你需要在Python中用并行来加速计算密集型任务的执行的话,那么至少在GIL没有被取消掉的当下,用多线程是走不通的。另外根据官方文档的介绍[1],会发生一件很有趣的事情,由于系统调度的原因,在多核处理器下执行多线程的计算,会比不使用多线程还要慢。
由于GIL的存在,如果我们需要使用多核来对计算密集型任务进行加速的话,那么我们不能使用多线程,而不得不去使用多进程。但是Python的设计在这里很有迷惑性,他把线程和进程的接口设计的几乎一模一样,除了进程还可以强制终结和一些特有的属性之外,没有任何差别。这给人造成一种错觉,好像是多进程和多线程之间可以无缝替换,当然这可能是设计者的愿景,而其实两者之间的区别是巨大的。前面已经介绍过了,举个具体的例子,比如说数据的流动,在线程中只要指定好就可以了,但是进程间的数据流动一般来说没有那边简单,一般都需要采用一些外部的方式来解决,如Socket,Pipe,共享内存等。另外,由于Unix/Windows可使用的多进程实现方式不同,也导致了多进程的代码具有很强的系统依赖性。
多进程在不同系统下的实现方式
首先先来介绍下Python多进程的实现方式[2]。Unix系统下默认的实现方式是fork,而fork可以将进程复制一份,子进程可以执行与主程序不同的函数,此外,这种方式生成的进程继承了父进程的数据,所以数据可以方便的从父进程流动到子进程。而在Windows上不支持fork,而是要使用spawn。spawn其实也是将进程复制一份,但是进程会重新执行一遍主函数里面的代码,就像父进程一样,然后再去执行相应的函数。所以这就会导致一个问题就是如果我们不加任何判断的话,这个进程会不断的复制自身,形成新的进程。Python的设计者当然考虑到了这一点,所以如果你在spawn进程的初始阶段还尝试创建新进程的话,会报错退出。怎么区别主进程和父进程呢?一般会采用__name__属性来区分。
我们用一段代码来验证一下之前讲的这些结论:
import multiprocessing as mp
import os
# Run phase
def v():
print('Run', os.getpid())
print(__name__, os.getpid())
# Initialization phase
print('Initialize', os.getpid())
print(__name__, os.getpid())
if __name__ == '__main__':
# start a new Process to execute function `v` and wait for it
p = mp.Process(target=v)
p.start()
p.join()
代码在Windows下的输出结果为 (Python 2.7)
('Initialize', 6896)
('__main__', 6896)
('Initialize', 14284)
('__parents_main__', 14284)
('Run', 14284)
('__main__', 14284)
主进程执行的时候,会有将当前模块的名称设置为__main__,而spawn出来的子进程中在初始化阶段该属性则为__parents_main__,而在执行阶段名称又会变回__main__。
Initialize 15088
__main__ 15088
Initialize 13992
__mp_main__ 13992
Run 13992
__mp_main__ 13992
可以发现除了子进程的模块名本身发生了变化以外,在子进程执行阶段的模块名也不再换回__main__。我猜测可能是不想让你尽可能的不要在子进程里面再开子进程吧,当然其实这也节省了开销,毕竟不用做Context的拷贝了。[?]
在Unix环境下输出如下 (Python 2.7)
('Initialize', 21)
('__main__', 21)
('Run', 22)
('__main__', 22)
这个就没有这么复杂了,子进程没有初始化阶段,直接跳到执行部分。模块名保持__main__不变。
当然对于Python 3.4以上的版本来说,Unix下也是可以使用spawn的,他的结果如下:
Initialize 64
__main__ 64
Initialize 66
__mp_main__ 66
Run 66
__mp_main__ 66
可以看到,结果与Windows下完全一致。
看到这或许有人要吐槽Windows了,但是其实fork也有他的不足。首先就是不安全,毕竟他就直接把所有句柄全部复制给子进程了。如果两者间的同步做的不足的话,那么就容易出问题。对于一些典型应用,如数据库,CUDA等等,都不是fork 安全的。另外,fork出来的子进程也不能将它对数据的改动直接传至父进程。也就是说虽然数据拿出来简单,可是要放回去就还是要想办法。
多进程的数据流动方式
前面提到了多线程和多进程采用了统一的接口,他的设计目的当然是想将两者尽可能的无缝切换,所以它实现了一些进程间数据的流动方式,包括Pipe,网络传输和共享内存。Pipe(管道)方式是最常用的一种数据传输方式,比如说我们跑些批处理或者控制台应用程序的时候,使用的输入输出都是使用管道方式传输的。管道方式适合小量数据的传输,因为他是基于缓存的一种传输方式,另外缓存设置的比较小,一个典型的缓存大小的数值是64K [3]。所以如果你尝试用Pipe去传输1G左右的数据就会非常的慢。网络传输我们都比较熟悉了,他的传输速度还是比较快的,但是其实它需要对数据进行序列化和反序列化,所以当数据量上去之后用于数据转换的时间会比较长。最终的解决方案就是共享内存了,他非常适合做大数据量情况下的高速数据传输,缺点就是实现较为复杂。
Python中的多进程内置实现了上述所说的所有方式,我们依次来简单介绍一下,具体怎么使用我们下次再介绍。首先是Pipe,它是multiprocessing中Pool以及Queue的默认传输方式。网络传输方式主要是通过mp库中的Manager来实现,可以通过这个来实现简单的分布式。最后,共享内存方式mp库主要实现了Value和Array,还是比较受限的,更加灵活的结构就需要自己写C++代码来编译了。当然,我们也可以自己来实现文件传输,比如用一系列持久化工具如CPickle,HDF5,joblib等先保存成文件,然后将文件的路径作为参数进行传输即可。
这次的内容基本就讲完了,下次我们会介绍一系列应用场景,然后就着那些应用场景来介绍我们究竟应该怎么去使用多线程和多进程库。
引用
[1] GlobalInterpreterLock - Python Wiki
[2] 17.2. multiprocessing - Process-based parallelism - Python 3.7.0 documentation
[3] Pipe buffer size is 4k or 64k?
以上,就是文章的全部内容啦,如果感觉还意犹未尽的话,可以给我的 Github 主页点个follow、项目加个star或者打赏之类的(滑稽),以后说不定还会再分享一些相关的经验。