分类目录:《系统学习Python》总目录
在文章《并发模型和异步编程:基础知识》我们简单介绍了Python中的进程、线程和协程。本文就着重介绍Python中的进程、线程和GIL的关系。
Python解释器的每个实例都是一个进程。使用multiprocessing
或concurrent.futures
库可以启动额外的Python进程。Python的subprocess
库用于启动运行外部程序(不管使用何种语言编写)的进程。而Python解释器仅使用一个线程运行用户的程序和内存垃圾回收程序。使用threading
或concurrent.futures
库可以启动额外的Python线程。对对象引用计数和解释器其他内部状态的访问受一个锁的控制,这个锁就是全局解释器锁(Global Interpreter Lock,GIL)。任意时间点上只有一个Python线程可以持有GIL。这意味着,任意时间点上只有一个线程能执行Python代码,与CPU核数量无关。为了防止一个Python线程无限期持有GIL,Python的字节码解释器默认每5毫秒暂停当前Python线程,释放GIL。被暂停的线程可以再次尝试获得GIL,但是如果有其他线程等待,那么操作系统调度程序可能会从中挑选一个线程开展工作。我们编写的Python代码无法控制GIL。但是,耗时的任务可由内置函数或C语言(以及其他能在Python/C API层级接合的语言)扩展释放GIL。Python标准库中发起系统调用的函数均可释放GIL。这包括所有执行磁盘I/O、网络I/O的函数,以及time.sleep()
。NumPy
/SciPy
库中很多CPU密集型函数,以及zlib
和bz2
模块中执行压缩和解压操作的函数,也都释放GIL。在Python/C API层级集成的扩展也可以启动不受GIL影响的非Python线程。这些不受GIL影响的线程无法更改Python对象,但是可以读取或写入内存中支持缓冲协议的底层对象,例如bytearray
、array.array
和NumPy
数组。
GIL对使用Python线程进行网络编程的影响相对较小,因为I/O函数释放GIL,而且与内存读写相比,网络读写的延迟始终很高。各个单独的线程无论如何都要花费大量时间等待,所以线程可以交错执行,对整体吞吐量不会产生重大影响。正如David Beazley所言:“Python线程非常擅长什么都不做。对GIL的争用会降低计算密集型Python线程的速度。对于这类任务,在单线程中依序执行的代码更简单,速度也更快。若想在多核上运行CPU密集型Python代码,必须使用多个Python进程。threading
模块的文档对此做了很好的概括。
由于CPython有GIL,因此同一时间只有一个线程能执行Python代码(尽管有些旨在提升性能的库可以克服这个限制)。如果我们希望应用程序充分地利用多核设备的计算资源,那么建议使用multiprocessing
或concurrent.futures.ProcessPoolExecutor
。然而,如果我们想同时运行多个I/O密集型任务,那么线程仍是最合适的模型。前一段开头指出那是“CPython实现细节”,因为GIL不是Python语言规定的机制。Jython和IronPython就没有GIL。可惜,二者落后较多,还停留在Python2.7。高性能的PyPy解释器的2.7和3.7版本也有GIL。
本文没有提到协程,因为默认情况下,协程共用同一个Python线程,而且受异步框架提供的事件循环监管,所以不受GIL影响。在异步程序中也可以使用多个线程,但是最佳实践是在同一个线程中运行事件循环和所有协程,其他线程负责执行特定的任务。
参考文献:
[1] Mark Lutz. Python学习手册[M]. 机械工业出版社, 2018.
[2] 卢西亚诺·拉马略.流畅的Python 第2版(全2册) 编程语言[M].人民邮电出版社,2023.