Python 并行计算那点事(第1部分) -- 译文 [原创]

2021年5月4日 2点热度 0条评论

Python 并行计算的那点事(第1部分)(The Python Concurrency Story - Part 1)

英文原文:https://powerfulpython.com/blog/python-concurrency-story-pt1/
本文:https://www.cnblogs.com/popapa/p/python_concurrency1.html
采集日期:2021-05-02

以编写软件为业有一件事很不错,就是能让人保持谦卑。我一度以为自己很聪明,并对此有点洋洋自得。直到开始每天写代码的日子,才发现并非如此。海森堡 Bug(Heisenbug)就像一个小瘟神一般,静静地等着我狂妄自大的那一刻。。。突然一个 Bug 就被我放了进来,为了找到它花了3个小时,修复却只消1行代码。

当然,对于很多人来说,能让我们保持谦卑的缘由就是并行计算(Concurrency)。从现在开始,无论对并行计算喜欢与否,作为专业的软件工程师,我们都不得不对它做出妥善的考虑和理解。这就需要研发出一些思维模型,能够对其进行清晰地演绎,并且掌握一些为了完成工作所必需的软件工具。理想情况下,还能渐渐学会少发生一些那种找找3小时、修修1行代码的 Bug。

虽然基本原理是通用的,但软件如何实现在很大程度上取决于所用的开发语言。为了实现并行计算,每种开发语言都有着各自的抽象、语法和支持库。本文介绍 Python 的并行计算。。。从某种意义上说,这是一种世界观,用来处理同时发生的多件事情。在21世纪,了解并行计算的来龙去脉能让您获益匪浅。

对并行计算表现能力最强大的可能就是 C 语言了[1]。用 C 语言实现的并行计算,能够真正挑战计算机的物理极限。因为可以利用一些非常底层的系统调用,类似于 Linux 中的clone(),其实那就是用来实现线程的。有了这些利器,相当于掌控了整台虚拟设备!

高级语言通常不会给出那么高的自由度,以便换取很多其他的便利。比如,普通的 PHP 根本不允许创建线程。并且在进程级别上干活的工作量会很大。谢天谢地,现在不是用 PHP 编写代码,而是用 Python,它自带了一套很有意思的并行计算体系。只要完全理解了这套体系,您就会成为全球排名前1%的 Python 程序员。

为了实现上述目标,有必要真正弄明白由现代操作系统提供给 Python 使用的并发原语(Primitive)。理解了这一点,不仅会让您成为更好的 Python 程序员,还将提升您这辈子所有语言的开发水平

太酷了吧!兴奋吗?我就很兴奋。那就开始吧!

(顺便说一句,最让人兴奋的部分是深入探讨何时线程不是真正的线程。您到时候自然就明白了。)

进程和线程(Processes and Threads)

就从基础知识开始吧。现代操作系统为执行线程提供了两种组织方式。进程就是一个正在运行的程序。线程是进程中的活动单位。这就为如何实现并行计算提供了两种基本的选择:N个进程或N个线程。

如果做些深入的了解,线程和进程确实有很多相似之处。其实在 Linux 中,之前提到的系统调用 clone() 既可以用于创建进程,也可以用来创建线程,只要调用函数时提供不同的参数即可。

在实操时,两者的主要区别在于共享和不共享的东西不同。如果一个进程创建了两个子进程,则每个子进程都拥有自己的内存,没有什么共享的东西。(默认如此,有参数可以进行修改。)而新的线程不仅会共享其父进程的内存,还会共享文件描述符、文件系统的上下文和信号处理过程。

采用多线程而不是多进程,有一些实实在在的好处。线程在内存占用方面要轻量得多。同样是守护程序,相比生成10个线程而言,显然生成10个不共享内存的子进程会占用更多的内存空间。此外,线程之间的通信和同步都比进程要简单。根据定义,进程间的任何通信都要用到 IPC 调用,因此还会带来切入内核态的开销。当然可以在进程之间共享内存,但工作量要比线程多些[2]

线程的悲哀(The Tragedy of Threads)

简而言之,在多线程和多进程这两个并行模型中,理论上用线程可以写出性能更高的应用程序。

哦哦,我说的只是“理论上”吧?没错。我们会被带到沟里去:编写无错的多线程代码非常困难。您会遭遇各种微妙的、令人困惑的 Bug,想要很容易就重现这些 Bug 需要靠运气,不走运的话就难了。竞态条件(Race Condition)、死锁(Deadlock)、活锁(Live Lock)等等,还有很多。

解决这些问题的代价就是耗费开发时间。安全的线程编程涉及到规范地使用同步原语(Synchronization Primitive),诸如锁和互斥锁(Mutex)。作为一名优秀的软件工程师,这是应付不时之需的工具包(如果您还没有的话)。但不必这么做总还是最好的。

(等等,这是前兆吗?我觉得是吧。。。)

此外还需要考虑一点,也是 Python 所特有的:Python 的线程并不完全像它看上去的那样。

何时线程不是真正的线程(When Threads Aren't Really Threads)

上述线程实际上指的是操作系统线程(OS Thread)。在用 C 语言编写程序时,调用pthread_create(OS X,Linux)或CreateThread(Windows)即可获得这种线程。这是真正的线程,是由操作系统内核分配和管理的。但如果用 Python 这种高级语言创建线程,就不一定了,至少不完全是。

在较新版的 Python 中,只要线创建threading.Thread的实例,然后调用其start()方法即可启动线程。已启动的线程确实分到了一个独立的操作系统线程,在大多数平台上确实如此[3]。两者的区别在于:两个操作系统线程可以同时运行,以充分利用各自独立的核心或 CPU。但一般情况下两个 Python 线程却无法同时运行。

这是全局解释器锁导致的,也即 GIL。标准 Python 中存在一种机制,同时只允许1个线程运行 Python 字节码[4]。即便是在128核的野兽级机器上运行,标准的多线程 Python 程序在任一时刻也只能用到其中1个核。[5]

乍一看这似乎很糟糕,但事实证明,对于大部分工程领域而言,GIL 根本就不算什么重大限制。就纯粹的 CPU 性能而言[6],Python 的线程确实有些不如操作系统线程。不过 Python 进程完全不受 GIL 的影响,进程就是我们的出路,也是拯救处理器受限(CPU-Bound)任务的出路。第2部分将会讨论进程。

  1. 其实汇编语言的表现能力更强。不过当前很少有人需要用到这么底层的东西了,在可能遭遇的情形下,C 的并行计算能力几乎一样强大。 ↩︎

  2. 在较新的 Linux 中,我知道的最佳方案是mmap(),当然其他几种机制也是可行的。 ↩︎

  3. 某些内核不支持线程的操作系统除外。我希望您不必为此担心。 ↩︎

  4. 引入 GIL 是一个非常好的决定。这样解释器的实现难度就降低了数量级,同时在通常的单线程情况下又维持住了性能。别忘了,Python 是由志愿者提供的。 ↩︎

  5. 其实这只说对了大约98%。解决方案有很多,具体取决于您愿意投入多大的精力。用 C 或 C++编写的扩展模块可以临时释放 GIL。因此,像 numpy 这样的软件包并受单核运行的限制。
    而且,至少还有其他 Python 解释器(Jython、IronPython、PyPy)具有实验性的不带 GIL 的分支(Branch)发布。不过对于您实际编写的纯 Python 代码,超过99%都只能完全用足一个 CPU 核。 ↩︎

  6. 当然,那些并没有接近 CPU 极限的线程,可以采用一些非常有用的编程模式,因此 GIL 甚至都无需多虑。建立一个响应式(Responsive)UI 就是个很好的例子:主线程可以迅速地卖力干活,而辅助线程则负责监测用户的输入、停止计算或执行其他操作。 ↩︎