Python编程Python 开发人员

为什么在单个 **Python** 进程中的多个 **线程** 尽管主机系统有多个核心可用,仍然在一个 CPU 内核上顺序执行?

用 Hintsage AI 助手通过面试

问题的回答

Python全局解释器锁 (GIL) 是一种互斥锁,它保护对 Python 对象的访问,确保在任何时刻只有一个线程执行 Python 字节码。这个设计决定是在 CPython 中作出的,以简化内存管理并防止对象引用计数上的竞争条件。因此,即使在多核处理器上,线程 也不能并行运行 Python 代码;相反,它们在单个核心上快速切换执行,使得 CPU 密集型多线程失效。

import threading import multiprocessing import time def cpu_intensive_task(n): """纯 Python CPU 密集型操作""" count = 0 for i in range(n): count += i ** 2 return count # 演示线程的限制 start = time.time() threads = [threading.Thread(target=cpu_intensive_task, args=(5_000_000,)) for _ in range(4)] for t in threads: t.start() for t in threads: t.join() print(f"线程时间:{time.time() - start:.2f}s") # 输出显示由于 GIL 竞争,时间大约是单线程的 4 倍。 start = time.time() processes = [multiprocessing.Process(target=cpu_intensive_task, args=(5_000_000,)) for _ in range(4)] for p in processes: p.start() for p in processes: p.join() print(f"多处理时间:{time.time() - start:.2f}s") # 输出显示约为单线程的 1 倍(速度提升 4 倍)

生活中的情况

问题: 我们的分析平台需要处理 10GB 的日志文件,进行复杂的正则表达式提取和统计计算。工程团队在一台 16 核服务器上实现了基于 线程 的工作池,使用 concurrent.futures.ThreadPoolExecutor,并进行了 16 个 线程 的操作。令人惊讶的是,CPU 利用率保持在 6-7%(一个核心),处理花费了 3 个小时,而顺序处理仅花费 45 分钟。GIL 强制进行顺序执行,并增加了线程切换的开销。

解决方案 1:使用 C 扩展优化 线程 我们评估了将重计算移到 NumPy 操作,并使用在执行期间释放 GIL 的 C 加速库。

优点: 代码更改最小;共享内存消除序列化成本;由于线程共享地址空间,内存占用更低。

缺点: 限于 NumPy 支持的操作;自定义算法仍需要执行 Python 字节码;调试 C 扩展交互增加了复杂性。

解决方案 2:基于进程的并行处理使用 multiprocessing 我们考虑切换到 multiprocessing.Poolconcurrent.futures.ProcessPoolExecutor,启动单独的 Python 解释器。

优点: 真正的并行性,利用所有 CPU 内核;对于 CPU 密集型任务线性可扩展;隔离完全防止 GIL 竞争。

缺点: 内存使用增加(每个进程加载单独的 Python 解释器 ~50-100MB);数据必须在进程间通信时进行序列化/反序列化;进程启动延迟开销。

选择的解决方案: 我们选择使用 multiprocessing 进行分块数据处理。日志被分成 16 个段,由 ProcessPoolExecutor 处理,并合并结果。分块策略通过减少进程间通信频率来最小化 pickle 开销。

结果: 处理时间从 3 小时减少到 4 分钟(速度提升 45 倍)。所有 16 个核心的 CPU 利用率达到 98%。每个进程的内存使用增加了 800MB(总共 12.8GB),在我们 128GB 的服务器上是可以接受的。我们实现了一个进程池单例,以在多个批处理作业之间摊销启动成本。

候选人常常遗漏的内容


为什么 GIL 不会影响 I/O 密集型 线程 的性能?

许多候选人错误地认为 线程Python 中完全无用。GIL 在 I/O 操作(网络请求、磁盘读取、数据库查询)和显式释放它的 C 扩展调用(如 NumPy 矩阵操作)时被释放。当一个 线程 因 I/O 而阻塞时,其他 线程 可以执行 Python 代码。因此,线程 在并发 I/O 执行方面仍然非常有效,例如在 asyncio 基于服务器的网页抓取或处理成千上万的同时连接。


PyPyJython 这样的替代 Python 实现是否有 GIL

候选人往往认为移除 GIL 仅仅是使用不同的解释器的问题。PyPy(JIT 编译的 Python)也实现了 GIL 以保持线程安全,尽管它的不同对象模型可以使线程切换更有效。然而,Jython(运行在 JVM 上)和 IronPython(运行在 .NET CLR 上)没有 GIL,因为它们依赖于底层虚拟机的垃圾收集和线程原语,从而实现真正的线程级并行性。


是否可以在不生成新进程的情况下手动释放 GIL

许多开发者不知道在 C 扩展中手动管理 GIL。在编写 Cython 或 C 代码时,可以使用 Py_BEGIN_ALLOW_THREADSPy_END_ALLOW_THREADS 宏显式释放 GIL,以围绕长时间运行的计算。此外,Python 3.12+ 引入了每个解释器 GIL(PEP 684),允许在一个进程内使用单独的 GIL 的子解释器,尽管这需要实验性的 interpreters 模块,并且不直接在解释器之间共享对象。